| """API surface (docs/05). The worker is NOT started here (no lifespan), so cards |
| stay QUEUED — we assert the HTTP contract, dedup, and CRUD, not pipeline output.""" |
|
|
| import httpx |
| import pytest |
| from httpx import ASGITransport |
|
|
| from app.main import app |
|
|
|
|
| @pytest.fixture |
| async def client(database): |
| transport = ASGITransport(app=app) |
| async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: |
| yield c |
|
|
|
|
| async def test_health(client): |
| r = await client.get("/health") |
| assert r.status_code == 200 |
| assert r.json()["schema_version"] == "1.6" |
|
|
|
|
| async def test_create_returns_id_and_queued(client): |
| r = await client.post("/cards", json={"url": "https://instagram.com/reel/aaa"}) |
| assert r.status_code == 200 |
| body = r.json() |
| assert body["card_id"] |
| assert body["state"] == "queued" |
| assert body["cached"] is False |
|
|
|
|
| async def test_dedup_returns_same_card(client): |
| url = "https://instagram.com/reel/dup" |
| r1 = await client.post("/cards", json={"url": url}) |
| r2 = await client.post("/cards", json={"url": url}) |
| assert r1.json()["card_id"] == r2.json()["card_id"] |
| assert r2.json()["cached"] is True |
|
|
|
|
| async def test_get_list_patch_delete(client): |
| r = await client.post("/cards", json={"url": "https://instagram.com/reel/crud"}) |
| card_id = r.json()["card_id"] |
|
|
| g = await client.get(f"/cards/{card_id}") |
| assert g.status_code == 200 |
| assert g.json()["card_id"] == card_id |
|
|
| lst = await client.get("/cards") |
| assert any(c["card_id"] == card_id for c in lst.json()) |
|
|
| blocks = [{"type": "checklist", "id": "b1", |
| "items": [{"text": "milk", "checked": True}]}] |
| p = await client.patch(f"/cards/{card_id}", json={"blocks": blocks}) |
| assert p.status_code == 200 |
| assert p.json()["blocks"][0]["items"][0]["checked"] is True |
|
|
| |
| actions = { |
| "followed": True, |
| "items": [{"id": "a1", "text": "Drink water", "done": True}], |
| } |
| pa = await client.patch(f"/cards/{card_id}", json={"action_items": actions}) |
| assert pa.status_code == 200 |
| body = pa.json()["action_items"] |
| assert body["followed"] is True |
| assert body["items"][0]["done"] is True |
| |
| fresh = await client.post( |
| "/cards", json={"url": "https://instagram.com/reel/actions-default"} |
| ) |
| fid = fresh.json()["card_id"] |
| fa = (await client.get(f"/cards/{fid}")).json()["action_items"] |
| assert fa == {"followed": False, "items": []} |
|
|
| d = await client.delete(f"/cards/{card_id}") |
| assert d.status_code == 200 |
| assert (await client.get(f"/cards/{card_id}")).status_code == 404 |
|
|
|
|
| async def test_get_missing_card_404(client): |
| r = await client.get("/cards/does-not-exist") |
| assert r.status_code == 404 |
|
|
|
|
| async def test_catalog_empty_initially(client): |
| r = await client.get("/catalog") |
| assert r.status_code == 200 |
| assert r.json() == [] |
|
|
|
|
| async def test_catalog_upsert_dedupes_and_lists(client, database): |
| async with database.session() as s: |
| await database.upsert_artifact( |
| s, card_id="c1", type_="book", title="Atomic Habits", |
| creator="James Clear", year=2018, thumbnail="http://x/a.jpg", saved=True, |
| ) |
| await database.upsert_artifact( |
| s, card_id="c2", type_="book", title="atomic habits", |
| creator=None, year=None, thumbnail=None, saved=True, |
| ) |
| await database.upsert_artifact( |
| s, card_id="c3", type_="movie", title="Inception", |
| creator="Nolan", year=2010, thumbnail=None, saved=True, |
| ) |
|
|
| r = await client.get("/catalog") |
| entries = r.json() |
| assert len(entries) == 2 |
| book = next(e for e in entries if e["type"] == "book") |
| assert sorted(book["source_card_ids"]) == ["c1", "c2"] |
| assert book["thumbnail"] == "http://x/a.jpg" |
|
|
| |
| r2 = await client.get("/catalog", params={"type": "movie"}) |
| assert [e["title"] for e in r2.json()] == ["Inception"] |
|
|
|
|
| async def _make_ready_card(database) -> str: |
| async with database.session() as s: |
| row = database.CardRow( |
| source_url="https://instagram.com/reel/chat", |
| state="ready", |
| content_type="recipe", |
| one_liner="Easy pancakes", |
| tldr="Mix, pour, flip.", |
| blocks=[ |
| {"type": "checklist", "id": "b1", |
| "items": [{"text": "flour", "checked": False}]}, |
| ], |
| ) |
| s.add(row) |
| await s.commit() |
| return row.id |
|
|
|
|
| async def test_chat_rejects_non_user_last_message(client): |
| r = await client.post( |
| "/cards/whatever/chat", |
| json={"messages": [{"role": "assistant", "content": "hi"}]}, |
| ) |
| assert r.status_code == 422 |
|
|
|
|
| async def test_chat_409_when_card_not_ready(client): |
| created = await client.post("/cards", json={"url": "https://instagram.com/reel/q"}) |
| card_id = created.json()["card_id"] |
| r = await client.post( |
| f"/cards/{card_id}/chat", |
| json={"messages": [{"role": "user", "content": "what is this?"}]}, |
| ) |
| assert r.status_code == 409 |
|
|
|
|
| async def test_chat_503_without_llm_backend(client, database): |
| card_id = await _make_ready_card(database) |
| r = await client.post( |
| f"/cards/{card_id}/chat", |
| json={"messages": [{"role": "user", "content": "how much flour?"}]}, |
| ) |
| assert r.status_code == 503 |
|
|
|
|
| async def test_chat_returns_reply_when_backend_answers(client, database, monkeypatch): |
| card_id = await _make_ready_card(database) |
|
|
| captured = {} |
|
|
| def fake_answer(card, history): |
| captured["title"] = card.base.one_liner |
| captured["history"] = history |
| return "You need flour." |
|
|
| monkeypatch.setattr("app.api.cards.llm_chat.answer", fake_answer) |
|
|
| r = await client.post( |
| f"/cards/{card_id}/chat", |
| json={"messages": [{"role": "user", "content": "ingredients?"}]}, |
| ) |
| assert r.status_code == 200 |
| assert r.json()["reply"] == "You need flour." |
| assert captured["title"] == "Easy pancakes" |
| assert captured["history"][-1]["content"] == "ingredients?" |
|
|
|
|
| |
| |
| |
|
|
| async def test_chat_persists_and_restores_per_owner(client, database, monkeypatch): |
| """A chat turn is saved for the owner that made it, restorable via GET, and |
| invisible to a different owner.""" |
| card_id = await _make_ready_card(database) |
| monkeypatch.setattr( |
| "app.api.cards.llm_chat.answer", lambda card, history: "Use 1 cup." |
| ) |
|
|
| post = await client.post( |
| f"/cards/{card_id}/chat", |
| headers={"x-owner-id": "alice"}, |
| json={"messages": [{"role": "user", "content": "how much flour?"}]}, |
| ) |
| assert post.status_code == 200 |
|
|
| |
| restored = await client.get( |
| f"/cards/{card_id}/chat", headers={"x-owner-id": "alice"} |
| ) |
| msgs = restored.json()["messages"] |
| assert [m["content"] for m in msgs] == ["how much flour?", "Use 1 cup."] |
|
|
| |
| other = await client.get( |
| f"/cards/{card_id}/chat", headers={"x-owner-id": "bob"} |
| ) |
| assert other.json()["messages"] == [] |
|
|
|
|
| async def test_rabbithole_persists_trail_and_restores(client, database, monkeypatch): |
| """Successive dives build a persisted trail keyed by the root topic, and a |
| branch taken after jumping back replaces the abandoned deeper steps.""" |
| card_id = await _make_ready_card(database) |
|
|
| async def fake_explore(card, topic, trail): |
| return {"explanation": f"About {topic}.", "threads": [f"{topic} deeper"]} |
|
|
| monkeypatch.setattr( |
| "app.api.cards.llm_rabbithole.explore_async", fake_explore |
| ) |
|
|
| |
| r1 = await client.post( |
| f"/cards/{card_id}/rabbithole", |
| headers={"x-owner-id": "alice"}, |
| json={"topic": "roots", "trail": [], "root": "roots"}, |
| ) |
| assert r1.status_code == 200 |
|
|
| |
| await client.post( |
| f"/cards/{card_id}/rabbithole", |
| headers={"x-owner-id": "alice"}, |
| json={"topic": "branch-a", "trail": ["roots"], "root": "roots"}, |
| ) |
|
|
| restored = await client.get( |
| f"/cards/{card_id}/rabbithole", |
| headers={"x-owner-id": "alice"}, |
| params={"root": "roots"}, |
| ) |
| steps = restored.json()["steps"] |
| assert [s["topic"] for s in steps] == ["roots", "branch-a"] |
| assert steps[0]["explanation"] == "About roots." |
|
|
| |
| await client.post( |
| f"/cards/{card_id}/rabbithole", |
| headers={"x-owner-id": "alice"}, |
| json={"topic": "branch-b", "trail": ["roots"], "root": "roots"}, |
| ) |
| restored2 = await client.get( |
| f"/cards/{card_id}/rabbithole", |
| headers={"x-owner-id": "alice"}, |
| params={"root": "roots"}, |
| ) |
| assert [s["topic"] for s in restored2.json()["steps"]] == ["roots", "branch-b"] |
|
|
| |
| other = await client.get( |
| f"/cards/{card_id}/rabbithole", |
| headers={"x-owner-id": "bob"}, |
| params={"root": "roots"}, |
| ) |
| assert other.json()["steps"] == [] |
|
|
|
|
| async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch): |
| async def fake_answer(history): |
| return ("You saved 2 recipes.", []) |
|
|
| monkeypatch.setattr( |
| "app.api.library_chat.llm_library_chat.answer", fake_answer |
| ) |
|
|
| await client.post( |
| "/library/chat", |
| headers={"x-owner-id": "alice"}, |
| json={"messages": [{"role": "user", "content": "what did I save?"}]}, |
| ) |
|
|
| restored = await client.get("/library/chat", headers={"x-owner-id": "alice"}) |
| assert [m["content"] for m in restored.json()["messages"]] == [ |
| "what did I save?", |
| "You saved 2 recipes.", |
| ] |
|
|
| other = await client.get("/library/chat", headers={"x-owner-id": "bob"}) |
| assert other.json()["messages"] == [] |
|
|
|
|
| async def test_catalog_detail_and_delete(client, database): |
| async with database.session() as s: |
| row = await database.upsert_artifact( |
| s, card_id="c1", type_="podcast", title="Lex Fridman", |
| creator=None, year=None, thumbnail=None, saved=True, |
| ) |
| artifact_id = row.id |
|
|
| g = await client.get(f"/catalog/{artifact_id}") |
| assert g.status_code == 200 |
| assert g.json()["entry"]["title"] == "Lex Fridman" |
| assert g.json()["source_card_ids"] == ["c1"] |
|
|
| d = await client.delete(f"/catalog/{artifact_id}") |
| assert d.status_code == 200 |
| assert (await client.get(f"/catalog/{artifact_id}")).status_code == 404 |
|
|
|
|
| |
| |
| |
|
|
| async def test_catalog_filter_by_card_id(client, database): |
| async with database.session() as s: |
| await database.upsert_artifact( |
| s, card_id="card-a", type_="book", title="Deep Work", |
| creator=None, year=None, thumbnail=None, |
| ) |
| await database.upsert_artifact( |
| s, card_id="card-b", type_="movie", title="Interstellar", |
| creator=None, year=None, thumbnail=None, |
| ) |
|
|
| r = await client.get("/catalog", params={"card_id": "card-a"}) |
| titles = [e["title"] for e in r.json()] |
| assert "Deep Work" in titles |
| assert "Interstellar" not in titles |
|
|
| r2 = await client.get("/catalog", params={"card_id": "card-b"}) |
| assert [e["title"] for e in r2.json()] == ["Interstellar"] |
|
|
| |
| r3 = await client.get("/catalog", params={"card_id": "unknown"}) |
| assert r3.json() == [] |
|
|
|
|
| |
| |
| |
|
|
| async def test_library_chat_422_non_user_last(client): |
| r = await client.post( |
| "/library/chat", |
| json={"messages": [{"role": "assistant", "content": "hello"}]}, |
| ) |
| assert r.status_code == 422 |
|
|
|
|
| async def test_library_chat_422_empty_messages(client): |
| r = await client.post("/library/chat", json={"messages": []}) |
| assert r.status_code == 422 |
|
|
|
|
| async def test_library_chat_503_without_llm_backend(client): |
| |
| r = await client.post( |
| "/library/chat", |
| json={"messages": [{"role": "user", "content": "what have I saved?"}]}, |
| ) |
| assert r.status_code == 503 |
|
|
|
|
| async def test_library_chat_returns_reply(client, monkeypatch): |
| async def fake_answer(history): |
| return ("You saved 3 recipes.", []) |
|
|
| monkeypatch.setattr("app.api.library_chat.llm_library_chat.answer", fake_answer) |
|
|
| r = await client.post( |
| "/library/chat", |
| json={"messages": [{"role": "user", "content": "what recipes?"}]}, |
| ) |
| assert r.status_code == 200 |
| body = r.json() |
| assert body["reply"] == "You saved 3 recipes." |
| assert body["sources"] == [] |
|
|
|
|
| |
| |
| |
|
|
| async def test_search_mode_auto_falls_back_gracefully(client): |
| |
| await client.post("/cards", json={"url": "https://instagram.com/reel/srch1"}) |
| |
| r = await client.get("/search", params={"q": "something", "mode": "auto"}) |
| assert r.status_code == 200 |
| assert isinstance(r.json(), list) |
|
|
|
|
| async def test_search_mode_semantic_falls_back_gracefully(client): |
| r = await client.get("/search", params={"q": "workout tips", "mode": "semantic"}) |
| assert r.status_code == 200 |
| assert isinstance(r.json(), list) |
|
|
|
|
| async def test_search_mode_text_works(client): |
| r = await client.get("/search", params={"q": "anything", "mode": "text"}) |
| assert r.status_code == 200 |
| assert isinstance(r.json(), list) |
|
|
|
|
| async def test_delete_card_cascades_cleanup(client, database): |
| from sqlalchemy import select |
| async with database.session() as s: |
| c1 = database.CardRow(id="del-1", source_url="http://x/1", state="ready") |
| c2 = database.CardRow(id="del-2", source_url="http://x/2", state="ready") |
| s.add_all([c1, c2]) |
| await s.commit() |
|
|
| col = await database.create_custom_collection(s, name="My Folder", owner_id=None) |
| await database.move_card_to_collection(s, "del-1", col.id) |
|
|
| await database.upsert_concept(s, name="Idea Solo", card_id="del-1") |
| await database.upsert_concept(s, name="Idea Shared", card_id="del-1") |
| await database.upsert_concept(s, name="Idea Shared", card_id="del-2") |
|
|
| await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Solo", creator=None, year=None, thumbnail=None, saved=True) |
| await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None, saved=True) |
| await database.upsert_artifact(s, card_id="del-2", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None, saved=True) |
|
|
| res = await client.delete("/cards/del-1") |
| assert res.status_code == 200 |
|
|
| async with database.session() as s: |
| col_row = await s.get(database.CollectionRow, col.id) |
| assert col_row is None |
|
|
| solo_c = (await s.execute(select(database.ConceptRow).where(database.ConceptRow.name == "Idea Solo"))).scalar_one_or_none() |
| assert solo_c is None |
|
|
| shared_c = (await s.execute(select(database.ConceptRow).where(database.ConceptRow.name == "Idea Shared"))).scalar_one() |
| assert shared_c.source_card_ids == ["del-2"] |
|
|
| solo_a = (await s.execute(select(database.ArtifactRow).where(database.ArtifactRow.title == "Book Solo"))).scalar_one_or_none() |
| assert solo_a is None |
|
|
| shared_a = (await s.execute(select(database.ArtifactRow).where(database.ArtifactRow.title == "Book Shared"))).scalar_one() |
| assert shared_a.source_card_ids == ["del-2"] |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_concepts_filtering_multi_reel(client): |
| from app.store import db as database |
| async with database.session() as s: |
| c1 = database.CardRow(id="f-1", source_url="http://f/1", state="ready") |
| c2 = database.CardRow(id="f-2", source_url="http://f/2", state="ready") |
| s.add_all([c1, c2]) |
| await s.commit() |
|
|
| await database.upsert_concept(s, name="Single Reel Concept", card_id="f-1") |
| await database.upsert_concept(s, name="Multi Reel Concept", card_id="f-1") |
| await database.upsert_concept(s, name="Multi Reel Concept", card_id="f-2") |
|
|
| res = await client.get("/concepts") |
| assert res.status_code == 200 |
| data = res.json() |
| names = [item["name"] for item in data] |
| assert "Multi Reel Concept" in names |
| assert "Single Reel Concept" not in names |
|
|
| res_card = await client.get("/concepts?card_id=f-1") |
| assert res_card.status_code == 200 |
| card_names = [item["name"] for item in res_card.json()] |
| assert "Single Reel Concept" in card_names |
| assert "Multi Reel Concept" in card_names |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_similar_concept_merging(client): |
| from app.store import db as database |
| async with database.session() as s: |
| c1 = database.CardRow(id="sim-1", source_url="http://sim/1", state="ready") |
| c2 = database.CardRow(id="sim-2", source_url="http://sim/2", state="ready") |
| s.add_all([c1, c2]) |
| await s.commit() |
|
|
| row1 = await database.upsert_concept(s, name="Spaced Repetition", card_id="sim-1") |
| row2 = await database.upsert_concept(s, name="Spaced Repetition System", card_id="sim-2") |
| assert row1.id == row2.id |
| assert set(row2.source_card_ids) == {"sim-1", "sim-2"} |
|
|
|
|