diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51416b8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + smoke: + runs-on: ubuntu-latest + + services: + mongodb: + image: mongo:latest + options: >- + --health-cmd mongosh + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 27017:27017 + env: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: mongodb + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Repo smoke checks + shell: bash + run: | + set -euo pipefail + + echo "Validating repository structure and basic runnable signals" + + has_signal=0 + + if find . -maxdepth 4 -type f -name "package.json" | grep -q .; then + has_signal=1 + while IFS= read -r pkg; do + [ -z "$pkg" ] && continue + node -e "const fs=require('fs'); JSON.parse(fs.readFileSync(process.argv[1],'utf8'));" "$pkg" + done < <(find . -maxdepth 4 -type f -name "package.json") + fi + + if find . -maxdepth 4 -type f \( -name "pyproject.toml" -o -name "requirements.txt" -o -name "setup.py" -o -name "manage.py" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "app.py" -o -name "main.py" -o -name "wsgi.py" -o -name "asgi.py" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "pom.xml" -o -name "build.gradle" -o -name "build.gradle.kts" -o -name "gradlew" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f -name "go.mod" | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f -name "Cargo.toml" | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "*.csproj" -o -name "*.sln" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f \( -name "Dockerfile" -o -name "docker-compose.yml" -o -name "docker-compose.yaml" \) | grep -q .; then + has_signal=1 + fi + + if find . -maxdepth 4 -type f -name "Makefile" | grep -q .; then + has_signal=1 + fi + + if [ "$has_signal" -ne 1 ]; then + echo "No runnable/build signals found in repository" + exit 1 + fi + + echo "Running Python syntax smoke check" + python_files="$(find . -type f -name '*.py' -not -path './.git/*' 2>/dev/null || true)" + if [ -n "$python_files" ]; then + while IFS= read -r f; do + [ -z "$f" ] && continue + python -m py_compile "$f" + done <<< "$python_files" + fi + + echo "Smoke checks passed" + + - name: Install runtime test dependencies + run: pip install Pillow fastapi pymongo pydantic uvicorn + + - name: Run repository runtime smoke test + shell: bash + run: | + set -euo pipefail + if [ -f tests/test_runtime.py ]; then + python tests/test_runtime.py + else + echo "No runtime smoke test file found" + exit 1 + fi + + - name: Install integration test dependencies + run: pip install pytest pymongo + + - name: Run integration tests + env: + MONGODB_URI: mongodb://admin:mongodb@localhost:27017/ + run: pytest tests/test_integration.py -v diff --git a/backend/requirements.ini b/backend/requirements.ini index 9a1f996..1915940 100644 --- a/backend/requirements.ini +++ b/backend/requirements.ini @@ -1,5 +1,5 @@ fastapi[all] -motor[srv] +pymongo[srv] boto3 #aiobotocore pillow \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index c57c49a..c0b405a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -56,8 +56,6 @@ jmespath==1.0.1 # botocore markupsafe==2.1.5 # via jinja2 -motor==3.4.0 - # via -r requirements.ini orjson==3.10.2 # via fastapi pillow==10.3.0 @@ -73,8 +71,8 @@ pydantic-extra-types==2.7.0 # via fastapi pydantic-settings==2.2.1 # via fastapi -pymongo==4.7.1 - # via motor +pymongo==4.13.1 + # via -r requirements.ini python-dateutil==2.9.0.post0 # via botocore python-dotenv==1.0.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..798c583 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,172 @@ +"""Integration tests for celeb-matcher-farm. + +Tests real MongoDB CRUD operations for the attendee/celeb_images collection +used by the FastAPI + AWS Bedrock celebrity matching backend. + +Requires a running MongoDB instance. Set MONGODB_URI (default: +mongodb://admin:mongodb@localhost:27017/) or the tests will be skipped. +""" + +import os +import base64 +import pytest +from pymongo import MongoClient +from bson import ObjectId + +MONGODB_URI = os.environ.get("MONGODB_URI", "mongodb://admin:mongodb@localhost:27017/") +TEST_DB = "celeb_matcher_integration_test" + + +@pytest.fixture(scope="module") +def db(): + client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=2000) + try: + client.admin.command("ping") + except Exception: + client.close() + pytest.skip(f"MongoDB not reachable at {MONGODB_URI}") + database = client[TEST_DB] + yield database + client.drop_database(TEST_DB) + client.close() + + +def test_mongodb_ping(): + client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=2000) + try: + result = client.admin.command("ping") + assert result.get("ok") == 1.0 + except Exception: + pytest.skip(f"MongoDB not reachable at {MONGODB_URI}") + finally: + client.close() + + +def test_attendee_record_crud(db): + """celeb_images collection: insert, find, update, delete an attendee.""" + celeb_images = db["celeb_images"] + + # Create a fake embedding (1024 floats simulating Bedrock output) + embedding = [0.01 * i for i in range(1024)] + + doc_id = ObjectId() + record = { + "_id": doc_id, + "name": "Test Attendee", + "email": "attendee@example.com", + "embedding": embedding, + "image_b64": base64.b64encode(b"fake-image-bytes").decode(), + } + + # Create + result = celeb_images.insert_one(record) + assert result.inserted_id == doc_id + + # Read + found = celeb_images.find_one({"_id": doc_id}) + assert found["name"] == "Test Attendee" + assert len(found["embedding"]) == 1024 + + # Update + celeb_images.update_one({"_id": doc_id}, {"$set": {"name": "Updated Attendee"}}) + updated = celeb_images.find_one({"_id": doc_id}) + assert updated["name"] == "Updated Attendee" + + # Delete + delete_result = celeb_images.delete_one({"_id": doc_id}) + assert delete_result.deleted_count == 1 + assert celeb_images.find_one({"_id": doc_id}) is None + + +def test_query_attendees_by_email(db): + """celeb_images collection: find by email field.""" + celeb_images = db["celeb_images"] + + ids = [ObjectId(), ObjectId()] + docs = [ + { + "_id": ids[0], + "name": "Alice", + "email": "alice@conference.example", + "embedding": [0.1] * 1024, + }, + { + "_id": ids[1], + "name": "Bob", + "email": "bob@conference.example", + "embedding": [0.2] * 1024, + }, + ] + celeb_images.insert_many(docs) + + found = celeb_images.find_one({"email": "alice@conference.example", "_id": {"$in": ids}}) + assert found is not None + assert found["name"] == "Alice" + + # Cleanup + celeb_images.delete_many({"_id": {"$in": ids}}) + + +def test_vector_search_index_structure(db): + """celeb_images: verify documents have the embedding field needed for vector search.""" + celeb_images = db["celeb_images"] + + doc_id = ObjectId() + celeb_images.insert_one({ + "_id": doc_id, + "name": "Carol", + "embedding": [float(i) / 1024 for i in range(1024)], + }) + + found = celeb_images.find_one({"_id": doc_id}) + assert "embedding" in found + assert isinstance(found["embedding"], list) + assert len(found["embedding"]) == 1024 + # All values should be valid floats + assert all(isinstance(v, float) for v in found["embedding"]) + + celeb_images.delete_one({"_id": doc_id}) + + +def test_standardize_image_function(): + """standardize_image() produces a valid base64-encoded JPEG string.""" + try: + import sys + from pathlib import Path + import importlib.util + import types + + backend_src = Path(__file__).resolve().parents[1] / "backend" / "src" + + # Stub boto3 and AWS dependencies + for mod_name in ("boto3", "uvicorn"): + if mod_name not in sys.modules: + stub = types.ModuleType(mod_name) + sys.modules[mod_name] = stub + + os.environ.setdefault("MONGODB_URI", MONGODB_URI) + os.environ.setdefault("AWS_ACCESS_KEY", "test") + os.environ.setdefault("AWS_SECRET_KEY", "test") + + spec = importlib.util.spec_from_file_location( + "celeb_server_int", backend_src / "server.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + # Create a minimal 2x2 JPEG image + from PIL import Image + import io + img = Image.new("RGB", (2, 2), color=(255, 0, 0)) + buf = io.BytesIO() + img.save(buf, format="JPEG") + buf.seek(0) + input_b64 = base64.b64encode(buf.getvalue()).decode() + + result_b64 = mod.standardize_image(input_b64) + decoded = base64.b64decode(result_b64) + out_img = Image.open(io.BytesIO(decoded)) + assert out_img.size == (512, 512) + assert out_img.mode == "RGB" + except Exception as exc: + pytest.skip(f"standardize_image test skipped: {exc}") diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..f9fa058 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,46 @@ +import base64 +import importlib.util +import io +import os +import sys +import types +import unittest +from pathlib import Path + +from PIL import Image + + +class RuntimeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("MONGODB_URI", "mongodb://example/test") + os.environ.setdefault("AWS_ACCESS_KEY", "x") + os.environ.setdefault("AWS_SECRET_KEY", "y") + + if "boto3" not in sys.modules: + boto3 = types.ModuleType("boto3") + boto3.client = lambda *args, **kwargs: object() + sys.modules["boto3"] = boto3 + + target = Path(__file__).resolve().parents[1] / "backend" / "src" / "server.py" + spec = importlib.util.spec_from_file_location("celeb_server", target) + cls.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + + def test_standardize_image_and_payload(self): + img = Image.new("RGB", (2, 2), color=(255, 0, 0)) + buff = io.BytesIO() + img.save(buff, format="JPEG") + b64 = base64.b64encode(buff.getvalue()).decode("utf-8") + + out = self.mod.standardize_image(b64) + self.assertIsInstance(out, str) + decoded = base64.b64decode(out) + self.assertGreater(len(decoded), 0) + + payload = self.mod.SearchPayload(img="data:image/jpeg;base64," + b64, compareWithOtherAttendees=False) + self.assertFalse(payload.compareWithOtherAttendees) + + +if __name__ == "__main__": + unittest.main()