File size: 8,809 Bytes
1177cc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
"""
End-to-end API test against a RUNNING vectoriser and a THROWAWAY database (never the production one).

    # terminal 1: a second instance on its own port + scratch DB
    MONGODB_DB_NAME=ifind_vectest PORT=6100 python app.py
    # terminal 2
    VECTORISER_TEST_URL=http://localhost:6100 VECTORISER_TEST_DB=ifind_vectest pytest tests/test_api_integration.py -v

Skipped unless VECTORISER_TEST_URL is set. Refuses to run when the DB name is "ifind" (production).
Seeds its own internships and students, drops the scratch DB afterwards.
"""

import os
import time

import pytest
import requests

BASE = os.environ.get("VECTORISER_TEST_URL")
DB_NAME = os.environ.get("VECTORISER_TEST_DB", "ifind_vectest")

pytestmark = pytest.mark.skipif(not BASE, reason="set VECTORISER_TEST_URL to run the API integration tests")

APPROVED = {"status": "auto_approved", "scamScore": 0.05}

INTERNSHIPS = [
    ("Python Backend Intern", "Build REST APIs with Python, FastAPI and PostgreSQL. Docker, Git, unit testing."),
    ("Machine Learning Intern", "Train NLP models with PyTorch and scikit-learn; embeddings, transformers, data pipelines."),
    ("Frontend React Intern", "Build responsive UIs with React, TypeScript, Tailwind CSS and Next.js."),
    ("Data Analyst Intern", "SQL, Excel, Tableau dashboards, statistics and business reporting."),
    ("Digital Marketing Intern", "SEO, social media campaigns, content writing and Google Analytics."),
    ("Graphic Design Intern", "Photoshop, Illustrator, Figma, branding and poster design."),
]

PARSED_RESUME = {
    "name": "Test Student",
    "skills": ["Python", "FastAPI", "PostgreSQL", "Docker", "Git", "REST APIs"],
    "education": [{"institution": "Test University", "degree": "B.Tech", "field": "Computer Science"}],
    "experience": [{"title": "Backend Developer Intern", "company": "Acme", "description": "Built REST APIs in Python."}],
    "projects": [{"name": "URL shortener", "description": "FastAPI + PostgreSQL service deployed with Docker."}],
}


@pytest.fixture(scope="module")
def db():
    assert DB_NAME != "ifind", "refusing to run against the production database"
    import pymongo
    from dotenv import load_dotenv
    from pathlib import Path
    load_dotenv(Path(__file__).resolve().parent.parent / ".env")
    client = pymongo.MongoClient(os.environ["MONGODB_URI"], serverSelectionTimeoutMS=15000)
    d = client[DB_NAME]
    for name in d.list_collection_names():
        d[name].drop()
    yield d
    client.drop_database(DB_NAME)


def post(path, body=None, **kw):
    return requests.post(f"{BASE}{path}", json=body if body is not None else {}, timeout=300, **kw)


def test_health():
    r = requests.get(f"{BASE}/health", timeout=10)
    assert r.status_code == 200 and r.json()["ready"] is True


def test_root():
    assert requests.get(f"{BASE}/", timeout=10).status_code == 200


def test_encode_internships_shape_and_mongo_doc():
    r = post("/encode-internships", {"internships": [
        {"id": "a1", "title": INTERNSHIPS[0][0], "description": INTERNSHIPS[0][1]},
        {"_id": {"$oid": "664a00000000000000000001"}, "name": INTERNSHIPS[1][0], "summary": INTERNSHIPS[1][1]},
    ]})
    assert r.status_code == 200
    body = r.json()
    assert body["encoded"] == 2
    assert [v["id"] for v in body["vectors"]] == ["a1", "664a00000000000000000001"]
    for v in body["vectors"]:
        assert len(v["tfidf"]) == 15000 and len(v["bert"]) == 768
        assert abs(sum(x * x for x in v["bert"]) - 1) < 1e-3          # L2-normalised


def test_encode_internships_is_deterministic_and_discriminates():
    def enc(t, d):
        return post("/encode-internships", {"internships": [{"id": "x", "title": t, "description": d}]}).json()["vectors"][0]["bert"]
    a, a2, b = enc(*INTERNSHIPS[0]), enc(*INTERNSHIPS[0]), enc(*INTERNSHIPS[4])
    dot = lambda u, v: sum(x * y for x, y in zip(u, v))
    assert dot(a, a2) > 0.999
    assert dot(a, b) < 0.9


def test_encode_internships_batch_limit():
    items = [{"id": str(i), "title": "t", "description": "d"} for i in range(71)]
    assert post("/encode-internships", {"internships": items}).status_code == 422


def test_encode_internships_validation():
    assert post("/encode-internships", {}).status_code == 422


