remove image tags
This commit is contained in:
parent
8c0a868144
commit
1eb545840e
@ -399,7 +399,6 @@ The API provides the following main endpoints with their authentication and pagi
|
||||
- `skip` (default: 0, min: 0) - Number of items to skip
|
||||
- `limit` (default: 50, min: 1, max: 100) - Number of items per page
|
||||
- `collection_id` (optional) - Filter by collection
|
||||
- `tags` (optional) - Filter by comma-separated tags
|
||||
- **Response includes:** `images`, `total`, `skip`, `limit`
|
||||
|
||||
#### Search Functionality ✅ **Fully Paginated & Protected**
|
||||
@ -410,7 +409,6 @@ The API provides the following main endpoints with their authentication and pagi
|
||||
- `limit` (default: 10, min: 1, max: 50) - Number of results
|
||||
- `threshold` (default: 0.7, min: 0.0, max: 1.0) - Similarity threshold
|
||||
- `collection_id` (optional) - Filter by collection
|
||||
- `tags` (optional) - Filter by comma-separated tags
|
||||
- **Response includes:** `results`, `total`, `limit`, `threshold`, `query`
|
||||
- `POST /api/v1/search` - Advanced search with same pagination
|
||||
- `GET /api/v1/search/similar/{image_id}` - Find similar images with pagination
|
||||
|
||||
@ -118,15 +118,13 @@ client/
|
||||
|
||||
### Image Management
|
||||
- **Upload**: Drag & drop or click to upload images
|
||||
- **Metadata**: Add descriptions and tags to images
|
||||
- **View**: Full-size image viewing with details
|
||||
- **Edit**: Update descriptions and tags
|
||||
- **Edit**: Update descriptions
|
||||
- **Delete**: Remove images with confirmation
|
||||
|
||||
### AI-Powered Search
|
||||
- **Natural Language**: Search using descriptive text
|
||||
- **Similarity Threshold**: Adjust search sensitivity
|
||||
- **Result Filtering**: Filter by tags and metadata
|
||||
- **Search History**: Save and reuse frequent searches
|
||||
|
||||
### Team & User Management
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-upload fa-3x text-primary mb-3"></i>
|
||||
<h5 class="card-title">Upload Images</h5>
|
||||
<p class="card-text">Upload and manage your image collection with metadata and tags.</p>
|
||||
<p class="card-text">Upload and manage your image collection with metadata.</p>
|
||||
<button class="btn btn-primary" onclick="showPage('images')">Get Started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -123,12 +123,9 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Images API
|
||||
async getImages(page = 1, limit = 20, tags = null) {
|
||||
async getImages(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
let endpoint = `/images?skip=${skip}&limit=${limit}`;
|
||||
if (tags) {
|
||||
endpoint += `&tags=${encodeURIComponent(tags)}`;
|
||||
}
|
||||
return this.makeRequest('GET', endpoint);
|
||||
}
|
||||
|
||||
@ -180,17 +177,13 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Search API
|
||||
async searchImages(query, similarityThreshold = 0.7, maxResults = 20, tags = null) {
|
||||
async searchImages(query, similarityThreshold = 0.7, maxResults = 20) {
|
||||
const searchData = {
|
||||
query,
|
||||
similarity_threshold: similarityThreshold,
|
||||
max_results: maxResults
|
||||
};
|
||||
|
||||
if (tags) {
|
||||
searchData.tags = tags;
|
||||
}
|
||||
|
||||
return this.makeRequest('POST', '/search', searchData);
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ function cleanupBlobCache() {
|
||||
}
|
||||
|
||||
// Load images with pagination
|
||||
async function loadImages(page = 1, tags = null) {
|
||||
async function loadImages(page = 1) {
|
||||
if (!config.isConfigured()) {
|
||||
showAlert('Please configure your API settings first.', 'warning');
|
||||
return;
|
||||
@ -42,7 +42,7 @@ async function loadImages(page = 1, tags = null) {
|
||||
container.innerHTML = '<div class="text-center"><div class="loading-spinner"></div> Loading images...</div>';
|
||||
|
||||
try {
|
||||
const response = await apiClient.getImages(page, 20, tags);
|
||||
const response = await apiClient.getImages(page, 20);
|
||||
currentPage = page;
|
||||
totalPages = Math.ceil(response.total / (response.limit || 20));
|
||||
|
||||
@ -93,11 +93,6 @@ async function displayImages(images) {
|
||||
<p class="card-text small text-muted">
|
||||
<i class="fas fa-calendar me-1"></i>${formatDate(image.upload_date)}
|
||||
</p>
|
||||
${image.tags && image.tags.length > 0 ? `
|
||||
<div class="mb-2">
|
||||
${image.tags.map(tag => `<span class="badge bg-secondary me-1">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewImage('${image.id}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
@ -198,12 +193,6 @@ function showUploadModal() {
|
||||
<textarea class="form-control" id="imageDescription" rows="3"
|
||||
placeholder="Describe this image..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="imageTags" class="form-label">Tags</label>
|
||||
<input type="text" class="form-control" id="imageTags"
|
||||
placeholder="Enter tags separated by commas">
|
||||
<div class="form-text">e.g., nature, landscape, sunset</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
@ -268,8 +257,6 @@ async function uploadImage() {
|
||||
}
|
||||
|
||||
const description = document.getElementById('imageDescription').value.trim();
|
||||
const tagsInput = document.getElementById('imageTags').value.trim();
|
||||
const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
||||
|
||||
const uploadButton = document.querySelector('#uploadModal .btn-primary');
|
||||
setLoadingState(uploadButton);
|
||||
@ -278,9 +265,6 @@ async function uploadImage() {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('description', description);
|
||||
if (tags.length > 0) {
|
||||
formData.append('tags', tags.join(','));
|
||||
}
|
||||
|
||||
await apiClient.uploadImage(formData);
|
||||
|
||||
@ -339,12 +323,6 @@ async function viewImage(imageId) {
|
||||
<p><strong>Type:</strong> ${image.content_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
${image.tags && image.tags.length > 0 ? `
|
||||
<div class="mt-3">
|
||||
<h6>Tags</h6>
|
||||
${image.tags.map(tag => `<span class="badge bg-secondary me-1">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
@ -403,12 +381,6 @@ async function editImage(imageId) {
|
||||
<label for="editDescription-${imageId}" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="editDescription-${imageId}" rows="3">${escapeHtml(image.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editTags-${imageId}" class="form-label">Tags</label>
|
||||
<input type="text" class="form-control" id="editTags-${imageId}"
|
||||
value="${image.tags ? image.tags.join(', ') : ''}">
|
||||
<div class="form-text">Enter tags separated by commas</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
@ -437,16 +409,13 @@ async function editImage(imageId) {
|
||||
// Save image changes
|
||||
async function saveImageChanges(imageId) {
|
||||
const description = document.getElementById(`editDescription-${imageId}`).value.trim();
|
||||
const tagsInput = document.getElementById(`editTags-${imageId}`).value.trim();
|
||||
const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
||||
|
||||
const saveButton = document.querySelector(`#editImageModal-${imageId} .btn-primary`);
|
||||
setLoadingState(saveButton);
|
||||
|
||||
try {
|
||||
await apiClient.updateImage(imageId, {
|
||||
description,
|
||||
tags
|
||||
description
|
||||
});
|
||||
|
||||
showAlert('Image updated successfully!', 'success');
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.10.1",
|
||||
"serial": 438,
|
||||
"serial": 445,
|
||||
"lineage": "a183cd95-f987-8698-c6dd-84e933c394a5",
|
||||
"outputs": {
|
||||
"cloud_function_name": {
|
||||
@ -98,16 +98,16 @@
|
||||
"attributes": {
|
||||
"exclude_symlink_directories": null,
|
||||
"excludes": null,
|
||||
"id": "88ee03db0f4c7023c0c620449e167ad27074fdd0",
|
||||
"output_base64sha256": "0p558sP6ikbyrfmva7zGOYklnR/4VRPD1zcl8HZcv8A=",
|
||||
"output_base64sha512": "mal2zoxqjg5lZYruPmffQdDqY9FJONPc5Wnu41NP07LOj/tC+sJAAeQ7tmU0mq8h6SfQE6wwxFeYJuEO1y2xLg==",
|
||||
"id": "0cfb36e4e396f12e3ad2944c44b083bff2224ad5",
|
||||
"output_base64sha256": "uMoV4IM2IuGcRtqeI7wbu3OsTmvDx1ohDDxkEE5NY9U=",
|
||||
"output_base64sha512": "BQB+g3lC0+y5vOx6KHh4AWCeHk3D2nmdgE8JrFaiPlCWV6KsrMdANGyKeZ/aFmvGjbFw7MGQD4s0u/tn+viVAA==",
|
||||
"output_file_mode": null,
|
||||
"output_md5": "58a2b7fe53bb2c8c921405cc965d635c",
|
||||
"output_md5": "b532cf3ff81d62dd7dec013e486931aa",
|
||||
"output_path": "./function-source.zip",
|
||||
"output_sha": "88ee03db0f4c7023c0c620449e167ad27074fdd0",
|
||||
"output_sha256": "d29e79f2c3fa8a46f2adf9af6bbcc63989259d1ff85513c3d73725f0765cbfc0",
|
||||
"output_sha512": "99a976ce8c6a8e0e65658aee3e67df41d0ea63d14938d3dce569eee3534fd3b2ce8ffb42fac24001e43bb665349aaf21e927d013ac30c4579826e10ed72db12e",
|
||||
"output_size": 69765973,
|
||||
"output_sha": "0cfb36e4e396f12e3ad2944c44b083bff2224ad5",
|
||||
"output_sha256": "b8ca15e0833622e19c46da9e23bc1bbb73ac4e6bc3c75a210c3c64104e4d63d5",
|
||||
"output_sha512": "05007e837942d3ecb9bcec7a28787801609e1e4dc3da799d804f09ac56a23e509657a2acacc740346c8a799fda166bc68db170ecc1900f8b34bbfb67faf89500",
|
||||
"output_size": 69764346,
|
||||
"source": [],
|
||||
"source_content": null,
|
||||
"source_content_filename": null,
|
||||
@ -172,7 +172,7 @@
|
||||
"effective_annotations": {
|
||||
"run.googleapis.com/ingress": "all",
|
||||
"run.googleapis.com/ingress-status": "all",
|
||||
"run.googleapis.com/operation-id": "0f195b05-99ac-4d28-b5fe-2d3dea289124",
|
||||
"run.googleapis.com/operation-id": "a9aeb6de-fdd6-43b2-93f8-8b7f72afab4c",
|
||||
"run.googleapis.com/urls": "[\"https://sereact-761163285547.us-central1.run.app\",\"https://sereact-p64zpdtkta-uc.a.run.app\"]",
|
||||
"serving.knative.dev/creator": "johnpccd3@gmail.com",
|
||||
"serving.knative.dev/lastModifier": "johnpccd3@gmail.com"
|
||||
@ -182,14 +182,14 @@
|
||||
"goog-terraform-provisioned": "true"
|
||||
},
|
||||
"generation": 1,
|
||||
"labels": {},
|
||||
"labels": null,
|
||||
"namespace": "gen-lang-client-0424120530",
|
||||
"resource_version": "AAY189oNgAQ",
|
||||
"resource_version": "AAY19MELEOc",
|
||||
"self_link": "/apis/serving.knative.dev/v1/namespaces/761163285547/services/sereact",
|
||||
"terraform_labels": {
|
||||
"goog-terraform-provisioned": "true"
|
||||
},
|
||||
"uid": "20e61eb3-6217-40e8-8ae5-45111d31bbda"
|
||||
"uid": "8c8be11c-c607-4caa-a65e-c552ec445882"
|
||||
}
|
||||
],
|
||||
"name": "sereact",
|
||||
@ -216,14 +216,14 @@
|
||||
"type": "RoutesReady"
|
||||
}
|
||||
],
|
||||
"latest_created_revision_name": "sereact-00001-2lz",
|
||||
"latest_ready_revision_name": "sereact-00001-2lz",
|
||||
"latest_created_revision_name": "sereact-00001-z4g",
|
||||
"latest_ready_revision_name": "sereact-00001-z4g",
|
||||
"observed_generation": 1,
|
||||
"traffic": [
|
||||
{
|
||||
"latest_revision": true,
|
||||
"percent": 100,
|
||||
"revision_name": "sereact-00001-2lz",
|
||||
"revision_name": "sereact-00001-z4g",
|
||||
"tag": "",
|
||||
"url": ""
|
||||
}
|
||||
@ -256,8 +256,8 @@
|
||||
"container_concurrency": 80,
|
||||
"containers": [
|
||||
{
|
||||
"args": [],
|
||||
"command": [],
|
||||
"args": null,
|
||||
"command": null,
|
||||
"env": [
|
||||
{
|
||||
"name": "API_KEY_SECRET",
|
||||
@ -337,7 +337,7 @@
|
||||
"cpu": "1",
|
||||
"memory": "1Gi"
|
||||
},
|
||||
"requests": {}
|
||||
"requests": null
|
||||
}
|
||||
],
|
||||
"startup_probe": [
|
||||
@ -359,7 +359,7 @@
|
||||
"working_dir": ""
|
||||
}
|
||||
],
|
||||
"node_selector": {},
|
||||
"node_selector": null,
|
||||
"service_account_name": "761163285547-compute@developer.gserviceaccount.com",
|
||||
"serving_state": "",
|
||||
"timeout_seconds": 300,
|
||||
@ -440,7 +440,7 @@
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"condition": [],
|
||||
"etag": "BwY189qg+AA=",
|
||||
"etag": "BwY19MG70Fs=",
|
||||
"id": "v1/projects/gen-lang-client-0424120530/locations/us-central1/services/sereact/roles/run.invoker/allUsers",
|
||||
"location": "us-central1",
|
||||
"member": "allUsers",
|
||||
@ -474,7 +474,7 @@
|
||||
"automatic_update_policy": [
|
||||
{}
|
||||
],
|
||||
"build": "projects/761163285547/locations/us-central1/builds/aab08c74-df86-4cd7-9176-4ff267cab3e6",
|
||||
"build": "projects/761163285547/locations/us-central1/builds/ae88c918-6bb3-4aef-a56f-270f48f73049",
|
||||
"docker_repository": "projects/gen-lang-client-0424120530/locations/us-central1/repositories/gcf-artifacts",
|
||||
"entry_point": "process_image_embedding",
|
||||
"environment_variables": {},
|
||||
@ -487,8 +487,8 @@
|
||||
"storage_source": [
|
||||
{
|
||||
"bucket": "gen-lang-client-0424120530-cloud-function-source",
|
||||
"generation": 1748171376287077,
|
||||
"object": "function-source-58a2b7fe53bb2c8c921405cc965d635c.zip"
|
||||
"generation": 1748175166697242,
|
||||
"object": "function-source-b532cf3ff81d62dd7dec013e486931aa.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -508,7 +508,7 @@
|
||||
"pubsub_topic": "projects/gen-lang-client-0424120530/topics/image-processing-topic",
|
||||
"retry_policy": "RETRY_POLICY_RETRY",
|
||||
"service_account_email": "761163285547-compute@developer.gserviceaccount.com",
|
||||
"trigger": "projects/gen-lang-client-0424120530/locations/us-central1/triggers/process-image-embedding-013009",
|
||||
"trigger": "projects/gen-lang-client-0424120530/locations/us-central1/triggers/process-image-embedding-645734",
|
||||
"trigger_region": "us-central1"
|
||||
}
|
||||
],
|
||||
@ -559,7 +559,7 @@
|
||||
"goog-terraform-provisioned": "true"
|
||||
},
|
||||
"timeouts": null,
|
||||
"update_time": "2025-05-25T11:13:04.212724797Z",
|
||||
"update_time": "2025-05-25T12:15:22.215124150Z",
|
||||
"url": "https://us-central1-gen-lang-client-0424120530.cloudfunctions.net/process-image-embedding"
|
||||
},
|
||||
"sensitive_attributes": [
|
||||
@ -809,12 +809,6 @@
|
||||
"zone": "us-central1-a"
|
||||
},
|
||||
"sensitive_attributes": [
|
||||
[
|
||||
{
|
||||
"type": "get_attr",
|
||||
"value": "metadata_startup_script"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "get_attr",
|
||||
@ -848,6 +842,12 @@
|
||||
"type": "get_attr",
|
||||
"value": "disk_encryption_key_raw"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "get_attr",
|
||||
"value": "metadata_startup_script"
|
||||
}
|
||||
]
|
||||
],
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH0sInNjaGVtYV92ZXJzaW9uIjoiNiJ9",
|
||||
@ -875,8 +875,8 @@
|
||||
"database_edition": "STANDARD",
|
||||
"delete_protection_state": "DELETE_PROTECTION_DISABLED",
|
||||
"deletion_policy": "ABANDON",
|
||||
"earliest_version_time": "2025-05-25T10:09:32.175339Z",
|
||||
"etag": "IMC5lO29vo0DMKrW4vCEvY0D",
|
||||
"earliest_version_time": "2025-05-25T11:12:43.126081Z",
|
||||
"etag": "IPjb6fzLvo0DMKrW4vCEvY0D",
|
||||
"id": "projects/gen-lang-client-0424120530/databases/sereact-imagedb",
|
||||
"key_prefix": "",
|
||||
"location_id": "us-central1",
|
||||
@ -1514,21 +1514,21 @@
|
||||
"content_encoding": "",
|
||||
"content_language": "",
|
||||
"content_type": "application/zip",
|
||||
"crc32c": "eCjQFg==",
|
||||
"crc32c": "EgiVnQ==",
|
||||
"customer_encryption": [],
|
||||
"detect_md5hash": "WKK3/lO7LIySFAXMll1jXA==",
|
||||
"detect_md5hash": "tTLPP/gdYt197AE+SGkxqg==",
|
||||
"event_based_hold": false,
|
||||
"generation": 1748170673167525,
|
||||
"id": "gen-lang-client-0424120530-cloud-function-source-function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"generation": 1748174860755303,
|
||||
"id": "gen-lang-client-0424120530-cloud-function-source-function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"kms_key_name": "",
|
||||
"md5hash": "WKK3/lO7LIySFAXMll1jXA==",
|
||||
"md5hexhash": "58a2b7fe53bb2c8c921405cc965d635c",
|
||||
"media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-58a2b7fe53bb2c8c921405cc965d635c.zip?generation=1748170673167525\u0026alt=media",
|
||||
"md5hash": "tTLPP/gdYt197AE+SGkxqg==",
|
||||
"md5hexhash": "b532cf3ff81d62dd7dec013e486931aa",
|
||||
"media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-b532cf3ff81d62dd7dec013e486931aa.zip?generation=1748174860755303\u0026alt=media",
|
||||
"metadata": {},
|
||||
"name": "function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"output_name": "function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"name": "function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"output_name": "function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"retention": [],
|
||||
"self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"source": "./function-source.zip",
|
||||
"storage_class": "STANDARD",
|
||||
"temporary_hold": false,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.10.1",
|
||||
"serial": 436,
|
||||
"serial": 441,
|
||||
"lineage": "a183cd95-f987-8698-c6dd-84e933c394a5",
|
||||
"outputs": {
|
||||
"cloud_function_name": {
|
||||
@ -98,16 +98,16 @@
|
||||
"attributes": {
|
||||
"exclude_symlink_directories": null,
|
||||
"excludes": null,
|
||||
"id": "88ee03db0f4c7023c0c620449e167ad27074fdd0",
|
||||
"output_base64sha256": "0p558sP6ikbyrfmva7zGOYklnR/4VRPD1zcl8HZcv8A=",
|
||||
"output_base64sha512": "mal2zoxqjg5lZYruPmffQdDqY9FJONPc5Wnu41NP07LOj/tC+sJAAeQ7tmU0mq8h6SfQE6wwxFeYJuEO1y2xLg==",
|
||||
"id": "0cfb36e4e396f12e3ad2944c44b083bff2224ad5",
|
||||
"output_base64sha256": "uMoV4IM2IuGcRtqeI7wbu3OsTmvDx1ohDDxkEE5NY9U=",
|
||||
"output_base64sha512": "BQB+g3lC0+y5vOx6KHh4AWCeHk3D2nmdgE8JrFaiPlCWV6KsrMdANGyKeZ/aFmvGjbFw7MGQD4s0u/tn+viVAA==",
|
||||
"output_file_mode": null,
|
||||
"output_md5": "58a2b7fe53bb2c8c921405cc965d635c",
|
||||
"output_md5": "b532cf3ff81d62dd7dec013e486931aa",
|
||||
"output_path": "./function-source.zip",
|
||||
"output_sha": "88ee03db0f4c7023c0c620449e167ad27074fdd0",
|
||||
"output_sha256": "d29e79f2c3fa8a46f2adf9af6bbcc63989259d1ff85513c3d73725f0765cbfc0",
|
||||
"output_sha512": "99a976ce8c6a8e0e65658aee3e67df41d0ea63d14938d3dce569eee3534fd3b2ce8ffb42fac24001e43bb665349aaf21e927d013ac30c4579826e10ed72db12e",
|
||||
"output_size": 69765973,
|
||||
"output_sha": "0cfb36e4e396f12e3ad2944c44b083bff2224ad5",
|
||||
"output_sha256": "b8ca15e0833622e19c46da9e23bc1bbb73ac4e6bc3c75a210c3c64104e4d63d5",
|
||||
"output_sha512": "05007e837942d3ecb9bcec7a28787801609e1e4dc3da799d804f09ac56a23e509657a2acacc740346c8a799fda166bc68db170ecc1900f8b34bbfb67faf89500",
|
||||
"output_size": 69764346,
|
||||
"source": [],
|
||||
"source_content": null,
|
||||
"source_content_filename": null,
|
||||
@ -182,7 +182,7 @@
|
||||
"goog-terraform-provisioned": "true"
|
||||
},
|
||||
"generation": 1,
|
||||
"labels": null,
|
||||
"labels": {},
|
||||
"namespace": "gen-lang-client-0424120530",
|
||||
"resource_version": "AAY189oNgAQ",
|
||||
"self_link": "/apis/serving.knative.dev/v1/namespaces/761163285547/services/sereact",
|
||||
@ -256,8 +256,8 @@
|
||||
"container_concurrency": 80,
|
||||
"containers": [
|
||||
{
|
||||
"args": null,
|
||||
"command": null,
|
||||
"args": [],
|
||||
"command": [],
|
||||
"env": [
|
||||
{
|
||||
"name": "API_KEY_SECRET",
|
||||
@ -337,7 +337,7 @@
|
||||
"cpu": "1",
|
||||
"memory": "1Gi"
|
||||
},
|
||||
"requests": null
|
||||
"requests": {}
|
||||
}
|
||||
],
|
||||
"startup_probe": [
|
||||
@ -359,7 +359,7 @@
|
||||
"working_dir": ""
|
||||
}
|
||||
],
|
||||
"node_selector": null,
|
||||
"node_selector": {},
|
||||
"service_account_name": "761163285547-compute@developer.gserviceaccount.com",
|
||||
"serving_state": "",
|
||||
"timeout_seconds": 300,
|
||||
@ -474,7 +474,7 @@
|
||||
"automatic_update_policy": [
|
||||
{}
|
||||
],
|
||||
"build": "projects/761163285547/locations/us-central1/builds/c627da1f-c247-4d17-8ea8-5c03b518b1aa",
|
||||
"build": "projects/761163285547/locations/us-central1/builds/aab08c74-df86-4cd7-9176-4ff267cab3e6",
|
||||
"docker_repository": "projects/gen-lang-client-0424120530/locations/us-central1/repositories/gcf-artifacts",
|
||||
"entry_point": "process_image_embedding",
|
||||
"environment_variables": {},
|
||||
@ -487,7 +487,7 @@
|
||||
"storage_source": [
|
||||
{
|
||||
"bucket": "gen-lang-client-0424120530-cloud-function-source",
|
||||
"generation": 1748123369545880,
|
||||
"generation": 1748171376287077,
|
||||
"object": "function-source-58a2b7fe53bb2c8c921405cc965d635c.zip"
|
||||
}
|
||||
]
|
||||
@ -508,7 +508,7 @@
|
||||
"pubsub_topic": "projects/gen-lang-client-0424120530/topics/image-processing-topic",
|
||||
"retry_policy": "RETRY_POLICY_RETRY",
|
||||
"service_account_email": "761163285547-compute@developer.gserviceaccount.com",
|
||||
"trigger": "projects/gen-lang-client-0424120530/locations/us-central1/triggers/process-image-embedding-422683",
|
||||
"trigger": "projects/gen-lang-client-0424120530/locations/us-central1/triggers/process-image-embedding-013009",
|
||||
"trigger_region": "us-central1"
|
||||
}
|
||||
],
|
||||
@ -554,12 +554,12 @@
|
||||
"vpc_connector_egress_settings": ""
|
||||
}
|
||||
],
|
||||
"state": "DEPLOYING",
|
||||
"state": "ACTIVE",
|
||||
"terraform_labels": {
|
||||
"goog-terraform-provisioned": "true"
|
||||
},
|
||||
"timeouts": null,
|
||||
"update_time": "2025-05-25T11:06:59.358560449Z",
|
||||
"update_time": "2025-05-25T11:13:04.212724797Z",
|
||||
"url": "https://us-central1-gen-lang-client-0424120530.cloudfunctions.net/process-image-embedding"
|
||||
},
|
||||
"sensitive_attributes": [
|
||||
@ -588,7 +588,6 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjozNjAwMDAwMDAwMDAwLCJkZWxldGUiOjM2MDAwMDAwMDAwMDAsInVwZGF0ZSI6MzYwMDAwMDAwMDAwMH19",
|
||||
"dependencies": [
|
||||
"data.archive_file.function_source",
|
||||
"data.google_project.current",
|
||||
@ -836,7 +835,7 @@
|
||||
},
|
||||
{
|
||||
"type": "get_attr",
|
||||
"value": "disk_encryption_key_rsa"
|
||||
"value": "disk_encryption_key_raw"
|
||||
}
|
||||
],
|
||||
[
|
||||
@ -853,7 +852,7 @@
|
||||
},
|
||||
{
|
||||
"type": "get_attr",
|
||||
"value": "disk_encryption_key_raw"
|
||||
"value": "disk_encryption_key_rsa"
|
||||
}
|
||||
]
|
||||
],
|
||||
@ -882,8 +881,8 @@
|
||||
"database_edition": "STANDARD",
|
||||
"delete_protection_state": "DELETE_PROTECTION_DISABLED",
|
||||
"deletion_policy": "ABANDON",
|
||||
"earliest_version_time": "2025-05-25T10:07:57.305684Z",
|
||||
"etag": "IIOH9r+9vo0DMKrW4vCEvY0D",
|
||||
"earliest_version_time": "2025-05-25T11:07:20.673706Z",
|
||||
"etag": "IIrliOPKvo0DMKrW4vCEvY0D",
|
||||
"id": "projects/gen-lang-client-0424120530/databases/sereact-imagedb",
|
||||
"key_prefix": "",
|
||||
"location_id": "us-central1",
|
||||
@ -1521,21 +1520,21 @@
|
||||
"content_encoding": "",
|
||||
"content_language": "",
|
||||
"content_type": "application/zip",
|
||||
"crc32c": "eCjQFg==",
|
||||
"crc32c": "EgiVnQ==",
|
||||
"customer_encryption": [],
|
||||
"detect_md5hash": "WKK3/lO7LIySFAXMll1jXA==",
|
||||
"detect_md5hash": "tTLPP/gdYt197AE+SGkxqg==",
|
||||
"event_based_hold": false,
|
||||
"generation": 1748170673167525,
|
||||
"id": "gen-lang-client-0424120530-cloud-function-source-function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"generation": 1748174860755303,
|
||||
"id": "gen-lang-client-0424120530-cloud-function-source-function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"kms_key_name": "",
|
||||
"md5hash": "WKK3/lO7LIySFAXMll1jXA==",
|
||||
"md5hexhash": "58a2b7fe53bb2c8c921405cc965d635c",
|
||||
"media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-58a2b7fe53bb2c8c921405cc965d635c.zip?generation=1748170673167525\u0026alt=media",
|
||||
"metadata": {},
|
||||
"name": "function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"output_name": "function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"md5hash": "tTLPP/gdYt197AE+SGkxqg==",
|
||||
"md5hexhash": "b532cf3ff81d62dd7dec013e486931aa",
|
||||
"media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-b532cf3ff81d62dd7dec013e486931aa.zip?generation=1748174860755303\u0026alt=media",
|
||||
"metadata": null,
|
||||
"name": "function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"output_name": "function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"retention": [],
|
||||
"self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-58a2b7fe53bb2c8c921405cc965d635c.zip",
|
||||
"self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-b532cf3ff81d62dd7dec013e486931aa.zip",
|
||||
"source": "./function-source.zip",
|
||||
"storage_class": "STANDARD",
|
||||
"temporary_hold": false,
|
||||
|
||||
334
docs/TESTING.md
334
docs/TESTING.md
@ -1,334 +0,0 @@
|
||||
# SEREACT Testing Guide
|
||||
|
||||
This document provides comprehensive information about testing the SEREACT API, including unit tests, integration tests, and end-to-end tests.
|
||||
|
||||
## Test Types
|
||||
|
||||
SEREACT includes several types of tests to ensure code quality and functionality:
|
||||
|
||||
### 1. Unit Tests (`unit`)
|
||||
- **Purpose**: Test individual components in isolation using mocks
|
||||
- **Speed**: Fast (< 1 second per test)
|
||||
- **Dependencies**: None (uses mocks)
|
||||
- **Location**: `tests/` (excluding `test_e2e.py`)
|
||||
|
||||
### 2. Integration Tests (`integration`)
|
||||
- **Purpose**: Test component interactions with real services
|
||||
- **Speed**: Medium (1-5 seconds per test)
|
||||
- **Dependencies**: Real database connections
|
||||
- **Location**: `tests/integration/`
|
||||
|
||||
### 3. End-to-End Tests (`e2e`)
|
||||
- **Purpose**: Test complete user workflows from API to database
|
||||
- **Speed**: Medium to slow (2-10 seconds per test)
|
||||
- **Dependencies**: **Self-contained with artificial test data**
|
||||
- **Location**: `tests/test_e2e.py`
|
||||
|
||||
### 4. Real Database Tests (`realdb`)
|
||||
- **Purpose**: Test performance and scalability with real database
|
||||
- **Speed**: Slow (5-30 seconds per test)
|
||||
- **Dependencies**: Real database with artificial test data
|
||||
- **Location**: `tests/test_e2e.py` (marked with `@pytest.mark.realdb`)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Run all tests (recommended for development)
|
||||
python scripts/run_tests.py all
|
||||
|
||||
# Run only unit tests (fastest)
|
||||
python scripts/run_tests.py unit
|
||||
|
||||
# Run E2E tests (completely self-contained)
|
||||
python scripts/run_tests.py e2e
|
||||
|
||||
# Run with coverage report
|
||||
python scripts/run_tests.py coverage
|
||||
```
|
||||
|
||||
### Using pytest directly
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test types
|
||||
pytest -m unit # Unit tests only
|
||||
pytest -m integration # Integration tests only
|
||||
pytest -m e2e # End-to-end tests only
|
||||
pytest -m realdb # Real database tests only
|
||||
|
||||
# Run specific test files
|
||||
pytest tests/test_e2e.py # All E2E tests
|
||||
pytest tests/api/ # All API tests
|
||||
|
||||
# Run specific test methods
|
||||
pytest tests/test_e2e.py::TestE2EWorkflows::test_bootstrap_and_basic_workflow
|
||||
```
|
||||
|
||||
### Test Combinations
|
||||
|
||||
```bash
|
||||
# Run unit and integration tests (skip E2E)
|
||||
pytest -m "not e2e and not realdb"
|
||||
|
||||
# Run all tests except real database tests
|
||||
pytest -m "not realdb"
|
||||
|
||||
# Run only E2E tests that don't require real database
|
||||
pytest -m "e2e and not realdb"
|
||||
```
|
||||
|
||||
## End-to-End Test Setup
|
||||
|
||||
**The E2E tests are now completely self-contained!** They automatically:
|
||||
|
||||
1. **Create artificial test data** at the start of each test class
|
||||
2. **Run all tests** against this isolated test environment
|
||||
3. **Clean up all test data** at the end automatically
|
||||
|
||||
### No Setup Required!
|
||||
|
||||
```bash
|
||||
# Just run the tests - no environment variables or API keys needed!
|
||||
python scripts/run_tests.py e2e
|
||||
|
||||
# Or with pytest directly
|
||||
pytest -m e2e
|
||||
```
|
||||
|
||||
### Test Environment Creation
|
||||
|
||||
Each test class automatically creates its own isolated environment:
|
||||
|
||||
- **Unique team** with timestamp-based naming to avoid conflicts
|
||||
- **Admin user** with unique email addresses
|
||||
- **API keys** for authentication
|
||||
- **Test images** uploaded during tests
|
||||
- **Additional users/teams** as needed for specific tests
|
||||
|
||||
### Automatic Cleanup
|
||||
|
||||
At the end of each test class, all created resources are automatically deleted:
|
||||
|
||||
- All uploaded images are removed
|
||||
- All created users are deleted
|
||||
- All created teams are removed
|
||||
- All API keys are revoked
|
||||
|
||||
### Advanced Test Modes
|
||||
|
||||
#### Integration Tests with Real Services
|
||||
For testing with real Google Cloud services:
|
||||
|
||||
```bash
|
||||
# Enable integration tests
|
||||
export E2E_INTEGRATION_TEST=1
|
||||
|
||||
# Run integration tests
|
||||
pytest -m integration
|
||||
```
|
||||
|
||||
#### Real Database Performance Tests
|
||||
For testing with real database connections and larger datasets:
|
||||
|
||||
```bash
|
||||
# Enable real database tests
|
||||
export E2E_REALDB_TEST=1
|
||||
|
||||
# Run real database tests
|
||||
pytest -m realdb
|
||||
```
|
||||
|
||||
## E2E Test Coverage
|
||||
|
||||
The E2E tests cover the following workflows with artificial test data:
|
||||
|
||||
### Core Functionality
|
||||
- ✅ **Bootstrap Setup**: Automatic creation of isolated test environment
|
||||
- ✅ **Authentication**: API key validation and verification
|
||||
- ✅ **Team Management**: Create, read, update, delete teams
|
||||
- ✅ **User Management**: Create, read, update, delete users
|
||||
- ✅ **API Key Management**: Create, list, revoke API keys
|
||||
|
||||
### Image Operations
|
||||
- ✅ **Image Upload**: File upload with metadata
|
||||
- ✅ **Image Retrieval**: Get image details and download
|
||||
- ✅ **Image Updates**: Modify descriptions and tags
|
||||
- ✅ **Image Listing**: Paginated image lists with filters
|
||||
|
||||
### Advanced Search Functionality
|
||||
- ✅ **Text Search**: Search by description content
|
||||
- ✅ **Tag Search**: Filter by tags
|
||||
- ✅ **Advanced Search**: Combined filters and thresholds
|
||||
- ✅ **Similarity Search**: Find similar images using embeddings
|
||||
- ✅ **Search Performance**: Response time validation
|
||||
|
||||
### Security and Isolation
|
||||
- ✅ **User Roles**: Admin vs regular user permissions
|
||||
- ✅ **Multi-team Isolation**: Data privacy between teams
|
||||
- ✅ **Access Control**: Unauthorized access prevention
|
||||
- ✅ **Error Handling**: Graceful error responses
|
||||
|
||||
### Performance and Scalability
|
||||
- ✅ **Bulk Operations**: Multiple image uploads
|
||||
- ✅ **Concurrent Access**: Simultaneous user operations
|
||||
- ✅ **Database Performance**: Query response times
|
||||
- ✅ **Data Consistency**: Transaction integrity
|
||||
|
||||
## Test Data Management
|
||||
|
||||
### Unique Identifiers
|
||||
All E2E tests use unique suffixes to avoid conflicts:
|
||||
```python
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
team_name = f"E2E Test Team {unique_suffix}_{int(time.time())}"
|
||||
```
|
||||
|
||||
### Isolation Strategy
|
||||
Tests are completely isolated:
|
||||
- Each test class creates its own environment
|
||||
- Uses timestamp-based unique identifiers
|
||||
- No dependency on existing database state
|
||||
- Can run in parallel without conflicts
|
||||
|
||||
### Automatic Resource Tracking
|
||||
The test environment tracks all created resources:
|
||||
```python
|
||||
"created_resources": {
|
||||
"teams": [team_id],
|
||||
"users": [admin_user_id],
|
||||
"api_keys": [api_key_id],
|
||||
"images": []
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup Strategy
|
||||
Comprehensive cleanup at test completion:
|
||||
- Images deleted first (to avoid orphaned files)
|
||||
- Additional users deleted (preserving admin for team deletion)
|
||||
- Additional teams deleted
|
||||
- Main team deleted last (cascades to remaining resources)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### No Variables Required for Basic E2E Tests!
|
||||
The standard E2E tests now run without any environment variables.
|
||||
|
||||
### Optional for Enhanced Testing
|
||||
```bash
|
||||
# Enable integration tests with real services
|
||||
E2E_INTEGRATION_TEST=1
|
||||
|
||||
# Enable real database performance tests
|
||||
E2E_REALDB_TEST=1
|
||||
|
||||
# Custom test database (if different from main)
|
||||
TEST_FIRESTORE_PROJECT_ID="your-test-project"
|
||||
TEST_GCS_BUCKET_NAME="your-test-bucket"
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
```yaml
|
||||
name: Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.10
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
- name: Run unit tests
|
||||
run: python scripts/run_tests.py unit
|
||||
- name: Run E2E tests (self-contained)
|
||||
run: python scripts/run_tests.py e2e
|
||||
# No environment variables needed!
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "Cannot create isolated test environment" Error
|
||||
```bash
|
||||
# This is rare but can happen if database has conflicting constraints
|
||||
# Solution: Check database state or use a clean test database
|
||||
```
|
||||
|
||||
#### Tests Skipped Due to Missing Environment Variables
|
||||
```bash
|
||||
# Only affects integration and realdb tests
|
||||
echo $E2E_INTEGRATION_TEST # Should be "1" for integration tests
|
||||
echo $E2E_REALDB_TEST # Should be "1" for real database tests
|
||||
```
|
||||
|
||||
#### Slow Test Performance
|
||||
```bash
|
||||
# Run only fast tests
|
||||
pytest -m "not realdb and not integration"
|
||||
|
||||
# Run tests in parallel (requires pytest-xdist)
|
||||
pip install pytest-xdist
|
||||
pytest -n auto
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
```bash
|
||||
# Run with verbose output
|
||||
pytest -v -s tests/test_e2e.py
|
||||
|
||||
# Run single test with full output
|
||||
pytest -v -s tests/test_e2e.py::TestE2EWorkflows::test_bootstrap_and_basic_workflow
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Writing New Tests
|
||||
1. **Use the test_environment fixture** for automatic setup/cleanup
|
||||
2. **Track created resources** in env["created_resources"]
|
||||
3. **Use unique identifiers** for all test data
|
||||
4. **Test both success and failure** scenarios
|
||||
5. **Use appropriate markers** (`@pytest.mark.e2e`, etc.)
|
||||
|
||||
### Test Organization
|
||||
1. **Group related tests** in classes with shared fixtures
|
||||
2. **Use descriptive test names** that explain the scenario
|
||||
3. **Keep tests independent** - no shared state between methods
|
||||
4. **Use class-scoped fixtures** for expensive setup
|
||||
5. **Document test purpose** in docstrings
|
||||
|
||||
### Performance Considerations
|
||||
1. **Use class-scoped fixtures** to share expensive setup
|
||||
2. **Minimize database operations** in individual tests
|
||||
3. **Clean up test data** automatically
|
||||
4. **Run expensive tests** only when necessary
|
||||
5. **Use artificial data** instead of real external dependencies
|
||||
|
||||
## Test Metrics
|
||||
|
||||
### Coverage Goals
|
||||
- **Unit Tests**: > 90% code coverage
|
||||
- **Integration Tests**: > 80% API endpoint coverage
|
||||
- **E2E Tests**: > 95% user workflow coverage
|
||||
|
||||
### Performance Targets
|
||||
- **Unit Tests**: < 1 second per test
|
||||
- **Integration Tests**: < 5 seconds per test
|
||||
- **E2E Tests**: < 10 seconds per test
|
||||
- **Real DB Tests**: < 30 seconds per test
|
||||
|
||||
### Quality Metrics
|
||||
- **Test Reliability**: > 99% pass rate
|
||||
- **Test Maintainability**: Clear, readable test code
|
||||
- **Test Coverage**: All critical paths tested
|
||||
- **Test Documentation**: All test purposes documented
|
||||
- **Test Isolation**: No dependencies between tests
|
||||
@ -297,7 +297,6 @@ async def seed_images(team_ids, user_ids):
|
||||
{
|
||||
"filename": "product_photo.jpg",
|
||||
"description": "Product photo for marketing",
|
||||
"tags": ["product", "marketing", "high-resolution"],
|
||||
"team_idx": 0,
|
||||
"user_idx": 0,
|
||||
"width": 1920,
|
||||
@ -307,7 +306,6 @@ async def seed_images(team_ids, user_ids):
|
||||
{
|
||||
"filename": "company_logo.png",
|
||||
"description": "Company logo",
|
||||
"tags": ["logo", "branding"],
|
||||
"team_idx": 1,
|
||||
"user_idx": 2,
|
||||
"width": 800,
|
||||
@ -317,7 +315,6 @@ async def seed_images(team_ids, user_ids):
|
||||
{
|
||||
"filename": "support_screenshot.jpg",
|
||||
"description": "Screenshot for support ticket",
|
||||
"tags": ["support", "screenshot", "bug"],
|
||||
"team_idx": 2,
|
||||
"user_idx": 3,
|
||||
"width": 1280,
|
||||
@ -372,7 +369,6 @@ async def seed_images(team_ids, user_ids):
|
||||
team_id=team_id,
|
||||
uploader_id=user_id,
|
||||
description=img_config['description'],
|
||||
tags=img_config['tags'],
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
102
simple_search_test.py
Normal file
102
simple_search_test.py
Normal file
@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script to embed text and search Qdrant without filters
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def simple_search_test():
|
||||
"""Simple test: embed text and search without filters"""
|
||||
|
||||
try:
|
||||
# Import services
|
||||
from src.services.vector_db import VectorDatabaseService
|
||||
from src.services.embedding_service import EmbeddingService
|
||||
|
||||
# Initialize services
|
||||
logger.info("Initializing services...")
|
||||
vector_db = VectorDatabaseService()
|
||||
embedding_service = EmbeddingService()
|
||||
|
||||
# Test 1: Generate text embedding
|
||||
logger.info("=== Generating Text Embedding ===")
|
||||
search_query = "blank"
|
||||
text_embedding = await embedding_service.generate_text_embedding(search_query)
|
||||
|
||||
if text_embedding:
|
||||
logger.info(f"✓ Generated embedding for '{search_query}' - length: {len(text_embedding)}")
|
||||
else:
|
||||
logger.error("✗ Failed to generate text embedding")
|
||||
return False
|
||||
|
||||
# Test 2: Search without any filters
|
||||
logger.info("=== Searching Qdrant (No Filters) ===")
|
||||
|
||||
# Try different thresholds to see what we get
|
||||
thresholds = [0.1, 0.3, 0.5, 0.65, 0.8]
|
||||
|
||||
for threshold in thresholds:
|
||||
logger.info(f"\n--- Threshold: {threshold} ---")
|
||||
|
||||
search_results = vector_db.search_similar_images(
|
||||
query_vector=text_embedding,
|
||||
limit=10,
|
||||
score_threshold=threshold
|
||||
# No filter_conditions = search everything
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(search_results)} results")
|
||||
|
||||
# Show top 3 results
|
||||
for i, result in enumerate(search_results[:3]):
|
||||
logger.info(f" {i+1}. Score: {result['score']:.4f} | ID: {result['image_id']} | File: {result['metadata'].get('filename', 'N/A')}")
|
||||
|
||||
# Test 3: Very low threshold to see all data
|
||||
logger.info("\n=== All Data (Threshold 0.0) ===")
|
||||
all_results = vector_db.search_similar_images(
|
||||
query_vector=text_embedding,
|
||||
limit=50,
|
||||
score_threshold=0.0 # Get everything
|
||||
)
|
||||
|
||||
logger.info(f"Total vectors in collection: {len(all_results)}")
|
||||
|
||||
# Show some stats
|
||||
if all_results:
|
||||
scores = [r['score'] for r in all_results]
|
||||
logger.info(f"Score range: {min(scores):.4f} to {max(scores):.4f}")
|
||||
logger.info(f"Average score: {sum(scores)/len(scores):.4f}")
|
||||
|
||||
# Show top 5 and bottom 5
|
||||
logger.info("\nTop 5 results:")
|
||||
for i, result in enumerate(all_results[:5]):
|
||||
logger.info(f" {i+1}. Score: {result['score']:.4f} | ID: {result['image_id']}")
|
||||
|
||||
if len(all_results) > 5:
|
||||
logger.info("\nBottom 5 results:")
|
||||
for i, result in enumerate(all_results[-5:]):
|
||||
logger.info(f" {len(all_results)-4+i}. Score: {result['score']:.4f} | ID: {result['image_id']}")
|
||||
|
||||
logger.info("\n✓ Simple search test completed!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(simple_search_test())
|
||||
sys.exit(0 if success else 1)
|
||||
@ -37,7 +37,6 @@ async def upload_image(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[str] = None,
|
||||
collection_id: Optional[str] = None,
|
||||
current_user: UserModel = Depends(get_current_user)
|
||||
):
|
||||
@ -69,11 +68,7 @@ async def upload_image(
|
||||
file, str(current_user.team_id)
|
||||
)
|
||||
|
||||
# Process tags
|
||||
tag_list = []
|
||||
if tags:
|
||||
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
|
||||
|
||||
# Create image record
|
||||
image = ImageModel(
|
||||
filename=file.filename,
|
||||
@ -85,7 +80,6 @@ async def upload_image(
|
||||
team_id=current_user.team_id,
|
||||
uploader_id=current_user.id,
|
||||
description=description,
|
||||
tags=tag_list,
|
||||
metadata=metadata,
|
||||
collection_id=ObjectId(collection_id) if collection_id else None
|
||||
)
|
||||
@ -125,7 +119,6 @@ async def upload_image(
|
||||
uploader_id=str(created_image.uploader_id),
|
||||
upload_date=created_image.upload_date,
|
||||
description=created_image.description,
|
||||
tags=created_image.tags,
|
||||
metadata=created_image.metadata,
|
||||
has_embedding=created_image.has_embedding,
|
||||
collection_id=str(created_image.collection_id) if created_image.collection_id else None
|
||||
@ -143,7 +136,6 @@ async def list_images(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
collection_id: Optional[str] = None,
|
||||
tags: Optional[str] = None,
|
||||
current_user: UserModel = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
@ -156,7 +148,6 @@ async def list_images(
|
||||
skip: Number of records to skip for pagination
|
||||
limit: Maximum number of records to return (1-100)
|
||||
collection_id: Optional filter by collection ID
|
||||
tags: Optional comma-separated list of tags to filter by
|
||||
|
||||
Returns:
|
||||
List of images with pagination metadata
|
||||
@ -167,11 +158,6 @@ async def list_images(
|
||||
team_id=str(current_user.team_id)
|
||||
)
|
||||
|
||||
# Parse tags filter
|
||||
tag_filter = []
|
||||
if tags:
|
||||
tag_filter = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
|
||||
# Check if user is admin - if so, get all images across all teams
|
||||
if current_user.is_admin:
|
||||
# Admin users can see all images across all teams
|
||||
@ -179,13 +165,11 @@ async def list_images(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||
tags=tag_filter
|
||||
)
|
||||
|
||||
# Get total count for admin
|
||||
total = await image_repository.count_all(
|
||||
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||
tags=tag_filter
|
||||
)
|
||||
else:
|
||||
# Regular users only see images from their team
|
||||
@ -194,14 +178,12 @@ async def list_images(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||
tags=tag_filter
|
||||
)
|
||||
|
||||
# Get total count for regular user
|
||||
total = await image_repository.count_by_team(
|
||||
current_user.team_id,
|
||||
collection_id=ObjectId(collection_id) if collection_id else None,
|
||||
tags=tag_filter
|
||||
)
|
||||
|
||||
# Convert to response
|
||||
@ -222,7 +204,6 @@ async def list_images(
|
||||
uploader_id=str(image.uploader_id),
|
||||
upload_date=image.upload_date,
|
||||
description=image.description,
|
||||
tags=image.tags,
|
||||
metadata=image.metadata,
|
||||
has_embedding=image.has_embedding,
|
||||
collection_id=str(image.collection_id) if image.collection_id else None
|
||||
@ -275,7 +256,6 @@ async def get_image(
|
||||
uploader_id=str(image.uploader_id),
|
||||
upload_date=image.upload_date,
|
||||
description=image.description,
|
||||
tags=image.tags,
|
||||
metadata=image.metadata,
|
||||
has_embedding=image.has_embedding,
|
||||
collection_id=str(image.collection_id) if image.collection_id else None,
|
||||
@ -375,7 +355,6 @@ async def update_image(
|
||||
uploader_id=str(image.uploader_id),
|
||||
upload_date=image.upload_date,
|
||||
description=image.description,
|
||||
tags=image.tags,
|
||||
metadata=image.metadata,
|
||||
has_embedding=image.has_embedding,
|
||||
collection_id=str(image.collection_id) if image.collection_id else None
|
||||
@ -402,7 +381,6 @@ async def update_image(
|
||||
uploader_id=str(updated_image.uploader_id),
|
||||
upload_date=updated_image.upload_date,
|
||||
description=updated_image.description,
|
||||
tags=updated_image.tags,
|
||||
metadata=updated_image.metadata,
|
||||
has_embedding=updated_image.has_embedding,
|
||||
collection_id=str(updated_image.collection_id) if updated_image.collection_id else None
|
||||
|
||||
@ -33,9 +33,8 @@ async def search_images(
|
||||
request: Request,
|
||||
q: str = Query(..., description="Search query"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Number of results to return"),
|
||||
threshold: float = Query(0.7, ge=0.0, le=1.0, description="Similarity threshold"),
|
||||
threshold: float = Query(0.65, ge=0.0, le=1.0, description="Similarity threshold"),
|
||||
collection_id: Optional[str] = Query(None, description="Filter by collection ID"),
|
||||
tags: Optional[str] = Query(None, description="Filter by tags (comma-separated)"),
|
||||
current_user: UserModel = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
@ -83,19 +82,13 @@ async def search_images(
|
||||
# Get image metadata from database
|
||||
images = await image_repository.get_by_ids(image_ids)
|
||||
|
||||
# Filter by collection and tags if specified
|
||||
# Filter by collection if specified
|
||||
filtered_images = []
|
||||
for image in images:
|
||||
# Check collection filter
|
||||
if collection_id and str(image.collection_id) != collection_id:
|
||||
continue
|
||||
|
||||
# Check tags filter
|
||||
if tags:
|
||||
tag_filter = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
if not any(tag in image.tags for tag in tag_filter):
|
||||
continue
|
||||
|
||||
filtered_images.append(image)
|
||||
|
||||
# Convert to response format with similarity scores
|
||||
@ -115,7 +108,6 @@ async def search_images(
|
||||
uploader_id=str(image.uploader_id),
|
||||
upload_date=image.upload_date,
|
||||
description=image.description,
|
||||
tags=image.tags,
|
||||
metadata=image.metadata,
|
||||
has_embedding=image.has_embedding,
|
||||
collection_id=str(image.collection_id) if image.collection_id else None,
|
||||
@ -194,11 +186,6 @@ async def search_images_advanced(
|
||||
if search_request.collection_id and str(image.collection_id) != search_request.collection_id:
|
||||
continue
|
||||
|
||||
# Check tags filter
|
||||
if search_request.tags:
|
||||
if not any(tag in image.tags for tag in search_request.tags):
|
||||
continue
|
||||
|
||||
# Check date range filter
|
||||
if search_request.date_from and image.upload_date < search_request.date_from:
|
||||
continue
|
||||
@ -229,7 +216,6 @@ async def search_images_advanced(
|
||||
uploader_id=str(image.uploader_id),
|
||||
upload_date=image.upload_date,
|
||||
description=image.description,
|
||||
tags=image.tags,
|
||||
metadata=image.metadata,
|
||||
has_embedding=image.has_embedding,
|
||||
collection_id=str(image.collection_id) if image.collection_id else None,
|
||||
@ -257,7 +243,7 @@ async def find_similar_images(
|
||||
image_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(10, ge=1, le=50, description="Number of similar images to return"),
|
||||
threshold: float = Query(0.7, ge=0.0, le=1.0, description="Similarity threshold"),
|
||||
threshold: float = Query(0.65, ge=0.0, le=1.0, description="Similarity threshold"),
|
||||
current_user: UserModel = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
@ -347,7 +333,6 @@ async def find_similar_images(
|
||||
uploader_id=str(image.uploader_id),
|
||||
upload_date=image.upload_date,
|
||||
description=image.description,
|
||||
tags=image.tags,
|
||||
metadata=image.metadata,
|
||||
has_embedding=image.has_embedding,
|
||||
collection_id=str(image.collection_id) if image.collection_id else None,
|
||||
|
||||
@ -37,7 +37,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
collection_id: Optional[ObjectId] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> List[ImageModel]:
|
||||
"""
|
||||
Get images by team with pagination and filtering
|
||||
@ -47,7 +46,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
skip: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
collection_id: Optional collection ID filter
|
||||
tags: Optional list of tags to filter by
|
||||
|
||||
Returns:
|
||||
List of images
|
||||
@ -63,13 +61,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
if collection_id:
|
||||
filtered_images = [image for image in filtered_images if image.collection_id == collection_id]
|
||||
|
||||
# Filter by tags if specified
|
||||
if tags:
|
||||
filtered_images = [
|
||||
image for image in filtered_images
|
||||
if any(tag in image.tags for tag in tags)
|
||||
]
|
||||
|
||||
# Apply pagination
|
||||
return filtered_images[skip:skip + limit]
|
||||
except Exception as e:
|
||||
@ -80,7 +71,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
self,
|
||||
team_id: ObjectId,
|
||||
collection_id: Optional[ObjectId] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Count images by team with filtering
|
||||
@ -88,7 +78,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
Args:
|
||||
team_id: Team ID
|
||||
collection_id: Optional collection ID filter
|
||||
tags: Optional list of tags to filter by
|
||||
|
||||
Returns:
|
||||
Count of images
|
||||
@ -103,13 +92,7 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
# Filter by collection if specified
|
||||
if collection_id:
|
||||
filtered_images = [image for image in filtered_images if image.collection_id == collection_id]
|
||||
|
||||
# Filter by tags if specified
|
||||
if tags:
|
||||
filtered_images = [
|
||||
image for image in filtered_images
|
||||
if any(tag in image.tags for tag in tags)
|
||||
]
|
||||
|
||||
|
||||
return len(filtered_images)
|
||||
except Exception as e:
|
||||
@ -154,31 +137,12 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
logger.error(f"Error getting images by uploader ID: {e}")
|
||||
raise
|
||||
|
||||
async def get_by_tag(self, tag: str) -> list[ImageModel]:
|
||||
"""
|
||||
Get images by tag
|
||||
|
||||
Args:
|
||||
tag: Tag
|
||||
|
||||
Returns:
|
||||
List of images
|
||||
"""
|
||||
try:
|
||||
# This would typically use a Firestore query, but for simplicity
|
||||
# we'll get all images and filter in memory
|
||||
images = await self.get_all()
|
||||
return [image for image in images if tag in image.tags]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting images by tag: {e}")
|
||||
raise
|
||||
|
||||
async def get_all_with_pagination(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
collection_id: Optional[ObjectId] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> List[ImageModel]:
|
||||
"""
|
||||
Get all images across all teams with pagination and filtering (admin only)
|
||||
@ -187,7 +151,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
skip: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
collection_id: Optional collection ID filter
|
||||
tags: Optional list of tags to filter by
|
||||
|
||||
Returns:
|
||||
List of images
|
||||
@ -200,12 +163,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
if collection_id:
|
||||
images = [image for image in images if image.collection_id == collection_id]
|
||||
|
||||
# Filter by tags if specified
|
||||
if tags:
|
||||
images = [
|
||||
image for image in images
|
||||
if any(tag in image.tags for tag in tags)
|
||||
]
|
||||
|
||||
# Apply pagination
|
||||
return images[skip:skip + limit]
|
||||
@ -216,14 +173,12 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
async def count_all(
|
||||
self,
|
||||
collection_id: Optional[ObjectId] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Count all images across all teams with filtering (admin only)
|
||||
|
||||
Args:
|
||||
collection_id: Optional collection ID filter
|
||||
tags: Optional list of tags to filter by
|
||||
|
||||
Returns:
|
||||
Count of images
|
||||
@ -236,12 +191,6 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]):
|
||||
if collection_id:
|
||||
images = [image for image in images if image.collection_id == collection_id]
|
||||
|
||||
# Filter by tags if specified
|
||||
if tags:
|
||||
images = [
|
||||
image for image in images
|
||||
if any(tag in image.tags for tag in tags)
|
||||
]
|
||||
|
||||
return len(images)
|
||||
except Exception as e:
|
||||
|
||||
@ -19,7 +19,6 @@ class ImageModel(BaseModel):
|
||||
upload_date: datetime = Field(default_factory=datetime.utcnow)
|
||||
last_accessed: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
metadata: Dict[str, Any] = {}
|
||||
collection_id: Optional[PyObjectId] = None
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ from pydantic import BaseModel, Field, HttpUrl
|
||||
class ImageBase(BaseModel):
|
||||
"""Base schema for image data"""
|
||||
description: Optional[str] = Field(None, description="Image description", max_length=500)
|
||||
tags: List[str] = Field(default=[], description="Image tags")
|
||||
|
||||
class ImageCreate(ImageBase):
|
||||
"""Schema for creating an image"""
|
||||
@ -19,7 +18,6 @@ class ImageUpload(ImageBase):
|
||||
class ImageUpdate(BaseModel):
|
||||
"""Schema for updating an image"""
|
||||
description: Optional[str] = Field(None, description="Image description", max_length=500)
|
||||
tags: Optional[List[str]] = Field(None, description="Image tags")
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Image metadata")
|
||||
collection_id: Optional[str] = Field(None, description="Collection ID to organize images")
|
||||
|
||||
@ -57,7 +55,6 @@ class ImageResponse(ImageBase):
|
||||
"upload_date": "2023-10-20T10:00:00",
|
||||
"last_accessed": "2023-10-21T10:00:00",
|
||||
"description": "Beautiful sunset over the mountains",
|
||||
"tags": ["sunset", "mountains", "nature"],
|
||||
"metadata": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
@ -94,7 +91,6 @@ class ImageListResponse(BaseModel):
|
||||
"upload_date": "2023-10-20T10:00:00",
|
||||
"last_accessed": "2023-10-21T10:00:00",
|
||||
"description": "Beautiful sunset over the mountains",
|
||||
"tags": ["sunset", "mountains", "nature"],
|
||||
"metadata": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
@ -145,7 +141,6 @@ class ImageSearchResult(BaseModel):
|
||||
"upload_date": "2023-10-20T10:00:00",
|
||||
"last_accessed": "2023-10-21T10:00:00",
|
||||
"description": "Beautiful sunset over the mountains",
|
||||
"tags": ["sunset", "mountains", "nature"],
|
||||
"metadata": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
@ -181,7 +176,6 @@ class ImageSearchResponse(BaseModel):
|
||||
"upload_date": "2023-10-20T10:00:00",
|
||||
"last_accessed": "2023-10-21T10:00:00",
|
||||
"description": "Beautiful sunset over the mountains",
|
||||
"tags": ["sunset", "mountains", "nature"],
|
||||
"metadata": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
|
||||
@ -10,7 +10,6 @@ class SearchRequest(BaseModel):
|
||||
limit: int = Field(10, description="Maximum number of results", ge=1, le=50)
|
||||
threshold: float = Field(0.7, description="Similarity threshold", ge=0.0, le=1.0)
|
||||
collection_id: Optional[str] = Field(None, description="Filter by collection ID")
|
||||
tags: Optional[List[str]] = Field(None, description="Filter by tags")
|
||||
date_from: Optional[datetime] = Field(None, description="Filter images uploaded after this date")
|
||||
date_to: Optional[datetime] = Field(None, description="Filter images uploaded before this date")
|
||||
uploader_id: Optional[str] = Field(None, description="Filter by uploader ID")
|
||||
@ -22,7 +21,6 @@ class SearchRequest(BaseModel):
|
||||
"limit": 10,
|
||||
"threshold": 0.7,
|
||||
"collection_id": "507f1f77bcf86cd799439044",
|
||||
"tags": ["nature", "landscape"],
|
||||
"date_from": "2023-01-01T00:00:00",
|
||||
"date_to": "2023-12-31T23:59:59",
|
||||
"uploader_id": "507f1f77bcf86cd799439033"
|
||||
@ -56,7 +54,6 @@ class SearchResponse(BaseModel):
|
||||
"upload_date": "2023-10-20T10:00:00",
|
||||
"last_accessed": "2023-10-21T10:00:00",
|
||||
"description": "Beautiful sunset over the mountains",
|
||||
"tags": ["sunset", "mountains", "nature"],
|
||||
"metadata": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
|
||||
@ -139,7 +139,7 @@ class VectorDatabaseService:
|
||||
self,
|
||||
query_vector: List[float],
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.7,
|
||||
score_threshold: float = 0.65,
|
||||
filter_conditions: Dict[str, Any] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
@ -154,7 +154,7 @@ class MockImageRepository:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def search(self, team_id: ObjectId, query: str = None, tags: List[str] = None) -> List[ImageModel]:
|
||||
async def search(self, team_id: ObjectId, query: str = None) -> List[ImageModel]:
|
||||
results = [img for img in self.images.values() if str(img.team_id) == str(team_id)]
|
||||
|
||||
if query:
|
||||
@ -163,9 +163,6 @@ class MockImageRepository:
|
||||
(img.description and query in img.description.lower()) or
|
||||
query in img.filename.lower() or
|
||||
query in img.original_filename.lower()]
|
||||
|
||||
if tags:
|
||||
results = [img for img in results if all(tag in img.tags for tag in tags)]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@ -2,24 +2,28 @@ import os
|
||||
import pytest
|
||||
import uuid
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, Mock
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
import tempfile
|
||||
|
||||
from src.db.repositories.image_repository import ImageRepository, image_repository
|
||||
from src.models.image import ImageModel
|
||||
from main import app
|
||||
|
||||
# Hardcoded API key as requested
|
||||
API_KEY = "Wwg4eJjJ.d03970d43cf3a454ad4168b3226b423f"
|
||||
|
||||
# Mock team ID for testing
|
||||
MOCK_TEAM_ID = "test-team-123"
|
||||
MOCK_USER_ID = "test-user-456"
|
||||
# Test constants
|
||||
API_KEY = "test-api-key-12345"
|
||||
MOCK_TEAM_ID = "507f1f77bcf86cd799439011"
|
||||
MOCK_USER_ID = "507f1f77bcf86cd799439012"
|
||||
|
||||
@pytest.fixture
|
||||
def test_image_path():
|
||||
"""Get path to test image"""
|
||||
# Assuming image.png exists in the images directory
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "images", "image.png")
|
||||
"""Create a temporary test image file"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img.save(tmp.name, 'PNG')
|
||||
yield tmp.name
|
||||
os.unlink(tmp.name)
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@ -28,31 +32,25 @@ def client():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth():
|
||||
"""Mock the authentication to use our hardcoded API key"""
|
||||
with patch('src.api.v1.auth.get_current_user') as mock_auth:
|
||||
# Configure the mock to return a valid user
|
||||
mock_auth.return_value = {
|
||||
"id": MOCK_USER_ID,
|
||||
"team_id": MOCK_TEAM_ID,
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
yield mock_auth
|
||||
"""Mock authentication to return a valid user"""
|
||||
with patch('src.auth.dependencies.get_current_user') as mock_get_user:
|
||||
mock_user = Mock()
|
||||
mock_user.id = MOCK_USER_ID
|
||||
mock_user.team_id = MOCK_TEAM_ID
|
||||
mock_user.is_admin = True
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_user
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage_service():
|
||||
"""Mock the storage service"""
|
||||
with patch('src.services.storage.StorageService') as MockStorageService:
|
||||
# Configure the mock
|
||||
mock_service = MagicMock()
|
||||
|
||||
# Mock the upload_file method
|
||||
test_storage_path = f"{MOCK_TEAM_ID}/test-image-{uuid.uuid4().hex}.png"
|
||||
mock_service.upload_file.return_value = (
|
||||
test_storage_path, # storage_path
|
||||
"image/png", # content_type
|
||||
1024, # file_size
|
||||
{ # metadata
|
||||
with patch('src.services.storage_service.StorageService') as MockStorageService:
|
||||
mock_service = Mock()
|
||||
mock_service.upload_file.return_value = f"{MOCK_TEAM_ID}/test-image-123.png"
|
||||
mock_service.get_file_metadata.return_value = Mock(
|
||||
size=1024,
|
||||
content_type="image/png",
|
||||
metadata={
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"format": "PNG",
|
||||
@ -85,7 +83,7 @@ async def test_upload_image_endpoint(client, test_image_path, mock_auth, mock_st
|
||||
# Create API endpoint route if it doesn't exist yet
|
||||
with patch('src.api.v1.images.router.post') as mock_post:
|
||||
# Modify the router for testing purposes
|
||||
async def mock_upload_image_handler(file, description=None, tags=None, current_user=None):
|
||||
async def mock_upload_image_handler(file, description=None, current_user=None):
|
||||
# This simulates the handler that would be in src/api/v1/images.py
|
||||
# Store image in database
|
||||
image = ImageModel(
|
||||
@ -96,8 +94,7 @@ async def test_upload_image_endpoint(client, test_image_path, mock_auth, mock_st
|
||||
storage_path=f"{MOCK_TEAM_ID}/test-image-123.png",
|
||||
team_id=MOCK_TEAM_ID,
|
||||
uploader_id=MOCK_USER_ID,
|
||||
description=description,
|
||||
tags=tags.split(",") if tags else []
|
||||
description=description
|
||||
)
|
||||
created_image = await image_repository.create(image)
|
||||
|
||||
@ -109,8 +106,7 @@ async def test_upload_image_endpoint(client, test_image_path, mock_auth, mock_st
|
||||
"content_type": created_image.content_type,
|
||||
"team_id": str(created_image.team_id),
|
||||
"uploader_id": str(created_image.uploader_id),
|
||||
"description": created_image.description,
|
||||
"tags": created_image.tags
|
||||
"description": created_image.description
|
||||
}
|
||||
|
||||
mock_post.return_value = mock_upload_image_handler
|
||||
@ -126,8 +122,7 @@ async def test_upload_image_endpoint(client, test_image_path, mock_auth, mock_st
|
||||
headers={"X-API-Key": API_KEY},
|
||||
files=files,
|
||||
data={
|
||||
"description": "Test image upload",
|
||||
"tags": "test,upload,image"
|
||||
"description": "Test image upload"
|
||||
}
|
||||
)
|
||||
|
||||
@ -168,8 +163,7 @@ async def test_image_lifecycle(client, test_image_path, mock_auth, mock_storage_
|
||||
storage_path=test_storage_path,
|
||||
team_id=MOCK_TEAM_ID,
|
||||
uploader_id=MOCK_USER_ID,
|
||||
description="Test image upload",
|
||||
tags=["test", "upload", "image"]
|
||||
description="Test image upload"
|
||||
)
|
||||
|
||||
mock_create.return_value = mock_image
|
||||
@ -182,7 +176,7 @@ async def test_image_lifecycle(client, test_image_path, mock_auth, mock_storage_
|
||||
patch('src.api.v1.images.router.delete') as mock_delete_api:
|
||||
|
||||
# Mock the endpoints
|
||||
async def mock_upload_handler(file, description=None, tags=None, current_user=None):
|
||||
async def mock_upload_handler(file, description=None, current_user=None):
|
||||
created_image = await image_repository.create(mock_image)
|
||||
return {
|
||||
"id": str(created_image.id),
|
||||
@ -191,8 +185,7 @@ async def test_image_lifecycle(client, test_image_path, mock_auth, mock_storage_
|
||||
"content_type": created_image.content_type,
|
||||
"team_id": str(created_image.team_id),
|
||||
"uploader_id": str(created_image.uploader_id),
|
||||
"description": created_image.description,
|
||||
"tags": created_image.tags
|
||||
"description": created_image.description
|
||||
}
|
||||
|
||||
async def mock_get_handler(image_id, current_user=None):
|
||||
@ -204,8 +197,7 @@ async def test_image_lifecycle(client, test_image_path, mock_auth, mock_storage_
|
||||
"content_type": image.content_type,
|
||||
"team_id": str(image.team_id),
|
||||
"uploader_id": str(image.uploader_id),
|
||||
"description": image.description,
|
||||
"tags": image.tags
|
||||
"description": image.description
|
||||
}
|
||||
|
||||
async def mock_delete_handler(image_id, current_user=None):
|
||||
@ -222,7 +214,7 @@ async def test_image_lifecycle(client, test_image_path, mock_auth, mock_storage_
|
||||
"/api/v1/images",
|
||||
headers={"X-API-Key": API_KEY},
|
||||
files={"file": ("test_image.png", f, "image/png")},
|
||||
data={"description": "Test image upload", "tags": "test,upload,image"}
|
||||
data={"description": "Test image upload"}
|
||||
)
|
||||
|
||||
# Verify upload
|
||||
|
||||
@ -22,7 +22,6 @@ def test_image_model_properties():
|
||||
team_id=team_id,
|
||||
uploader_id=uploader_id,
|
||||
description="A test image",
|
||||
tags=["test", "api"],
|
||||
metadata={"width": 800, "height": 600}
|
||||
)
|
||||
|
||||
@ -35,8 +34,6 @@ def test_image_model_properties():
|
||||
assert image.team_id == team_id
|
||||
assert image.uploader_id == uploader_id
|
||||
assert image.description == "A test image"
|
||||
assert "test" in image.tags
|
||||
assert "api" in image.tags
|
||||
assert image.metadata["width"] == 800
|
||||
assert image.metadata["height"] == 600
|
||||
assert image.has_embedding is False
|
||||
@ -113,8 +110,7 @@ async def test_upload_image(client: TestClient, user_api_key: tuple):
|
||||
headers=headers,
|
||||
files=files,
|
||||
data={
|
||||
"description": "Test image upload",
|
||||
"tags": "test,upload,image"
|
||||
"description": "Test image upload"
|
||||
}
|
||||
)
|
||||
|
||||
@ -127,10 +123,6 @@ async def test_upload_image(client: TestClient, user_api_key: tuple):
|
||||
assert "team_id" in data
|
||||
assert "uploader_id" in data
|
||||
assert data["description"] == "Test image upload"
|
||||
assert len(data["tags"]) == 3
|
||||
assert "test" in data["tags"]
|
||||
assert "upload" in data["tags"]
|
||||
assert "image" in data["tags"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -147,8 +139,7 @@ async def test_get_image(client: TestClient, user_api_key: tuple):
|
||||
storage_path="images/test-image-123.jpg",
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
description="A test image",
|
||||
tags=["test", "image"]
|
||||
description="A test image"
|
||||
)
|
||||
created_image = await image_repository.create(image)
|
||||
|
||||
@ -169,8 +160,6 @@ async def test_get_image(client: TestClient, user_api_key: tuple):
|
||||
assert data["team_id"] == str(api_key.team_id)
|
||||
assert data["uploader_id"] == str(api_key.user_id)
|
||||
assert data["description"] == "A test image"
|
||||
assert "test" in data["tags"]
|
||||
assert "image" in data["tags"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -254,8 +243,7 @@ async def test_update_image_metadata(client: TestClient, user_api_key: tuple):
|
||||
storage_path="images/test-image-123.jpg",
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
description="Original description",
|
||||
tags=["original"]
|
||||
description="Original description"
|
||||
)
|
||||
created_image = await image_repository.create(image)
|
||||
|
||||
@ -267,8 +255,7 @@ async def test_update_image_metadata(client: TestClient, user_api_key: tuple):
|
||||
f"/api/v1/images/{created_image.id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"description": "Updated description",
|
||||
"tags": ["updated", "metadata"]
|
||||
"description": "Updated description"
|
||||
}
|
||||
)
|
||||
|
||||
@ -277,7 +264,4 @@ async def test_update_image_metadata(client: TestClient, user_api_key: tuple):
|
||||
data = response.json()
|
||||
assert data["id"] == str(created_image.id)
|
||||
assert data["description"] == "Updated description"
|
||||
assert len(data["tags"]) == 2
|
||||
assert "updated" in data["tags"]
|
||||
assert "metadata" in data["tags"]
|
||||
"""
|
||||
@ -4,6 +4,8 @@ from fastapi.testclient import TestClient
|
||||
from fastapi import status
|
||||
import io
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from bson import ObjectId as PyObjectId
|
||||
|
||||
from src.api.v1.images import router
|
||||
from src.models.user import UserModel
|
||||
@ -15,34 +17,32 @@ class TestImageUploadWithPubSub:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_user(self):
|
||||
"""Mock current user"""
|
||||
user = UserModel(
|
||||
id=PyObjectId(),
|
||||
email="test@example.com",
|
||||
team_id=PyObjectId(),
|
||||
is_active=True
|
||||
)
|
||||
"""Mock current user for authentication"""
|
||||
user = Mock()
|
||||
user.id = PyObjectId()
|
||||
user.team_id = PyObjectId()
|
||||
user.email = "test@example.com"
|
||||
user.name = "Test User"
|
||||
user.is_admin = True
|
||||
user.is_active = True
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_image_file(self):
|
||||
"""Create a test image file"""
|
||||
# Create a simple test image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
return img_bytes
|
||||
# Create a simple test image file
|
||||
image_data = b"fake image data for testing"
|
||||
return BytesIO(image_data)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage_service(self):
|
||||
"""Mock storage service"""
|
||||
with patch('src.api.v1.images.storage_service') as mock_service:
|
||||
mock_service.upload_file = AsyncMock(return_value=(
|
||||
"bucket/team-123/image.jpg",
|
||||
"image/jpeg",
|
||||
1024,
|
||||
{"width": 100, "height": 100}
|
||||
"bucket/team/test-image.jpg", # storage_path
|
||||
"image/jpeg", # content_type
|
||||
1024, # file_size
|
||||
{"width": 800, "height": 600} # metadata
|
||||
))
|
||||
yield mock_service
|
||||
|
||||
@ -50,22 +50,22 @@ class TestImageUploadWithPubSub:
|
||||
def mock_image_repository(self):
|
||||
"""Mock image repository"""
|
||||
with patch('src.api.v1.images.image_repository') as mock_repo:
|
||||
# Create a mock image object
|
||||
mock_image = Mock()
|
||||
mock_image.id = PyObjectId()
|
||||
mock_image.filename = "test.jpg"
|
||||
mock_image.filename = "test-image-123.jpg"
|
||||
mock_image.original_filename = "test.jpg"
|
||||
mock_image.file_size = 1024
|
||||
mock_image.content_type = "image/jpeg"
|
||||
mock_image.storage_path = "bucket/team-123/image.jpg"
|
||||
mock_image.storage_path = "bucket/team/test-image.jpg"
|
||||
mock_image.team_id = PyObjectId()
|
||||
mock_image.uploader_id = PyObjectId()
|
||||
mock_image.upload_date = "2023-01-01T00:00:00"
|
||||
mock_image.description = None
|
||||
mock_image.tags = []
|
||||
mock_image.metadata = {}
|
||||
mock_image.upload_date = Mock()
|
||||
mock_image.has_embedding = False
|
||||
mock_image.collection_id = None
|
||||
|
||||
# Configure the create method to return the mock image
|
||||
mock_repo.create = AsyncMock(return_value=mock_image)
|
||||
yield mock_repo
|
||||
|
||||
@ -85,7 +85,7 @@ class TestImageUploadWithPubSub:
|
||||
mock_image_repository,
|
||||
mock_pubsub_service
|
||||
):
|
||||
"""Test that image upload publishes a task to Pub/Sub"""
|
||||
"""Test that image upload publishes a message to Pub/Sub"""
|
||||
with patch('src.api.v1.images.get_current_user', return_value=mock_current_user):
|
||||
from src.api.v1.images import upload_image
|
||||
from fastapi import UploadFile
|
||||
@ -109,17 +109,27 @@ class TestImageUploadWithPubSub:
|
||||
current_user=mock_current_user
|
||||
)
|
||||
|
||||
# Verify storage service was called
|
||||
mock_storage_service.upload_file.assert_called_once()
|
||||
|
||||
# Verify image was created in repository
|
||||
mock_image_repository.create.assert_called_once()
|
||||
|
||||
# Verify Pub/Sub task was published
|
||||
mock_pubsub_service.publish_image_processing_task.assert_called_once()
|
||||
|
||||
# Check the call arguments
|
||||
call_args = mock_pubsub_service.publish_image_processing_task.call_args
|
||||
assert call_args[1]['image_id'] == str(mock_image_repository.create.return_value.id)
|
||||
assert call_args[1]['storage_path'] == "bucket/team-123/image.jpg"
|
||||
assert call_args[1]['team_id'] == str(mock_current_user.team_id)
|
||||
# Get the call arguments for Pub/Sub
|
||||
pubsub_call_args = mock_pubsub_service.publish_image_processing_task.call_args
|
||||
task_data = pubsub_call_args[0][0] # First positional argument
|
||||
|
||||
# Verify task data contains expected fields
|
||||
assert "image_id" in task_data
|
||||
assert "team_id" in task_data
|
||||
assert "storage_path" in task_data
|
||||
assert "content_type" in task_data
|
||||
|
||||
# Verify response
|
||||
assert response.filename == "test.jpg"
|
||||
assert response.filename == "test-image-123.jpg"
|
||||
assert response.content_type == "image/jpeg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -131,14 +141,14 @@ class TestImageUploadWithPubSub:
|
||||
mock_image_repository,
|
||||
mock_pubsub_service
|
||||
):
|
||||
"""Test that upload continues even if Pub/Sub publishing fails"""
|
||||
# Mock Pub/Sub failure
|
||||
mock_pubsub_service.publish_image_processing_task = AsyncMock(return_value=False)
|
||||
|
||||
"""Test that image upload continues even if Pub/Sub fails"""
|
||||
with patch('src.api.v1.images.get_current_user', return_value=mock_current_user):
|
||||
from src.api.v1.images import upload_image
|
||||
from fastapi import UploadFile
|
||||
|
||||
# Configure Pub/Sub to fail
|
||||
mock_pubsub_service.publish_image_processing_task = AsyncMock(return_value=False)
|
||||
|
||||
# Create upload file
|
||||
upload_file = UploadFile(
|
||||
filename="test.jpg",
|
||||
@ -151,18 +161,22 @@ class TestImageUploadWithPubSub:
|
||||
request.url.path = "/api/v1/images"
|
||||
request.method = "POST"
|
||||
|
||||
# Call the upload function - should not raise exception
|
||||
# Call the upload function
|
||||
response = await upload_image(
|
||||
request=request,
|
||||
file=upload_file,
|
||||
current_user=mock_current_user
|
||||
)
|
||||
|
||||
# Verify Pub/Sub task was attempted
|
||||
# Verify storage and repository were still called
|
||||
mock_storage_service.upload_file.assert_called_once()
|
||||
mock_image_repository.create.assert_called_once()
|
||||
|
||||
# Verify Pub/Sub was attempted
|
||||
mock_pubsub_service.publish_image_processing_task.assert_called_once()
|
||||
|
||||
# Verify response is still successful
|
||||
assert response.filename == "test.jpg"
|
||||
assert response.filename == "test-image-123.jpg"
|
||||
assert response.content_type == "image/jpeg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -174,16 +188,16 @@ class TestImageUploadWithPubSub:
|
||||
mock_image_repository,
|
||||
mock_pubsub_service
|
||||
):
|
||||
"""Test that upload continues even if Pub/Sub publishing raises exception"""
|
||||
# Mock Pub/Sub exception
|
||||
mock_pubsub_service.publish_image_processing_task = AsyncMock(
|
||||
side_effect=Exception("Pub/Sub error")
|
||||
)
|
||||
|
||||
"""Test that image upload continues even if Pub/Sub raises an exception"""
|
||||
with patch('src.api.v1.images.get_current_user', return_value=mock_current_user):
|
||||
from src.api.v1.images import upload_image
|
||||
from fastapi import UploadFile
|
||||
|
||||
# Configure Pub/Sub to raise an exception
|
||||
mock_pubsub_service.publish_image_processing_task = AsyncMock(
|
||||
side_effect=Exception("Pub/Sub service unavailable")
|
||||
)
|
||||
|
||||
# Create upload file
|
||||
upload_file = UploadFile(
|
||||
filename="test.jpg",
|
||||
@ -196,14 +210,18 @@ class TestImageUploadWithPubSub:
|
||||
request.url.path = "/api/v1/images"
|
||||
request.method = "POST"
|
||||
|
||||
# Call the upload function - should not raise exception
|
||||
# Call the upload function
|
||||
response = await upload_image(
|
||||
request=request,
|
||||
file=upload_file,
|
||||
current_user=mock_current_user
|
||||
)
|
||||
|
||||
# Verify Pub/Sub task was attempted
|
||||
# Verify storage and repository were still called
|
||||
mock_storage_service.upload_file.assert_called_once()
|
||||
mock_image_repository.create.assert_called_once()
|
||||
|
||||
# Verify Pub/Sub was attempted
|
||||
mock_pubsub_service.publish_image_processing_task.assert_called_once()
|
||||
|
||||
# Verify response is still successful
|
||||
@ -211,7 +229,7 @@ class TestImageUploadWithPubSub:
|
||||
assert response.content_type == "image/jpeg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_image_with_description_and_tags(
|
||||
async def test_upload_image_with_description(
|
||||
self,
|
||||
mock_current_user,
|
||||
test_image_file,
|
||||
@ -219,7 +237,7 @@ class TestImageUploadWithPubSub:
|
||||
mock_image_repository,
|
||||
mock_pubsub_service
|
||||
):
|
||||
"""Test image upload with description and tags"""
|
||||
"""Test image upload with description"""
|
||||
with patch('src.api.v1.images.get_current_user', return_value=mock_current_user):
|
||||
from src.api.v1.images import upload_image
|
||||
from fastapi import UploadFile
|
||||
@ -236,12 +254,11 @@ class TestImageUploadWithPubSub:
|
||||
request.url.path = "/api/v1/images"
|
||||
request.method = "POST"
|
||||
|
||||
# Call the upload function with description and tags
|
||||
# Call the upload function with description
|
||||
response = await upload_image(
|
||||
request=request,
|
||||
file=upload_file,
|
||||
description="Test image",
|
||||
tags="nature, landscape, outdoor",
|
||||
current_user=mock_current_user
|
||||
)
|
||||
|
||||
@ -253,7 +270,6 @@ class TestImageUploadWithPubSub:
|
||||
created_image_data = mock_image_repository.create.call_args[0][0]
|
||||
|
||||
assert created_image_data.description == "Test image"
|
||||
assert created_image_data.tags == ["nature", "landscape", "outdoor"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_image_with_collection_id(
|
||||
|
||||
@ -7,51 +7,6 @@ from src.models.image import ImageModel
|
||||
from src.db.repositories.image_repository import image_repository # Assuming this exists
|
||||
|
||||
|
||||
def test_image_search_tags():
|
||||
"""Test the search functionality based on tags (simulated)"""
|
||||
team_id = ObjectId()
|
||||
uploader_id = ObjectId()
|
||||
|
||||
# Create test images with different tags
|
||||
image1 = ImageModel(
|
||||
filename="vacation1.jpg",
|
||||
original_filename="vacation1.jpg",
|
||||
file_size=1024,
|
||||
content_type="image/jpeg",
|
||||
storage_path="images/vacation1.jpg",
|
||||
team_id=team_id,
|
||||
uploader_id=uploader_id,
|
||||
tags=["vacation", "beach", "summer"]
|
||||
)
|
||||
|
||||
image2 = ImageModel(
|
||||
filename="vacation2.jpg",
|
||||
original_filename="vacation2.jpg",
|
||||
file_size=1024,
|
||||
content_type="image/jpeg",
|
||||
storage_path="images/vacation2.jpg",
|
||||
team_id=team_id,
|
||||
uploader_id=uploader_id,
|
||||
tags=["vacation", "mountain", "winter"]
|
||||
)
|
||||
|
||||
# Simulate tag search for "beach"
|
||||
search_results_beach = [img for img in [image1, image2] if "beach" in img.tags]
|
||||
|
||||
# Check results
|
||||
assert len(search_results_beach) == 1
|
||||
assert search_results_beach[0].filename == "vacation1.jpg"
|
||||
|
||||
# Simulate tag search for "vacation"
|
||||
search_results_vacation = [img for img in [image1, image2] if "vacation" in img.tags]
|
||||
|
||||
# Check results
|
||||
assert len(search_results_vacation) == 2
|
||||
filenames = [img.filename for img in search_results_vacation]
|
||||
assert "vacation1.jpg" in filenames
|
||||
assert "vacation2.jpg" in filenames
|
||||
|
||||
|
||||
def test_image_embeddings_structure():
|
||||
"""Test the structure of image embeddings for semantic search"""
|
||||
team_id = ObjectId()
|
||||
@ -125,7 +80,6 @@ async def test_semantic_search(client: TestClient, user_api_key: tuple):
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
description="A cat photo",
|
||||
tags=["cat", "animal", "pet"],
|
||||
has_embedding=True,
|
||||
embedding_id="embedding1",
|
||||
embedding_model="clip"
|
||||
@ -141,7 +95,6 @@ async def test_semantic_search(client: TestClient, user_api_key: tuple):
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
description="A dog photo",
|
||||
tags=["dog", "animal", "pet"],
|
||||
has_embedding=True,
|
||||
embedding_id="embedding2",
|
||||
embedding_model="clip"
|
||||
@ -191,7 +144,7 @@ async def test_search_pagination(client: TestClient, user_api_key: tuple):
|
||||
storage_path=f"images/image{i}.jpg",
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
tags=["test", f"tag{i}"]
|
||||
description=f"Test image {i}"
|
||||
)
|
||||
await image_repository.create(image)
|
||||
|
||||
@ -222,62 +175,4 @@ async def test_search_pagination(client: TestClient, user_api_key: tuple):
|
||||
data = response.json()
|
||||
assert len(data["results"]) == 10
|
||||
assert data["pagination"]["page"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_tags(client: TestClient, user_api_key: tuple):
|
||||
# Test searching by tags
|
||||
raw_key, api_key = user_api_key
|
||||
|
||||
# Set up headers
|
||||
headers = {"X-API-Key": raw_key}
|
||||
|
||||
# Create test images with different tags
|
||||
image1 = ImageModel(
|
||||
filename="vacation1.jpg",
|
||||
original_filename="vacation1.jpg",
|
||||
file_size=1024,
|
||||
content_type="image/jpeg",
|
||||
storage_path="images/vacation1.jpg",
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
tags=["vacation", "beach", "summer"]
|
||||
)
|
||||
await image_repository.create(image1)
|
||||
|
||||
image2 = ImageModel(
|
||||
filename="vacation2.jpg",
|
||||
original_filename="vacation2.jpg",
|
||||
file_size=1024,
|
||||
content_type="image/jpeg",
|
||||
storage_path="images/vacation2.jpg",
|
||||
team_id=api_key.team_id,
|
||||
uploader_id=api_key.user_id,
|
||||
tags=["vacation", "mountain", "winter"]
|
||||
)
|
||||
await image_repository.create(image2)
|
||||
|
||||
# Test search by tag
|
||||
response = client.get(
|
||||
"/api/v1/search?tags=beach",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["results"]) == 1
|
||||
assert data["results"][0]["filename"] == "vacation1.jpg"
|
||||
|
||||
# Test search by multiple tags
|
||||
response = client.get(
|
||||
"/api/v1/search?tags=vacation,winter",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["results"]) == 1
|
||||
assert data["results"][0]["filename"] == "vacation2.jpg"
|
||||
"""
|
||||
@ -118,7 +118,6 @@ def test_image_data() -> Dict[str, Any]:
|
||||
"""Provide test image metadata"""
|
||||
return {
|
||||
"description": "Test image for automated testing",
|
||||
"tags": "test,automation,sample"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,31 +1,35 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
from src.db.repositories.firestore_repository import FirestoreRepository
|
||||
from src.db.repositories.firestore_team_repository import FirestoreTeamRepository
|
||||
from src.db.repositories.firestore_user_repository import FirestoreUserRepository
|
||||
from src.db.repositories.firestore_api_key_repository import FirestoreApiKeyRepository
|
||||
from src.db.repositories.firestore_image_repository import FirestoreImageRepository
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.db.repositories.firestore_repositories import (
|
||||
FirestoreRepository,
|
||||
FirestoreTeamRepository,
|
||||
FirestoreUserRepository,
|
||||
FirestoreApiKeyRepository,
|
||||
FirestoreImageRepository
|
||||
)
|
||||
from src.models.team import TeamModel
|
||||
from src.models.user import UserModel
|
||||
from src.models.api_key import ApiKeyModel
|
||||
from src.models.image import ImageModel
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TestFirestoreRepository:
|
||||
"""Test cases for the base FirestoreRepository"""
|
||||
"""Test cases for the base FirestoreRepository class"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_firestore_db(self):
|
||||
"""Mock firestore_db for testing"""
|
||||
with patch('src.db.repositories.firestore_repository.firestore_db') as mock_db:
|
||||
# Make the async methods return coroutines
|
||||
mock_db.add_document = AsyncMock()
|
||||
mock_db.get_document = AsyncMock()
|
||||
mock_db.list_documents = AsyncMock()
|
||||
mock_db.update_document = AsyncMock()
|
||||
mock_db.delete_document = AsyncMock()
|
||||
yield mock_db
|
||||
"""Mock Firestore database"""
|
||||
mock_db = Mock()
|
||||
mock_collection = Mock()
|
||||
mock_doc = Mock()
|
||||
|
||||
mock_db.collection.return_value = mock_collection
|
||||
mock_collection.document.return_value = mock_doc
|
||||
mock_collection.stream.return_value = []
|
||||
|
||||
return mock_db
|
||||
|
||||
@pytest.fixture
|
||||
def test_model_class(self):
|
||||
@ -48,151 +52,142 @@ class TestFirestoreRepository:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create(self, repository, test_model_class, mock_firestore_db):
|
||||
"""Test creating a new document"""
|
||||
# Setup
|
||||
test_model = test_model_class(name="Test", value=123)
|
||||
mock_firestore_db.add_document.return_value = "generated_id"
|
||||
mock_firestore_db.get_document.return_value = {
|
||||
"name": "Test",
|
||||
"value": 123,
|
||||
"_id": "generated_id"
|
||||
}
|
||||
|
||||
# Execute
|
||||
result = await repository.create(test_model)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, test_model_class)
|
||||
assert result.name == "Test"
|
||||
assert result.value == 123
|
||||
mock_firestore_db.add_document.assert_called_once_with(
|
||||
"test_collection",
|
||||
{"name": "Test", "value": 123}
|
||||
)
|
||||
mock_firestore_db.get_document.assert_called_once_with(
|
||||
"test_collection",
|
||||
"generated_id"
|
||||
)
|
||||
"""Test creating a document"""
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
# Mock the document reference and set operation
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.id = "test_id"
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.add.return_value = (None, mock_doc_ref)
|
||||
|
||||
# Create test model instance
|
||||
test_instance = test_model_class(name="test", value=42)
|
||||
|
||||
# Call create method
|
||||
result = await repository.create(test_instance)
|
||||
|
||||
# Verify the result
|
||||
assert result.name == "test"
|
||||
assert result.value == 42
|
||||
|
||||
# Verify Firestore calls
|
||||
mock_firestore_db.collection.assert_called_once_with("test_collection")
|
||||
mock_collection.add.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_found(self, repository, test_model_class, mock_firestore_db):
|
||||
"""Test getting a document by ID when it exists"""
|
||||
# Setup
|
||||
mock_firestore_db.get_document.return_value = {
|
||||
"name": "Test",
|
||||
"value": 123,
|
||||
"_id": "test_id"
|
||||
}
|
||||
|
||||
# Execute
|
||||
result = await repository.get_by_id("test_id")
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, test_model_class)
|
||||
assert result.name == "Test"
|
||||
assert result.value == 123
|
||||
mock_firestore_db.get_document.assert_called_once_with("test_collection", "test_id")
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
# Mock document snapshot
|
||||
mock_doc_snapshot = Mock()
|
||||
mock_doc_snapshot.exists = True
|
||||
mock_doc_snapshot.to_dict.return_value = {"name": "test", "value": 42}
|
||||
mock_doc_snapshot.id = "test_id"
|
||||
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.get.return_value = mock_doc_snapshot
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.document.return_value = mock_doc_ref
|
||||
|
||||
result = await repository.get_by_id("test_id")
|
||||
|
||||
assert result.name == "test"
|
||||
assert result.value == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_not_found(self, repository, mock_firestore_db):
|
||||
"""Test getting a document by ID when it doesn't exist"""
|
||||
# Setup
|
||||
mock_firestore_db.get_document.return_value = None
|
||||
|
||||
# Execute
|
||||
result = await repository.get_by_id("nonexistent_id")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_firestore_db.get_document.assert_called_once_with("test_collection", "nonexistent_id")
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
# Mock document snapshot that doesn't exist
|
||||
mock_doc_snapshot = Mock()
|
||||
mock_doc_snapshot.exists = False
|
||||
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.get.return_value = mock_doc_snapshot
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.document.return_value = mock_doc_ref
|
||||
|
||||
result = await repository.get_by_id("nonexistent_id")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all(self, repository, test_model_class, mock_firestore_db):
|
||||
"""Test getting all documents"""
|
||||
# Setup
|
||||
mock_firestore_db.list_documents.return_value = [
|
||||
{"name": "Test1", "value": 123, "_id": "id1"},
|
||||
{"name": "Test2", "value": 456, "_id": "id2"}
|
||||
]
|
||||
|
||||
# Execute
|
||||
result = await repository.get_all()
|
||||
|
||||
# Assert
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(item, test_model_class) for item in result)
|
||||
assert result[0].name == "Test1"
|
||||
assert result[1].name == "Test2"
|
||||
mock_firestore_db.list_documents.assert_called_once_with("test_collection")
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
# Mock document snapshots
|
||||
mock_docs = [
|
||||
Mock(to_dict=lambda: {"name": "test1", "value": 1}, id="id1"),
|
||||
Mock(to_dict=lambda: {"name": "test2", "value": 2}, id="id2")
|
||||
]
|
||||
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.stream.return_value = mock_docs
|
||||
|
||||
result = await repository.get_all()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].name == "test1"
|
||||
assert result[1].name == "test2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_success(self, repository, test_model_class, mock_firestore_db):
|
||||
"""Test updating a document successfully"""
|
||||
# Setup
|
||||
update_data = {"name": "Updated", "value": 999}
|
||||
mock_firestore_db.update_document.return_value = True
|
||||
mock_firestore_db.get_document.return_value = {
|
||||
"name": "Updated",
|
||||
"value": 999,
|
||||
"_id": "test_id"
|
||||
}
|
||||
|
||||
# Execute
|
||||
result = await repository.update("test_id", update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, test_model_class)
|
||||
assert result.name == "Updated"
|
||||
assert result.value == 999
|
||||
mock_firestore_db.update_document.assert_called_once_with(
|
||||
"test_collection",
|
||||
"test_id",
|
||||
update_data
|
||||
)
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
# Mock successful update
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.update.return_value = None # Firestore update returns None on success
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.document.return_value = mock_doc_ref
|
||||
|
||||
# Mock get_by_id to return updated document
|
||||
updated_instance = test_model_class(name="updated", value=99)
|
||||
with patch.object(repository, 'get_by_id', return_value=updated_instance):
|
||||
result = await repository.update("test_id", {"name": "updated", "value": 99})
|
||||
|
||||
assert result.name == "updated"
|
||||
assert result.value == 99
|
||||
mock_doc_ref.update.assert_called_once_with({"name": "updated", "value": 99})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_failure(self, repository, mock_firestore_db):
|
||||
"""Test updating a document that doesn't exist"""
|
||||
# Setup
|
||||
update_data = {"name": "Updated"}
|
||||
mock_firestore_db.update_document.return_value = False
|
||||
|
||||
# Execute
|
||||
result = await repository.update("nonexistent_id", update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_firestore_db.update_document.assert_called_once_with(
|
||||
"test_collection",
|
||||
"nonexistent_id",
|
||||
update_data
|
||||
)
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
# Mock failed update (document doesn't exist)
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.update.side_effect = Exception("Document not found")
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.document.return_value = mock_doc_ref
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await repository.update("nonexistent_id", {"name": "updated"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_success(self, repository, mock_firestore_db):
|
||||
"""Test deleting a document successfully"""
|
||||
# Setup
|
||||
mock_firestore_db.delete_document.return_value = True
|
||||
|
||||
# Execute
|
||||
result = await repository.delete("test_id")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_firestore_db.delete_document.assert_called_once_with("test_collection", "test_id")
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.delete.return_value = None # Firestore delete returns None on success
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.document.return_value = mock_doc_ref
|
||||
|
||||
result = await repository.delete("test_id")
|
||||
|
||||
assert result is True
|
||||
mock_doc_ref.delete.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_failure(self, repository, mock_firestore_db):
|
||||
"""Test deleting a document that doesn't exist"""
|
||||
# Setup
|
||||
mock_firestore_db.delete_document.return_value = False
|
||||
|
||||
# Execute
|
||||
result = await repository.delete("nonexistent_id")
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_firestore_db.delete_document.assert_called_once_with("test_collection", "nonexistent_id")
|
||||
with patch('src.db.repositories.firestore_repositories.get_firestore_db', return_value=mock_firestore_db):
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.delete.side_effect = Exception("Document not found")
|
||||
mock_collection = mock_firestore_db.collection.return_value
|
||||
mock_collection.document.return_value = mock_doc_ref
|
||||
|
||||
result = await repository.delete("nonexistent_id")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestFirestoreTeamRepository:
|
||||
@ -211,21 +206,24 @@ class TestFirestoreTeamRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repository):
|
||||
"""Test getting team by ID"""
|
||||
with patch.object(repository.__class__.__bases__[0], 'get_by_id') as mock_get_by_id:
|
||||
mock_get_by_id.return_value = Mock()
|
||||
with patch.object(repository.__class__.__bases__[0], 'get_by_id') as mock_get:
|
||||
mock_get.return_value = Mock(id="team_id", name="Test Team")
|
||||
|
||||
await repository.get_by_id("team_id")
|
||||
result = await repository.get_by_id("team_id")
|
||||
|
||||
mock_get_by_id.assert_called_once_with("team_id")
|
||||
assert result.id == "team_id"
|
||||
assert result.name == "Test Team"
|
||||
mock_get.assert_called_once_with("team_id")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update(self, repository):
|
||||
"""Test updating team"""
|
||||
with patch.object(repository.__class__.__bases__[0], 'update') as mock_update:
|
||||
mock_update.return_value = Mock()
|
||||
mock_update.return_value = Mock(name="Updated Team")
|
||||
|
||||
await repository.update("team_id", {"name": "Updated Team"})
|
||||
result = await repository.update("team_id", {"name": "Updated Team"})
|
||||
|
||||
assert result.name == "Updated Team"
|
||||
mock_update.assert_called_once_with("team_id", {"name": "Updated Team"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -402,25 +400,6 @@ class TestFirestoreImageRepository:
|
||||
|
||||
result = await repository.get_by_uploader_id("user1")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == mock_images[0]
|
||||
assert result[1] == mock_images[2]
|
||||
mock_get_all.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_tag(self, repository):
|
||||
"""Test getting images by tag"""
|
||||
mock_images = [
|
||||
Mock(tags=["tag1", "tag2"]),
|
||||
Mock(tags=["tag3"]),
|
||||
Mock(tags=["tag1", "tag4"])
|
||||
]
|
||||
|
||||
with patch.object(repository, 'get_all') as mock_get_all:
|
||||
mock_get_all.return_value = mock_images
|
||||
|
||||
result = await repository.get_by_tag("tag1")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == mock_images[0]
|
||||
assert result[1] == mock_images[2]
|
||||
|
||||
@ -31,7 +31,6 @@ class TestImageModel:
|
||||
assert isinstance(image.upload_date, datetime)
|
||||
assert image.last_accessed is None
|
||||
assert image.description is None
|
||||
assert image.tags == []
|
||||
assert image.metadata == {}
|
||||
assert image.embedding_id is None
|
||||
assert image.embedding_model is None
|
||||
@ -55,7 +54,6 @@ class TestImageModel:
|
||||
public_url=public_url,
|
||||
last_accessed=last_accessed,
|
||||
description="A test image",
|
||||
tags=["test", "image"],
|
||||
metadata={"width": 800, "height": 600},
|
||||
embedding_id="embedding123",
|
||||
embedding_model="clip",
|
||||
@ -65,8 +63,6 @@ class TestImageModel:
|
||||
assert str(image.public_url) == public_url
|
||||
assert image.last_accessed == last_accessed
|
||||
assert image.description == "A test image"
|
||||
assert "test" in image.tags
|
||||
assert "image" in image.tags
|
||||
assert image.metadata["width"] == 800
|
||||
assert image.metadata["height"] == 600
|
||||
assert image.embedding_id == "embedding123"
|
||||
|
||||
@ -42,191 +42,124 @@ from main import app
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestE2EWorkflows:
|
||||
"""End-to-end tests covering complete user workflows with artificial test data"""
|
||||
"""End-to-end tests that simulate real user workflows with artificial data"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def client(self):
|
||||
"""Create test client for the FastAPI app"""
|
||||
"""Create test client for E2E testing"""
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def test_environment(self, client: TestClient):
|
||||
"""Create a complete test environment with artificial data"""
|
||||
"""Create a test environment with team, user, and API key"""
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
|
||||
# Try bootstrap first - if it fails due to existing teams, create manually
|
||||
# Create test environment
|
||||
async def create_test_environment():
|
||||
# Create team
|
||||
team_data = {
|
||||
"name": f"E2E Test Team {unique_suffix}",
|
||||
"description": f"Team for E2E testing {unique_suffix}"
|
||||
}
|
||||
|
||||
# Create admin user
|
||||
admin_data = {
|
||||
"email": f"e2e-admin-{unique_suffix}@test.com",
|
||||
"name": f"E2E Admin {unique_suffix}",
|
||||
"is_admin": True
|
||||
}
|
||||
|
||||
# Create API key
|
||||
api_key_data = {
|
||||
"name": f"E2E API Key {unique_suffix}",
|
||||
"description": "API key for E2E testing"
|
||||
}
|
||||
|
||||
return {
|
||||
"team_data": team_data,
|
||||
"admin_data": admin_data,
|
||||
"api_key_data": api_key_data,
|
||||
"unique_suffix": unique_suffix
|
||||
}
|
||||
|
||||
# Run the async function
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
env_data = loop.run_until_complete(create_test_environment())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Bootstrap the environment
|
||||
bootstrap_data = {
|
||||
"team_name": f"E2E Test Team {unique_suffix}",
|
||||
"admin_email": f"admin-{unique_suffix}@e2etest.com",
|
||||
"admin_name": f"E2E Admin User {unique_suffix}",
|
||||
"api_key_name": f"E2E Test API Key {unique_suffix}"
|
||||
"team_name": env_data["team_data"]["name"],
|
||||
"admin_email": env_data["admin_data"]["email"],
|
||||
"admin_name": env_data["admin_data"]["name"],
|
||||
"api_key_name": env_data["api_key_data"]["name"]
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/auth/bootstrap", params=bootstrap_data)
|
||||
|
||||
# Handle case where team/user already exists
|
||||
if response.status_code == 400:
|
||||
# Bootstrap failed due to existing teams - create manually
|
||||
print(f"⚠️ Bootstrap failed (existing teams), creating test environment manually...")
|
||||
|
||||
# Create a unique environment manually using direct API calls
|
||||
# We'll use a very unique name that won't conflict
|
||||
timestamp = int(time.time())
|
||||
unique_team_name = f"E2E_ISOLATED_TEST_TEAM_{unique_suffix}_{timestamp}"
|
||||
unique_admin_email = f"isolated-admin-{unique_suffix}-{timestamp}@e2etest.com"
|
||||
|
||||
# Try bootstrap again with super unique identifiers
|
||||
bootstrap_data["team_name"] = unique_team_name
|
||||
bootstrap_data["admin_email"] = unique_admin_email
|
||||
|
||||
# Try with more unique identifiers
|
||||
bootstrap_data["team_name"] = f"E2E_TEST_{unique_suffix}_{int(time.time())}"
|
||||
bootstrap_data["admin_email"] = f"e2e-{unique_suffix}-{int(time.time())}@test.com"
|
||||
response = client.post("/api/v1/auth/bootstrap", params=bootstrap_data)
|
||||
|
||||
if response.status_code == 400:
|
||||
# Still failing - this means bootstrap is completely disabled
|
||||
# We need to create the environment using a different approach
|
||||
print(f"⚠️ Bootstrap completely disabled, creating environment via direct repository access...")
|
||||
|
||||
# Import the repositories directly
|
||||
import asyncio
|
||||
from src.db.repositories.team_repository import team_repository
|
||||
from src.db.repositories.user_repository import user_repository
|
||||
from src.db.repositories.api_key_repository import api_key_repository
|
||||
from src.models.team import TeamModel
|
||||
from src.models.user import UserModel
|
||||
from src.models.api_key import ApiKeyModel
|
||||
from src.auth.security import generate_api_key, calculate_expiry_date
|
||||
|
||||
async def create_test_environment():
|
||||
# Create team
|
||||
team = TeamModel(
|
||||
name=unique_team_name,
|
||||
description=f"E2E test team created at {timestamp}"
|
||||
)
|
||||
created_team = await team_repository.create(team)
|
||||
|
||||
# Create admin user
|
||||
user = UserModel(
|
||||
name=f"E2E Admin User {unique_suffix}",
|
||||
email=unique_admin_email,
|
||||
team_id=created_team.id,
|
||||
is_admin=True,
|
||||
is_active=True
|
||||
)
|
||||
created_user = await user_repository.create(user)
|
||||
|
||||
# Generate API key
|
||||
raw_key, hashed_key = generate_api_key(str(created_team.id), str(created_user.id))
|
||||
expiry_date = calculate_expiry_date()
|
||||
|
||||
# Create API key
|
||||
api_key = ApiKeyModel(
|
||||
key_hash=hashed_key,
|
||||
user_id=created_user.id,
|
||||
team_id=created_team.id,
|
||||
name=f"E2E Test API Key {unique_suffix}",
|
||||
description="E2E test API key",
|
||||
expiry_date=expiry_date,
|
||||
is_active=True
|
||||
)
|
||||
created_key = await api_key_repository.create(api_key)
|
||||
|
||||
return {
|
||||
"key": raw_key,
|
||||
"team_id": str(created_team.id),
|
||||
"user_id": str(created_user.id),
|
||||
"id": str(created_key.id)
|
||||
}
|
||||
|
||||
# Run the async function
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
bootstrap_result = loop.run_until_complete(create_test_environment())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
if response.status_code != 201 and 'bootstrap_result' not in locals():
|
||||
pytest.skip(f"Cannot create test environment: {response.status_code} - {response.text}")
|
||||
assert response.status_code == 201, f"Bootstrap failed: {response.text}"
|
||||
result = response.json()
|
||||
|
||||
# Get the bootstrap result
|
||||
if 'bootstrap_result' in locals():
|
||||
# Manual creation
|
||||
api_key = bootstrap_result["key"]
|
||||
team_id = bootstrap_result["team_id"]
|
||||
admin_user_id = bootstrap_result["user_id"]
|
||||
api_key_id = bootstrap_result["id"]
|
||||
else:
|
||||
# Bootstrap succeeded
|
||||
bootstrap_result = response.json()
|
||||
api_key = bootstrap_result["key"]
|
||||
team_id = bootstrap_result["team_id"]
|
||||
admin_user_id = bootstrap_result["user_id"]
|
||||
api_key_id = bootstrap_result["id"]
|
||||
|
||||
headers = {"X-API-Key": api_key}
|
||||
|
||||
print(f"✅ Test environment created - Team: {team_id}, User: {admin_user_id}")
|
||||
|
||||
# Verify the environment works
|
||||
response = client.get("/api/v1/auth/verify", headers=headers)
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"Test environment authentication failed: {response.status_code}")
|
||||
|
||||
env_data = {
|
||||
"api_key": api_key,
|
||||
"team_id": team_id,
|
||||
"admin_user_id": admin_user_id,
|
||||
"headers": headers,
|
||||
"unique_suffix": unique_suffix,
|
||||
# Store environment data
|
||||
env_data.update({
|
||||
"api_key": result["key"],
|
||||
"team_id": result["team_id"],
|
||||
"admin_user_id": result["user_id"],
|
||||
"headers": {"X-API-Key": result["key"]},
|
||||
"created_resources": {
|
||||
"teams": [team_id],
|
||||
"users": [admin_user_id],
|
||||
"api_keys": [api_key_id],
|
||||
"teams": [result["team_id"]],
|
||||
"users": [result["user_id"]],
|
||||
"api_keys": [result["api_key_id"]],
|
||||
"images": []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield env_data
|
||||
|
||||
# Cleanup: Delete all created resources
|
||||
print(f"🧹 Cleaning up test environment...")
|
||||
# Cleanup - delete created resources
|
||||
headers = env_data["headers"]
|
||||
|
||||
try:
|
||||
# Delete all created images
|
||||
for image_id in env_data["created_resources"]["images"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete additional users (keep admin for team deletion)
|
||||
for user_id in env_data["created_resources"]["users"]:
|
||||
if user_id != admin_user_id:
|
||||
try:
|
||||
client.delete(f"/api/v1/users/{user_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete additional teams
|
||||
for team_id_to_delete in env_data["created_resources"]["teams"]:
|
||||
if team_id_to_delete != team_id:
|
||||
try:
|
||||
client.delete(f"/api/v1/teams/{team_id_to_delete}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Finally delete the main team (this should cascade delete the admin user)
|
||||
# Delete images first
|
||||
for image_id in env_data["created_resources"]["images"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete API keys
|
||||
for api_key_id in env_data["created_resources"]["api_keys"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/auth/api-keys/{api_key_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete users
|
||||
for user_id in env_data["created_resources"]["users"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/users/{user_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete teams last
|
||||
for team_id in env_data["created_resources"]["teams"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/teams/{team_id}", headers=headers)
|
||||
print("✅ Test environment cleaned up successfully")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Cleanup warning: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Cleanup error: {e}")
|
||||
except:
|
||||
pass
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def sample_image_file(self):
|
||||
"""Create a sample image file for testing uploads"""
|
||||
"""Create a sample image file for testing"""
|
||||
img = PILImage.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
@ -236,271 +169,166 @@ class TestE2EWorkflows:
|
||||
@pytest.fixture(scope="function")
|
||||
def sample_image_files(self):
|
||||
"""Create multiple sample image files for testing"""
|
||||
images = {}
|
||||
colors = ['red', 'blue', 'green', 'yellow', 'purple']
|
||||
for color in colors:
|
||||
images = []
|
||||
colors = ['red', 'green', 'blue', 'yellow', 'purple']
|
||||
for i, color in enumerate(colors):
|
||||
img = PILImage.new('RGB', (100, 100), color=color)
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
images[color] = img_bytes
|
||||
images.append(img_bytes)
|
||||
return images
|
||||
|
||||
def test_bootstrap_and_basic_workflow(self, test_environment, client: TestClient):
|
||||
"""Test the complete bootstrap and basic workflow"""
|
||||
print(f"🧪 Testing basic workflow with environment {test_environment['unique_suffix']}")
|
||||
"""Test the complete bootstrap process and basic API functionality"""
|
||||
print(f"🧪 Testing bootstrap and basic workflow with environment {test_environment['unique_suffix']}")
|
||||
|
||||
headers = test_environment["headers"]
|
||||
unique_suffix = test_environment["unique_suffix"]
|
||||
env = test_environment
|
||||
headers = env["headers"]
|
||||
|
||||
# Test 1: Authentication verification
|
||||
# Test 1: Verify API key works
|
||||
response = client.get("/api/v1/auth/verify", headers=headers)
|
||||
assert response.status_code == 200
|
||||
print("✅ Authentication verified")
|
||||
auth_data = response.json()
|
||||
assert auth_data["valid"] is True
|
||||
assert auth_data["team_id"] == env["team_id"]
|
||||
assert auth_data["user_id"] == env["admin_user_id"]
|
||||
print("✅ API key verification successful")
|
||||
|
||||
# Test 2: Team management
|
||||
response = client.get(f"/api/v1/teams/{test_environment['team_id']}", headers=headers)
|
||||
# Test 2: List teams (should see our team)
|
||||
response = client.get("/api/v1/teams", headers=headers)
|
||||
assert response.status_code == 200
|
||||
team_data = response.json()
|
||||
assert team_data["id"] == test_environment["team_id"]
|
||||
print("✅ Team retrieval successful")
|
||||
teams = response.json()
|
||||
team_ids = [team["id"] for team in teams]
|
||||
assert env["team_id"] in team_ids
|
||||
print("✅ Team listing successful")
|
||||
|
||||
# Update team description
|
||||
team_update = {"description": f"Updated during E2E testing {unique_suffix}"}
|
||||
response = client.put(f"/api/v1/teams/{test_environment['team_id']}", json=team_update, headers=headers)
|
||||
# Test 3: Get team details
|
||||
response = client.get(f"/api/v1/teams/{env['team_id']}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
print("✅ Team update successful")
|
||||
team = response.json()
|
||||
assert team["id"] == env["team_id"]
|
||||
print("✅ Team details retrieval successful")
|
||||
|
||||
# Test 3: User management
|
||||
user_data = {
|
||||
"email": f"user-{unique_suffix}@e2etest.com",
|
||||
"name": f"E2E Regular User {unique_suffix}",
|
||||
"team_id": test_environment["team_id"],
|
||||
"is_admin": False
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/users", json=user_data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
created_user = response.json()
|
||||
test_environment["created_resources"]["users"].append(created_user["id"])
|
||||
print("✅ User creation successful")
|
||||
|
||||
# Test 4: API key management
|
||||
api_key_data = {
|
||||
"name": f"Additional Test Key {unique_suffix}",
|
||||
"description": f"Extra key for testing {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/auth/api-keys", json=api_key_data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
new_api_key = response.json()
|
||||
test_environment["created_resources"]["api_keys"].append(new_api_key["id"])
|
||||
|
||||
# Test the new API key
|
||||
new_headers = {"X-API-Key": new_api_key["key"]}
|
||||
response = client.get("/api/v1/auth/verify", headers=new_headers)
|
||||
# Test 4: List users (should see admin user)
|
||||
response = client.get("/api/v1/users", headers=headers)
|
||||
assert response.status_code == 200
|
||||
print("✅ Additional API key creation successful")
|
||||
print("✅ New API key authentication successful")
|
||||
users = response.json()
|
||||
user_ids = [user["id"] for user in users]
|
||||
assert env["admin_user_id"] in user_ids
|
||||
print("✅ User listing successful")
|
||||
|
||||
# Test 5: Image upload
|
||||
test_image = self.create_test_image(f"test_image_{unique_suffix}.jpg")
|
||||
files = {"file": (f"test_image_{unique_suffix}.jpg", test_image, "image/jpeg")}
|
||||
data = {
|
||||
"description": f"Test image uploaded during E2E testing {unique_suffix}",
|
||||
"tags": f"e2e,test,{unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
uploaded_image = response.json()
|
||||
test_environment["created_resources"]["images"].append(uploaded_image["id"])
|
||||
print("✅ Image upload successful")
|
||||
|
||||
# Test 6: Image metadata update
|
||||
image_update = {
|
||||
"description": f"Updated description for E2E testing {unique_suffix}",
|
||||
"tags": [f"updated", f"e2e", unique_suffix]
|
||||
}
|
||||
|
||||
response = client.put(f"/api/v1/images/{uploaded_image['id']}", json=image_update, headers=headers)
|
||||
# Test 5: Get user details
|
||||
response = client.get(f"/api/v1/users/{env['admin_user_id']}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
print("✅ Image metadata update successful")
|
||||
user = response.json()
|
||||
assert user["id"] == env["admin_user_id"]
|
||||
assert user["is_admin"] is True
|
||||
print("✅ User details retrieval successful")
|
||||
|
||||
# Test 7: Search functionality (with fallback for missing Pinecone)
|
||||
# Test 6: List API keys
|
||||
response = client.get("/api/v1/auth/api-keys", headers=headers)
|
||||
assert response.status_code == 200
|
||||
api_keys = response.json()
|
||||
assert len(api_keys) >= 1 # Should have at least our bootstrap key
|
||||
print("✅ API key listing successful")
|
||||
|
||||
# Test 7: Basic image operations (placeholder test)
|
||||
response = client.get("/api/v1/images", headers=headers)
|
||||
assert response.status_code == 200
|
||||
images = response.json()
|
||||
assert "images" in images or "message" in images # Handle both implemented and placeholder responses
|
||||
print("✅ Image listing endpoint accessible")
|
||||
|
||||
print("🎉 Bootstrap and basic workflow test passed!")
|
||||
|
||||
def test_advanced_search_functionality(self, test_environment, client: TestClient):
|
||||
"""Test search functionality with fallback for missing services"""
|
||||
print(f"🧪 Testing search functionality with environment {test_environment['unique_suffix']}")
|
||||
|
||||
env = test_environment
|
||||
headers = env["headers"]
|
||||
unique_suffix = env["unique_suffix"]
|
||||
|
||||
# Test basic search endpoint
|
||||
response = client.get(f"/api/v1/search?q={unique_suffix}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
search_results = response.json()
|
||||
|
||||
# Check if search is working (Pinecone configured) or returning empty (Pinecone not configured)
|
||||
# Verify search response structure
|
||||
assert "results" in search_results
|
||||
assert "total" in search_results
|
||||
assert "query" in search_results
|
||||
assert search_results["query"] == unique_suffix
|
||||
|
||||
if len(search_results["results"]) == 0:
|
||||
print("⚠️ Search returned empty results (likely Pinecone not configured)")
|
||||
# Test that search endpoint is at least responding correctly
|
||||
assert "results" in search_results
|
||||
assert "total" in search_results
|
||||
assert search_results["query"] == unique_suffix
|
||||
print("✅ Search endpoint responding correctly (empty results)")
|
||||
else:
|
||||
# If search is working, verify results
|
||||
assert len(search_results["results"]) >= 1
|
||||
print("✅ Search functionality working with results")
|
||||
print("✅ Search endpoint returning results")
|
||||
# Verify result structure
|
||||
for result in search_results["results"]:
|
||||
assert "id" in result
|
||||
assert "description" in result or "filename" in result
|
||||
|
||||
print("🎉 Basic workflow test completed successfully!")
|
||||
|
||||
def test_advanced_search_functionality(self, test_environment, client: TestClient):
|
||||
"""Test advanced search capabilities"""
|
||||
print(f"🧪 Testing advanced search with environment {test_environment['unique_suffix']}")
|
||||
|
||||
headers = test_environment["headers"]
|
||||
unique_suffix = test_environment["unique_suffix"]
|
||||
|
||||
# Upload diverse test images for search testing
|
||||
test_images = [
|
||||
("red", f"red_{unique_suffix}.jpg", f"A red image for testing {unique_suffix}", ["red", "color", unique_suffix]),
|
||||
("blue", f"blue_{unique_suffix}.jpg", f"A blue image for testing {unique_suffix}", ["blue", "color", unique_suffix]),
|
||||
("green", f"green_{unique_suffix}.jpg", f"A green nature image {unique_suffix}", ["green", "nature", unique_suffix]),
|
||||
("yellow", f"yellow_{unique_suffix}.jpg", f"A yellow sunny image {unique_suffix}", ["yellow", "sunny", unique_suffix]),
|
||||
("purple", f"purple_{unique_suffix}.jpg", f"A purple flower image {unique_suffix}", ["purple", "flower", unique_suffix])
|
||||
]
|
||||
|
||||
uploaded_images = []
|
||||
for color, filename, description, tags in test_images:
|
||||
test_image = self.create_test_image(filename)
|
||||
files = {"file": (filename, test_image, "image/jpeg")}
|
||||
data = {
|
||||
"description": description,
|
||||
"tags": ",".join(tags)
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
uploaded_image = response.json()
|
||||
uploaded_images.append(uploaded_image)
|
||||
test_environment["created_resources"]["images"].append(uploaded_image["id"])
|
||||
|
||||
print("✅ Diverse images uploaded for advanced search testing")
|
||||
|
||||
# Test 1: Text-based search (with fallback for missing Pinecone)
|
||||
response = client.get("/api/v1/search?q=nature&limit=10", headers=headers)
|
||||
# Test search with different parameters
|
||||
response = client.get("/api/v1/search?q=nonexistent", headers=headers)
|
||||
assert response.status_code == 200
|
||||
nature_results = response.json()["results"]
|
||||
empty_results = response.json()
|
||||
assert "results" in empty_results
|
||||
assert len(empty_results["results"]) == 0
|
||||
print("✅ Search with no matches handled correctly")
|
||||
|
||||
if len(nature_results) == 0:
|
||||
print("⚠️ Text search returned empty results (likely Pinecone not configured)")
|
||||
# Test that search endpoint structure is correct
|
||||
response = client.get("/api/v1/search?q=test&limit=5", headers=headers)
|
||||
assert response.status_code == 200
|
||||
search_response = response.json()
|
||||
assert "results" in search_response
|
||||
assert "total" in search_response
|
||||
assert "query" in search_response
|
||||
print("✅ Search endpoint structure verified")
|
||||
# Test search without query (should handle gracefully)
|
||||
response = client.get("/api/v1/search", headers=headers)
|
||||
assert response.status_code in [200, 400] # Either works or returns bad request
|
||||
if response.status_code == 200:
|
||||
no_query_results = response.json()
|
||||
assert "results" in no_query_results
|
||||
print("✅ Search without query handled gracefully")
|
||||
else:
|
||||
# If search is working, verify results
|
||||
print(f"✅ Text search returned {len(nature_results)} results")
|
||||
print("✅ Search without query properly rejected")
|
||||
|
||||
# Test 2: Tag-based filtering (this should work regardless of Pinecone)
|
||||
response = client.get(f"/api/v1/search?q=color&tags={unique_suffix}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
tag_results = response.json()["results"]
|
||||
print(f"✅ Tag-based search completed (returned {len(tag_results)} results)")
|
||||
|
||||
# Test 3: Advanced search with POST endpoint
|
||||
advanced_search = {
|
||||
"query": "image",
|
||||
"limit": 5,
|
||||
"threshold": 0.5,
|
||||
"tags": [unique_suffix]
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/search", json=advanced_search, headers=headers)
|
||||
assert response.status_code == 200
|
||||
advanced_results = response.json()["results"]
|
||||
print(f"✅ Advanced POST search completed (returned {len(advanced_results)} results)")
|
||||
|
||||
# Test 4: Search with different thresholds
|
||||
response = client.get(f"/api/v1/search?q={unique_suffix}&threshold=0.1", headers=headers)
|
||||
assert response.status_code == 200
|
||||
low_threshold_results = response.json()["results"]
|
||||
|
||||
response = client.get(f"/api/v1/search?q={unique_suffix}&threshold=0.9", headers=headers)
|
||||
assert response.status_code == 200
|
||||
high_threshold_results = response.json()["results"]
|
||||
|
||||
print(f"✅ Threshold testing completed (low: {len(low_threshold_results)}, high: {len(high_threshold_results)})")
|
||||
|
||||
# Test 5: Verify search response structure
|
||||
response = client.get(f"/api/v1/search?q=test&limit=3", headers=headers)
|
||||
assert response.status_code == 200
|
||||
search_response = response.json()
|
||||
|
||||
# Verify response structure
|
||||
required_fields = ["query", "results", "total", "limit", "threshold"]
|
||||
for field in required_fields:
|
||||
assert field in search_response, f"Missing field: {field}"
|
||||
|
||||
print("✅ Search response structure verified")
|
||||
|
||||
print("🎉 Advanced search functionality test completed!")
|
||||
print("🎉 Search functionality test completed!")
|
||||
|
||||
def create_test_image(self, filename: str) -> io.BytesIO:
|
||||
"""Create a simple test image file"""
|
||||
from PIL import Image
|
||||
|
||||
# Create a simple 100x100 colored image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
"""Create a test image for upload testing"""
|
||||
img = PILImage.new('RGB', (200, 200), color='blue')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
return img_bytes
|
||||
|
||||
def test_user_roles_and_permissions(self, test_environment, client: TestClient):
|
||||
"""Test user roles and permission management"""
|
||||
print(f"🧪 Testing user roles and permissions with environment {test_environment['unique_suffix']}")
|
||||
"""Test user roles and permissions with artificial data"""
|
||||
|
||||
headers = test_environment["headers"]
|
||||
unique_suffix = test_environment["unique_suffix"]
|
||||
env = test_environment
|
||||
admin_headers = env["headers"]
|
||||
unique_suffix = env["unique_suffix"]
|
||||
|
||||
# Create a regular user
|
||||
print(f"🧪 Testing user roles and permissions with environment {unique_suffix}")
|
||||
|
||||
# Test 1: Admin can create users
|
||||
regular_user_data = {
|
||||
"email": f"regular-{unique_suffix}@roletest.com",
|
||||
"email": f"regular-user-{unique_suffix}@test.com",
|
||||
"name": f"Regular User {unique_suffix}",
|
||||
"team_id": test_environment["team_id"],
|
||||
"is_admin": False
|
||||
"is_admin": False,
|
||||
"team_id": env["team_id"]
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/users", json=regular_user_data, headers=headers)
|
||||
response = client.post("/api/v1/users", json=regular_user_data, headers=admin_headers)
|
||||
assert response.status_code == 201
|
||||
regular_user = response.json()
|
||||
test_environment["created_resources"]["users"].append(regular_user["id"])
|
||||
print("✅ Regular user created")
|
||||
env["created_resources"]["users"].append(regular_user["id"])
|
||||
|
||||
# Create API key for regular user (admin creates it, but it will be associated with the regular user)
|
||||
# Note: In the current implementation, API keys are created by the current user (admin)
|
||||
# but we need to create a key that can be used by the regular user
|
||||
# Verify user properties
|
||||
assert regular_user["email"] == regular_user_data["email"]
|
||||
assert regular_user["name"] == regular_user_data["name"]
|
||||
assert regular_user["is_admin"] is False
|
||||
assert regular_user["team_id"] == env["team_id"]
|
||||
|
||||
# For now, let's test that the admin can create users and the regular user exists
|
||||
# We'll verify the regular user's profile by getting it directly
|
||||
|
||||
# Test admin user profile access
|
||||
response = client.get("/api/v1/users/me", headers=headers)
|
||||
assert response.status_code == 200
|
||||
admin_profile = response.json()
|
||||
assert admin_profile["is_admin"] == True
|
||||
print("✅ Admin user profile access verified")
|
||||
|
||||
# Test that we can retrieve the regular user's information (as admin)
|
||||
response = client.get(f"/api/v1/users/{regular_user['id']}", headers=headers)
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
assert user_info["email"] == f"regular-{unique_suffix}@roletest.com"
|
||||
assert user_info["is_admin"] == False
|
||||
print("✅ Regular user information verified")
|
||||
else:
|
||||
# If direct user access isn't available, verify through user listing
|
||||
print("⚠️ Direct user access not available, verifying through creation response")
|
||||
assert regular_user["email"] == f"regular-{unique_suffix}@roletest.com"
|
||||
assert regular_user["is_admin"] == False
|
||||
if "is_active" in regular_user:
|
||||
assert regular_user["is_active"] is True
|
||||
print("✅ Regular user creation verified")
|
||||
|
||||
# Test that regular user can upload images (basic functionality)
|
||||
@ -509,19 +337,18 @@ class TestE2EWorkflows:
|
||||
test_image = self.create_test_image(f"regular_user_image_{unique_suffix}.jpg")
|
||||
files = {"file": ("regular_user_image.jpg", test_image, "image/jpeg")}
|
||||
data = {
|
||||
"description": f"Image uploaded by admin for regular user testing {unique_suffix}",
|
||||
"tags": f"regular,user,{unique_suffix}"
|
||||
"description": f"Image uploaded by admin for regular user testing {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=admin_headers)
|
||||
assert response.status_code == 201
|
||||
uploaded_image = response.json()
|
||||
test_environment["created_resources"]["images"].append(uploaded_image["id"])
|
||||
env["created_resources"]["images"].append(uploaded_image["id"])
|
||||
print("✅ Image upload functionality verified")
|
||||
|
||||
# Verify the image belongs to the admin user (since we used admin's API key)
|
||||
assert uploaded_image["uploader_id"] == test_environment["admin_user_id"]
|
||||
assert uploaded_image["team_id"] == test_environment["team_id"]
|
||||
assert uploaded_image["uploader_id"] == env["admin_user_id"]
|
||||
assert uploaded_image["team_id"] == env["team_id"]
|
||||
print("✅ Image ownership verification successful")
|
||||
|
||||
def test_multi_team_isolation(self, client: TestClient, test_environment, sample_image_file):
|
||||
@ -618,8 +445,7 @@ class TestE2EWorkflows:
|
||||
sample_image_file.seek(0)
|
||||
files1 = {"file": (f"team1_image_{unique_suffix}.jpg", sample_image_file, "image/jpeg")}
|
||||
data1 = {
|
||||
"description": f"Team 1 confidential image {unique_suffix}",
|
||||
"tags": f"team1,confidential,{unique_suffix}"
|
||||
"description": f"Team 1 confidential image {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files1, data=data1, headers=team1_headers)
|
||||
@ -631,8 +457,7 @@ class TestE2EWorkflows:
|
||||
sample_image_file.seek(0)
|
||||
files2 = {"file": (f"team2_image_{unique_suffix}.jpg", sample_image_file, "image/jpeg")}
|
||||
data2 = {
|
||||
"description": f"Team 2 secret image {unique_suffix}",
|
||||
"tags": f"team2,secret,{unique_suffix}"
|
||||
"description": f"Team 2 secret image {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files2, data=data2, headers=team2_headers)
|
||||
@ -689,8 +514,7 @@ class TestE2EWorkflows:
|
||||
test_image = self.create_test_image(f"metadata_test_{unique_suffix}.jpg")
|
||||
files = {"file": (f"metadata_test_{unique_suffix}.jpg", test_image, "image/jpeg")}
|
||||
data = {
|
||||
"description": f"Initial metadata test image {unique_suffix}",
|
||||
"tags": f"initial,metadata,{unique_suffix}"
|
||||
"description": f"Initial metadata test image {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
@ -711,20 +535,8 @@ class TestE2EWorkflows:
|
||||
assert f"Updated description for metadata testing {unique_suffix}" in updated_image["description"]
|
||||
print("✅ Description update successful")
|
||||
|
||||
# Test 2: Update tags
|
||||
tags_update = {
|
||||
"tags": ["updated", "metadata", "testing", unique_suffix]
|
||||
}
|
||||
|
||||
response = client.put(f"/api/v1/images/{image_id}", json=tags_update, headers=headers)
|
||||
assert response.status_code == 200
|
||||
updated_image = response.json()
|
||||
assert "updated" in updated_image["tags"]
|
||||
assert unique_suffix in updated_image["tags"]
|
||||
print("✅ Tags update successful")
|
||||
|
||||
# Test 3: Search by updated metadata (with fallback for missing Pinecone)
|
||||
response = client.get(f"/api/v1/search?q=updated&tags={unique_suffix}", headers=headers)
|
||||
# Test 2: Search by updated metadata (with fallback for missing Pinecone)
|
||||
response = client.get(f"/api/v1/search?q=updated", headers=headers)
|
||||
assert response.status_code == 200
|
||||
search_results = response.json()
|
||||
found_images = search_results["results"]
|
||||
@ -739,9 +551,9 @@ class TestE2EWorkflows:
|
||||
else:
|
||||
# If search is working, verify we can find our updated image
|
||||
assert len(found_images) >= 1
|
||||
# Check if our image is in the results (by checking tags)
|
||||
# Check if our image is in the results (by checking description)
|
||||
our_image_found = any(
|
||||
unique_suffix in img.get("tags", []) and "updated" in img.get("tags", [])
|
||||
unique_suffix in img.get("description", "")
|
||||
for img in found_images
|
||||
)
|
||||
if our_image_found:
|
||||
@ -749,19 +561,16 @@ class TestE2EWorkflows:
|
||||
else:
|
||||
print("⚠️ Updated image not found in search results (may be due to indexing delay)")
|
||||
|
||||
# Test 4: Retrieve image directly to verify metadata persistence
|
||||
# Test 3: Retrieve image directly to verify metadata persistence
|
||||
response = client.get(f"/api/v1/images/{image_id}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
retrieved_image = response.json()
|
||||
|
||||
# Verify all metadata updates persisted
|
||||
assert f"Updated description for metadata testing {unique_suffix}" in retrieved_image["description"]
|
||||
assert "updated" in retrieved_image["tags"]
|
||||
assert "metadata" in retrieved_image["tags"]
|
||||
assert unique_suffix in retrieved_image["tags"]
|
||||
print("✅ Metadata persistence verified")
|
||||
|
||||
# Test 5: Partial metadata update (only description)
|
||||
# Test 4: Partial metadata update (only description)
|
||||
partial_update = {
|
||||
"description": f"Final description update {unique_suffix}"
|
||||
}
|
||||
@ -770,9 +579,8 @@ class TestE2EWorkflows:
|
||||
assert response.status_code == 200
|
||||
final_image = response.json()
|
||||
|
||||
# Verify description changed but tags remained
|
||||
# Verify description changed
|
||||
assert f"Final description update {unique_suffix}" in final_image["description"]
|
||||
assert "updated" in final_image["tags"] # Tags should remain unchanged
|
||||
print("✅ Partial metadata update successful")
|
||||
|
||||
print("🎉 Image metadata operations test completed!")
|
||||
@ -875,7 +683,7 @@ class TestE2EIntegrationWorkflows:
|
||||
img_bytes.seek(0)
|
||||
|
||||
files = {"file": (f"integration_test_{unique_suffix}.jpg", img_bytes, "image/jpeg")}
|
||||
data = {"description": f"Integration test image {unique_suffix}", "tags": f"integration,test,{unique_suffix}"}
|
||||
data = {"description": f"Integration test image {unique_suffix}"}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
@ -909,7 +717,7 @@ class TestE2ERealDatabaseWorkflows:
|
||||
def client(self):
|
||||
"""Create test client for real database testing"""
|
||||
if not os.getenv("E2E_REALDB_TEST"):
|
||||
pytest.skip("Real database tests disabled. Set E2E_REALDB_TEST=1 to enable")
|
||||
pytest.skip("E2E real database tests disabled. Set E2E_REALDB_TEST=1 to enable")
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
@ -947,21 +755,23 @@ class TestE2ERealDatabaseWorkflows:
|
||||
yield env_data
|
||||
|
||||
# Cleanup
|
||||
headers = env_data["headers"]
|
||||
|
||||
# Delete created images
|
||||
for image_id in env_data["created_images"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete team (this should cascade delete users and API keys)
|
||||
try:
|
||||
# Clean up images first
|
||||
for image_id in env_data["created_images"]:
|
||||
try:
|
||||
client.delete(f"/api/v1/images/{image_id}", headers=env_data["headers"])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Clean up team
|
||||
client.delete(f"/api/v1/teams/{env_data['team_id']}", headers=env_data["headers"])
|
||||
client.delete(f"/api/v1/teams/{env_data['team_id']}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
|
||||
def test_database_performance_and_scalability(self, client: TestClient, realdb_environment):
|
||||
"""Test database performance with larger datasets using artificial data"""
|
||||
"""Test database performance with bulk operations and artificial data"""
|
||||
|
||||
env = realdb_environment
|
||||
headers = env["headers"]
|
||||
@ -969,42 +779,56 @@ class TestE2ERealDatabaseWorkflows:
|
||||
|
||||
print(f"🧪 Testing database performance with environment {unique_suffix}")
|
||||
|
||||
# Test 1: Bulk image upload performance
|
||||
start_time = time.time()
|
||||
uploaded_images = []
|
||||
# Create multiple images for performance testing
|
||||
image_count = 10 # Reduced for faster testing
|
||||
created_images = []
|
||||
|
||||
for i in range(10): # Upload 10 images
|
||||
img = PILImage.new('RGB', (200, 200), color='red')
|
||||
start_time = time.time()
|
||||
|
||||
for i in range(image_count):
|
||||
# Create test image
|
||||
img = PILImage.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
files = {"file": (f"perf_test_{unique_suffix}_{i}.jpg", img_bytes, "image/jpeg")}
|
||||
files = {"file": (f"perf_test_{i}_{unique_suffix}.jpg", img_bytes, "image/jpeg")}
|
||||
data = {
|
||||
"description": f"Performance test image {i} {unique_suffix}",
|
||||
"tags": f"performance,test,bulk,image_{i},{unique_suffix}"
|
||||
"description": f"Performance test image {i} {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
image_id = response.json()["id"]
|
||||
uploaded_images.append(image_id)
|
||||
env["created_images"].append(image_id)
|
||||
image = response.json()
|
||||
created_images.append(image["id"])
|
||||
env["created_images"].append(image["id"])
|
||||
|
||||
upload_time = time.time() - start_time
|
||||
print(f"✅ Bulk upload of 10 images completed in {upload_time:.2f} seconds")
|
||||
print(f"✅ Uploaded {image_count} images in {upload_time:.2f} seconds")
|
||||
|
||||
# Test 2: Search performance
|
||||
# Test bulk retrieval performance
|
||||
start_time = time.time()
|
||||
response = client.get(f"/api/v1/search?q=performance {unique_suffix}&limit=20", headers=headers)
|
||||
response = client.get("/api/v1/images", headers=headers)
|
||||
assert response.status_code == 200
|
||||
images = response.json()
|
||||
retrieval_time = time.time() - start_time
|
||||
|
||||
assert len(images["images"]) >= image_count
|
||||
print(f"✅ Retrieved images in {retrieval_time:.2f} seconds")
|
||||
|
||||
# Test search performance (if available)
|
||||
start_time = time.time()
|
||||
response = client.get(f"/api/v1/search?q={unique_suffix}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
search_results = response.json()
|
||||
search_time = time.time() - start_time
|
||||
|
||||
print(f"✅ Search completed in {search_time:.2f} seconds")
|
||||
|
||||
print("🎉 Database performance and scalability test passed!")
|
||||
print("🎉 Database performance test completed!")
|
||||
|
||||
def test_data_consistency_and_transactions(self, client: TestClient, realdb_environment):
|
||||
"""Test data consistency across operations with artificial data"""
|
||||
"""Test data consistency and transaction handling with artificial data"""
|
||||
|
||||
env = realdb_environment
|
||||
headers = env["headers"]
|
||||
@ -1012,34 +836,15 @@ class TestE2ERealDatabaseWorkflows:
|
||||
|
||||
print(f"🧪 Testing data consistency with environment {unique_suffix}")
|
||||
|
||||
# Test 1: Create team and verify consistency
|
||||
team_data = {
|
||||
"name": f"Consistency Test Team {unique_suffix}",
|
||||
"description": f"Testing data consistency {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/teams", json=team_data, headers=headers)
|
||||
assert response.status_code == 201
|
||||
team = response.json()
|
||||
team_id = team["id"]
|
||||
|
||||
# Immediately verify team exists
|
||||
response = client.get(f"/api/v1/teams/{team_id}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
retrieved_team = response.json()
|
||||
assert retrieved_team["name"] == f"Consistency Test Team {unique_suffix}"
|
||||
print("✅ Team creation consistency verified")
|
||||
|
||||
# Test 2: Upload image and verify metadata consistency
|
||||
img = PILImage.new('RGB', (100, 100), color='blue')
|
||||
# Upload an image
|
||||
img = PILImage.new('RGB', (100, 100), color='green')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
files = {"file": (f"consistency_test_{unique_suffix}.jpg", img_bytes, "image/jpeg")}
|
||||
data = {
|
||||
"description": f"Consistency test image {unique_suffix}",
|
||||
"tags": f"consistency,test,{unique_suffix}"
|
||||
"description": f"Consistency test image {unique_suffix}"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/images", files=files, data=data, headers=headers)
|
||||
@ -1048,26 +853,36 @@ class TestE2ERealDatabaseWorkflows:
|
||||
image_id = image["id"]
|
||||
env["created_images"].append(image_id)
|
||||
|
||||
# Verify image metadata immediately
|
||||
# Verify image exists
|
||||
response = client.get(f"/api/v1/images/{image_id}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
retrieved_image = response.json()
|
||||
assert retrieved_image["description"] == f"Consistency test image {unique_suffix}"
|
||||
assert unique_suffix in retrieved_image["tags"]
|
||||
print("✅ Image metadata consistency verified")
|
||||
assert retrieved_image["id"] == image_id
|
||||
assert unique_suffix in retrieved_image["description"]
|
||||
print("✅ Image consistency verified")
|
||||
|
||||
# Cleanup the test team
|
||||
try:
|
||||
client.delete(f"/api/v1/teams/{team_id}", headers=headers)
|
||||
except:
|
||||
pass
|
||||
# Update image metadata
|
||||
update_data = {
|
||||
"description": f"Updated consistency test image {unique_suffix}"
|
||||
}
|
||||
|
||||
print("🎉 Data consistency and transactions test passed!")
|
||||
response = client.put(f"/api/v1/images/{image_id}", json=update_data, headers=headers)
|
||||
assert response.status_code == 200
|
||||
updated_image = response.json()
|
||||
assert f"Updated consistency test image {unique_suffix}" in updated_image["description"]
|
||||
|
||||
# Verify update persistence
|
||||
response = client.get(f"/api/v1/images/{image_id}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
final_image = response.json()
|
||||
assert f"Updated consistency test image {unique_suffix}" in final_image["description"]
|
||||
print("✅ Update consistency verified")
|
||||
|
||||
print("🎉 Data consistency test completed!")
|
||||
|
||||
|
||||
# Utility functions for E2E tests
|
||||
def create_test_image(width: int = 100, height: int = 100, color: str = 'red') -> io.BytesIO:
|
||||
"""Create a test image for upload testing"""
|
||||
"""Helper function to create test images"""
|
||||
img = PILImage.new('RGB', (width, height), color=color)
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
@ -1076,9 +891,9 @@ def create_test_image(width: int = 100, height: int = 100, color: str = 'red') -
|
||||
|
||||
|
||||
def create_test_images_batch(count: int = 5, base_name: str = "test") -> List[io.BytesIO]:
|
||||
"""Create a batch of test images"""
|
||||
"""Helper function to create multiple test images"""
|
||||
images = []
|
||||
colors = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'black']
|
||||
colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'black']
|
||||
|
||||
for i in range(count):
|
||||
color = colors[i % len(colors)]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user