diff --git a/README.md b/README.md index 12970f0..b8e5f74 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/client/README.md b/client/README.md index 390faee..6fe6eed 100644 --- a/client/README.md +++ b/client/README.md @@ -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 diff --git a/client/index.html b/client/index.html index 90cb3a4..494821b 100644 --- a/client/index.html +++ b/client/index.html @@ -82,7 +82,7 @@
Upload Images
-

Upload and manage your image collection with metadata and tags.

+

Upload and manage your image collection with metadata.

diff --git a/client/js/api.js b/client/js/api.js index afd1b50..6a67f66 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -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); } diff --git a/client/js/images.js b/client/js/images.js index bcf84a7..b8f20a7 100644 --- a/client/js/images.js +++ b/client/js/images.js @@ -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 = '
Loading images...
'; 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) {

${formatDate(image.upload_date)}

- ${image.tags && image.tags.length > 0 ? ` -
- ${image.tags.map(tag => `${escapeHtml(tag)}`).join('')} -
- ` : ''}
-
- - -
e.g., nature, landscape, sunset
-
`; @@ -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) {

Type: ${image.content_type}

- ${image.tags && image.tags.length > 0 ? ` -
-
Tags
- ${image.tags.map(tag => `${escapeHtml(tag)}`).join('')} -
- ` : ''} `; const modalFooter = ` @@ -403,12 +381,6 @@ async function editImage(imageId) { -
- - -
Enter tags separated by commas
-
`; @@ -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'); diff --git a/deployment/terraform/terraform.tfstate b/deployment/terraform/terraform.tfstate index e4f22d7..a988b87 100644 --- a/deployment/terraform/terraform.tfstate +++ b/deployment/terraform/terraform.tfstate @@ -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, diff --git a/deployment/terraform/terraform.tfstate.backup b/deployment/terraform/terraform.tfstate.backup index de5103c..bfe8cdd 100644 --- a/deployment/terraform/terraform.tfstate.backup +++ b/deployment/terraform/terraform.tfstate.backup @@ -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, diff --git a/docs/TESTING.md b/docs/TESTING.md deleted file mode 100644 index 4a26f60..0000000 --- a/docs/TESTING.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/scripts/seed_firestore.py b/scripts/seed_firestore.py index 1681e89..dccc620 100644 --- a/scripts/seed_firestore.py +++ b/scripts/seed_firestore.py @@ -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 ) diff --git a/simple_search_test.py b/simple_search_test.py new file mode 100644 index 0000000..70b638c --- /dev/null +++ b/simple_search_test.py @@ -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) \ No newline at end of file diff --git a/src/api/v1/images.py b/src/api/v1/images.py index 4506cbd..3e80001 100644 --- a/src/api/v1/images.py +++ b/src/api/v1/images.py @@ -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 diff --git a/src/api/v1/search.py b/src/api/v1/search.py index ee25d83..c457f6e 100644 --- a/src/api/v1/search.py +++ b/src/api/v1/search.py @@ -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, diff --git a/src/db/repositories/firestore_image_repository.py b/src/db/repositories/firestore_image_repository.py index 1d0e01c..95e479c 100644 --- a/src/db/repositories/firestore_image_repository.py +++ b/src/db/repositories/firestore_image_repository.py @@ -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: diff --git a/src/models/image.py b/src/models/image.py index 8eafbcb..0d2f73c 100644 --- a/src/models/image.py +++ b/src/models/image.py @@ -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 diff --git a/src/schemas/image.py b/src/schemas/image.py index 8153e8b..03454b4 100644 --- a/src/schemas/image.py +++ b/src/schemas/image.py @@ -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, diff --git a/src/schemas/search.py b/src/schemas/search.py index 837d7df..bcbf3b0 100644 --- a/src/schemas/search.py +++ b/src/schemas/search.py @@ -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, diff --git a/src/services/vector_db.py b/src/services/vector_db.py index e8c80ef..55b42f2 100644 --- a/src/services/vector_db.py +++ b/src/services/vector_db.py @@ -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]]: """ diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 5a87546..a981f1f 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -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 diff --git a/tests/api/test_image_upload.py b/tests/api/test_image_upload.py index c5c0533..d84d34b 100644 --- a/tests/api/test_image_upload.py +++ b/tests/api/test_image_upload.py @@ -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 diff --git a/tests/api/test_images.py b/tests/api/test_images.py index 5f34d77..b23b2d3 100644 --- a/tests/api/test_images.py +++ b/tests/api/test_images.py @@ -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"] """ \ No newline at end of file diff --git a/tests/api/test_images_pubsub.py b/tests/api/test_images_pubsub.py index 3778ac8..b91430c 100644 --- a/tests/api/test_images_pubsub.py +++ b/tests/api/test_images_pubsub.py @@ -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( diff --git a/tests/api/test_search.py b/tests/api/test_search.py index 63da5eb..c902218 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -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" """ \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e1897d7..c99a2f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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" } diff --git a/tests/db/test_firestore_repositories.py b/tests/db/test_firestore_repositories.py index e00a87e..fe29409 100644 --- a/tests/db/test_firestore_repositories.py +++ b/tests/db/test_firestore_repositories.py @@ -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] diff --git a/tests/db/test_image_model.py b/tests/db/test_image_model.py index b0c3bec..a97cc18 100644 --- a/tests/db/test_image_model.py +++ b/tests/db/test_image_model.py @@ -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" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 6832cf5..e487083 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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)]