def test_encode_resume_parsed_json_and_text():
    j = post("/encode-resume", {"resume": PARSED_RESUME})
    t = post("/encode-resume", {"resume": "Python developer with FastAPI, PostgreSQL and Docker experience"})
    assert j.status_code == 200 and t.status_code == 200
    for r in (j, t):
        body = r.json()
        assert len(body["tfidf"]) == 15000 and len(body["bert"]) == 768 and body["recommended"] is None


def test_encode_resume_empty_rejected():
    assert post("/encode-resume", {"resume": "   "}).status_code == 422
    assert post("/encode-resume", {}).status_code == 422


def test_pipeline_end_to_end(db):
    from bson import ObjectId
    # 1. staging: 6 approved + 1 not approved
    ids = []
    for title, desc in INTERNSHIPS:
        _id = ObjectId()
        ids.append(_id)
        db["internships.mod-unvectorised"].insert_one({
            "_id": _id, "title": title, "description": desc, "isActive": True,
            "applyLink": f"https://example.com/{_id}", "moderation": APPROVED,
        })
    held = ObjectId()
    db["internships.mod-unvectorised"].insert_one({
        "_id": held, "title": "Held", "description": "needs review", "isActive": True,
        "moderation": {"status": "pending_review", "scamScore": 0.9},
    })

    r = post("/vectorize-hnsw", {"recommend": False})
    assert r.status_code == 200, r.text
    stats = r.json()["stats"]
    assert stats["processed"] == 6 and stats["errors"] == 0

    # approved ones moved with vectors, unapproved stays
    assert db["internships.mod-unvectorised"].count_documents({}) == 1
    assert db["internships.mod-unvectorised"].find_one({"_id": held})
    live = db["internships"].find_one({"_id": ids[0]})
    assert len(live["tfidf_vector"]) == 15000 and len(live["bert_vector"]) == 768
    assert db["internships.graph"].count_documents({}) == 1

    # 2. employer post vectorised in place
    pid = ObjectId()
    db["internships.this-platform"].insert_one({
        "_id": pid, "name": "Python API Intern", "company": "Acme", "summary": "Backend developer: Python, FastAPI, PostgreSQL, Docker and REST APIs",
        "isActive": True, "status": "published", "moderation": APPROVED,
    })
    r = post("/vectorize-platform", {"ids": [str(pid)], "recommend": False})
    assert r.status_code == 200 and r.json()["stats"]["processed"] == 1
    assert len(db["internships.this-platform"].find_one({"_id": pid})["bert_vector"]) == 768

    # 3. graph search finds the Python backend internship for a Python resume
    vec = post("/encode-resume", {"resume": PARSED_RESUME}).json()
    res = post("/search-internships", {"vector": vec["bert"], "k": 3}).json()["results"]
    assert res[0]["id"] in {str(ids[0]), str(pid)}                            # nearest neighbour is a Python backend role

    # 4. encode-resume with user_id saves vectors and recommends
    uid = ObjectId()
    db["users"].insert_one({"_id": uid, "username": "vectest", "role": "student", "city": "Pune", "state": "Maharashtra"})
    r = post("/encode-resume", {"resume": PARSED_RESUME, "user_id": str(uid), "recommend": True})
    assert r.status_code == 200 and r.json()["recommended"] > 0
    user = db["users"].find_one({"_id": uid})
    assert len(user["resume"]["bert_vector"]) == 768
    recs = user["recommendedInternships"]
    scores = recs["recommendedScores"]
    assert str(recs["recommendedList"][0]) in {str(ids[0]), str(pid)}       # the Python backend roles lead
    assert scores[0]["score"] - scores[-1]["score"] > 0.2                    # and clearly beat the unrelated ones
    assert [x["score"] for x in scores] == sorted((x["score"] for x in scores), reverse=True)
    assert str(pid) in [str(x) for x in recs["recommendedList"]]             # employer posts are recommended too

    # 5. recommend-all (sync) for that student
    r = post("/recommend-all", {"user_ids": [str(uid)], "background": False})
    assert r.status_code == 200 and r.json()["status"] == "ok"

    # 6. rebuild graph from stored vectors
    r = post("/rebuild-graph")
    assert r.status_code == 200 and r.json()["indexed"] >= 6
    assert len(post("/search-internships", {"vector": vec["bert"], "k": 3}).json()["results"]) > 0


def test_vectorize_hnsw_unknown_ids_are_skipped(db):
    from bson import ObjectId
    r = post("/vectorize-hnsw", {"ids": [str(ObjectId())], "recommend": False})
    assert r.status_code == 200 and r.json()["stats"]["processed"] == 0


def test_search_validation():
    assert post("/search-internships", {"vector": [0.1], "k": 0}).status_code == 422