mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
475 lines
17 KiB
Python
475 lines
17 KiB
Python
"""Integration tests for the Obsidian plugin HTTP wire contract.
|
|
|
|
Three concerns:
|
|
|
|
1. The /connect upsert really collapses concurrent first-time connects to
|
|
exactly one row. This locks the partial unique index from migration 129
|
|
to its purpose.
|
|
2. The fingerprint dedup path: a second device connecting with a fresh
|
|
``vault_id`` but the same ``vault_fingerprint`` adopts the existing
|
|
connector instead of creating a duplicate.
|
|
3. The end-to-end response shapes returned by /connect /sync /rename
|
|
/notes /manifest /stats match the schemas the plugin's TypeScript
|
|
decoders expect. Each renamed field is a contract change, and a smoke
|
|
pass like this is the cheapest way to catch a future drift before it
|
|
ships.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import func, select, text
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db import (
|
|
SearchSourceConnector,
|
|
SearchSourceConnectorType,
|
|
SearchSpace,
|
|
User,
|
|
)
|
|
from app.routes.obsidian_plugin_routes import (
|
|
obsidian_connect,
|
|
obsidian_delete_notes,
|
|
obsidian_manifest,
|
|
obsidian_rename,
|
|
obsidian_stats,
|
|
obsidian_sync,
|
|
)
|
|
from app.schemas.obsidian_plugin import (
|
|
ConnectRequest,
|
|
DeleteAck,
|
|
DeleteBatchRequest,
|
|
ManifestResponse,
|
|
NotePayload,
|
|
RenameAck,
|
|
RenameBatchRequest,
|
|
RenameItem,
|
|
StatsResponse,
|
|
SyncAck,
|
|
SyncBatchRequest,
|
|
)
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_note_payload(vault_id: str, path: str, content_hash: str) -> NotePayload:
|
|
"""Minimal NotePayload that the schema accepts; the indexer is mocked
|
|
out so the values don't have to round-trip through the real pipeline."""
|
|
now = datetime.now(UTC)
|
|
return NotePayload(
|
|
vault_id=vault_id,
|
|
path=path,
|
|
name=path.rsplit("/", 1)[-1].rsplit(".", 1)[0],
|
|
extension="md",
|
|
content="# Test\n\nbody",
|
|
content_hash=content_hash,
|
|
mtime=now,
|
|
ctime=now,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def race_user_and_space(async_engine):
|
|
"""User + SearchSpace committed via the live engine so the two
|
|
concurrent /connect sessions in the race test can both see them.
|
|
|
|
We can't use the savepoint-trapped ``db_session`` fixture here
|
|
because the concurrent sessions need to see committed rows.
|
|
"""
|
|
user_id = uuid.uuid4()
|
|
async with AsyncSession(async_engine) as setup:
|
|
user = User(
|
|
id=user_id,
|
|
email=f"obsidian-race-{uuid.uuid4()}@surfsense.test",
|
|
hashed_password="x",
|
|
is_active=True,
|
|
is_superuser=False,
|
|
is_verified=True,
|
|
)
|
|
space = SearchSpace(name="Race Space", user_id=user_id)
|
|
setup.add_all([user, space])
|
|
await setup.commit()
|
|
await setup.refresh(space)
|
|
space_id = space.id
|
|
|
|
yield user_id, space_id
|
|
|
|
async with AsyncSession(async_engine) as cleanup:
|
|
# Order matters: connectors -> documents -> space -> user. The
|
|
# connectors test creates documents, so we wipe them too. The
|
|
# CASCADE on user_id catches anything we missed.
|
|
await cleanup.execute(
|
|
text(
|
|
'DELETE FROM search_source_connectors WHERE user_id = :uid'
|
|
),
|
|
{"uid": user_id},
|
|
)
|
|
await cleanup.execute(
|
|
text("DELETE FROM searchspaces WHERE id = :id"),
|
|
{"id": space_id},
|
|
)
|
|
await cleanup.execute(
|
|
text('DELETE FROM "user" WHERE id = :uid'),
|
|
{"uid": user_id},
|
|
)
|
|
await cleanup.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /connect race + index enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConnectRace:
|
|
async def test_concurrent_first_connects_collapse_to_one_row(
|
|
self, async_engine, race_user_and_space
|
|
):
|
|
"""Two simultaneous /connect calls for the same vault should
|
|
produce exactly one row, not two. Same vault_id + same
|
|
fingerprint funnels through both partial unique indexes; the
|
|
loser falls back to the survivor row via the IntegrityError
|
|
branch in obsidian_connect."""
|
|
user_id, space_id = race_user_and_space
|
|
vault_id = str(uuid.uuid4())
|
|
fingerprint = "fp-" + uuid.uuid4().hex
|
|
|
|
async def _call(name_suffix: str) -> None:
|
|
async with AsyncSession(async_engine) as s:
|
|
fresh_user = await s.get(User, user_id)
|
|
payload = ConnectRequest(
|
|
vault_id=vault_id,
|
|
vault_name=f"My Vault {name_suffix}",
|
|
search_space_id=space_id,
|
|
vault_fingerprint=fingerprint,
|
|
)
|
|
await obsidian_connect(payload, user=fresh_user, session=s)
|
|
|
|
results = await asyncio.gather(
|
|
_call("a"), _call("b"), return_exceptions=True
|
|
)
|
|
for r in results:
|
|
assert not isinstance(r, Exception), f"Connect raised: {r!r}"
|
|
|
|
async with AsyncSession(async_engine) as verify:
|
|
count = (
|
|
await verify.execute(
|
|
select(func.count(SearchSourceConnector.id)).where(
|
|
SearchSourceConnector.user_id == user_id,
|
|
)
|
|
)
|
|
).scalar_one()
|
|
assert count == 1
|
|
|
|
async def test_partial_unique_index_blocks_raw_duplicate(
|
|
self, async_engine, race_user_and_space
|
|
):
|
|
"""Raw INSERTs that bypass the route must still be blocked by
|
|
the partial unique indexes from migration 129."""
|
|
user_id, space_id = race_user_and_space
|
|
vault_id = str(uuid.uuid4())
|
|
|
|
async with AsyncSession(async_engine) as s:
|
|
s.add(
|
|
SearchSourceConnector(
|
|
name="Obsidian \u2014 First",
|
|
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
|
is_indexable=False,
|
|
config={
|
|
"vault_id": vault_id,
|
|
"vault_name": "First",
|
|
"source": "plugin",
|
|
"vault_fingerprint": "fp-1",
|
|
},
|
|
user_id=user_id,
|
|
search_space_id=space_id,
|
|
)
|
|
)
|
|
await s.commit()
|
|
|
|
with pytest.raises(IntegrityError):
|
|
async with AsyncSession(async_engine) as s:
|
|
s.add(
|
|
SearchSourceConnector(
|
|
name="Obsidian \u2014 Second",
|
|
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
|
is_indexable=False,
|
|
config={
|
|
"vault_id": vault_id,
|
|
"vault_name": "Second",
|
|
"source": "plugin",
|
|
"vault_fingerprint": "fp-2",
|
|
},
|
|
user_id=user_id,
|
|
search_space_id=space_id,
|
|
)
|
|
)
|
|
await s.commit()
|
|
|
|
async def test_fingerprint_blocks_raw_cross_device_duplicate(
|
|
self, async_engine, race_user_and_space
|
|
):
|
|
"""Two connectors for the same user with different vault_ids but
|
|
the same fingerprint cannot coexist."""
|
|
user_id, space_id = race_user_and_space
|
|
fingerprint = "fp-" + uuid.uuid4().hex
|
|
|
|
async with AsyncSession(async_engine) as s:
|
|
s.add(
|
|
SearchSourceConnector(
|
|
name="Obsidian \u2014 Desktop",
|
|
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
|
is_indexable=False,
|
|
config={
|
|
"vault_id": str(uuid.uuid4()),
|
|
"vault_name": "Vault",
|
|
"source": "plugin",
|
|
"vault_fingerprint": fingerprint,
|
|
},
|
|
user_id=user_id,
|
|
search_space_id=space_id,
|
|
)
|
|
)
|
|
await s.commit()
|
|
|
|
with pytest.raises(IntegrityError):
|
|
async with AsyncSession(async_engine) as s:
|
|
s.add(
|
|
SearchSourceConnector(
|
|
name="Obsidian \u2014 Mobile",
|
|
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
|
is_indexable=False,
|
|
config={
|
|
"vault_id": str(uuid.uuid4()),
|
|
"vault_name": "Vault",
|
|
"source": "plugin",
|
|
"vault_fingerprint": fingerprint,
|
|
},
|
|
user_id=user_id,
|
|
search_space_id=space_id,
|
|
)
|
|
)
|
|
await s.commit()
|
|
|
|
async def test_second_device_adopts_existing_connector_via_fingerprint(
|
|
self, async_engine, race_user_and_space
|
|
):
|
|
"""Device A connects with vault_id=A. Device B then connects with
|
|
a fresh vault_id=B but the same fingerprint. The route must
|
|
return A's identity (not create a B row), proving cross-device
|
|
dedup happens transparently to the plugin."""
|
|
user_id, space_id = race_user_and_space
|
|
vault_id_a = str(uuid.uuid4())
|
|
vault_id_b = str(uuid.uuid4())
|
|
fingerprint = "fp-" + uuid.uuid4().hex
|
|
|
|
async with AsyncSession(async_engine) as s:
|
|
fresh_user = await s.get(User, user_id)
|
|
resp_a = await obsidian_connect(
|
|
ConnectRequest(
|
|
vault_id=vault_id_a,
|
|
vault_name="Shared Vault",
|
|
search_space_id=space_id,
|
|
vault_fingerprint=fingerprint,
|
|
),
|
|
user=fresh_user,
|
|
session=s,
|
|
)
|
|
|
|
async with AsyncSession(async_engine) as s:
|
|
fresh_user = await s.get(User, user_id)
|
|
resp_b = await obsidian_connect(
|
|
ConnectRequest(
|
|
vault_id=vault_id_b,
|
|
vault_name="Shared Vault",
|
|
search_space_id=space_id,
|
|
vault_fingerprint=fingerprint,
|
|
),
|
|
user=fresh_user,
|
|
session=s,
|
|
)
|
|
|
|
assert resp_b.vault_id == vault_id_a
|
|
assert resp_b.connector_id == resp_a.connector_id
|
|
|
|
async with AsyncSession(async_engine) as verify:
|
|
count = (
|
|
await verify.execute(
|
|
select(func.count(SearchSourceConnector.id)).where(
|
|
SearchSourceConnector.user_id == user_id,
|
|
)
|
|
)
|
|
).scalar_one()
|
|
assert count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Combined wire-shape smoke test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWireContractSmoke:
|
|
"""Walks /connect -> /sync -> /rename -> /notes -> /manifest -> /stats
|
|
sequentially and asserts each response matches the new schema. With
|
|
`response_model=` on every route, FastAPI is already validating the
|
|
shape on real traffic; this test mainly guards against accidental
|
|
field renames the way the TypeScript decoder would catch them."""
|
|
|
|
async def test_full_flow_returns_typed_payloads(
|
|
self, db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
|
|
):
|
|
vault_id = str(uuid.uuid4())
|
|
|
|
# 1. /connect
|
|
connect_resp = await obsidian_connect(
|
|
ConnectRequest(
|
|
vault_id=vault_id,
|
|
vault_name="Smoke Vault",
|
|
search_space_id=db_search_space.id,
|
|
vault_fingerprint="fp-" + uuid.uuid4().hex,
|
|
),
|
|
user=db_user,
|
|
session=db_session,
|
|
)
|
|
assert connect_resp.connector_id > 0
|
|
assert connect_resp.vault_id == vault_id
|
|
assert "sync" in connect_resp.capabilities
|
|
|
|
# 2. /sync — stub the indexer so the call doesn't drag the LLM /
|
|
# embedding pipeline in. We're testing the wire contract, not the
|
|
# indexer itself.
|
|
fake_doc = type("FakeDoc", (), {"id": 12345})()
|
|
with patch(
|
|
"app.routes.obsidian_plugin_routes.upsert_note",
|
|
new=AsyncMock(return_value=fake_doc),
|
|
):
|
|
sync_resp = await obsidian_sync(
|
|
SyncBatchRequest(
|
|
vault_id=vault_id,
|
|
notes=[
|
|
_make_note_payload(vault_id, "ok.md", "hash-ok"),
|
|
_make_note_payload(vault_id, "fail.md", "hash-fail"),
|
|
],
|
|
),
|
|
user=db_user,
|
|
session=db_session,
|
|
)
|
|
|
|
assert isinstance(sync_resp, SyncAck)
|
|
assert sync_resp.vault_id == vault_id
|
|
assert sync_resp.indexed == 2
|
|
assert sync_resp.failed == 0
|
|
assert len(sync_resp.items) == 2
|
|
assert all(it.status == "ok" for it in sync_resp.items)
|
|
# The TypeScript decoder filters on items[].status === "error" and
|
|
# extracts .path, so confirm both fields are present and named.
|
|
assert {it.path for it in sync_resp.items} == {"ok.md", "fail.md"}
|
|
|
|
# 2b. Re-run /sync but force the indexer to raise on one note so
|
|
# the per-item failure decoder gets exercised end-to-end.
|
|
async def _selective_upsert(session, *, connector, payload, user_id):
|
|
if payload.path == "fail.md":
|
|
raise RuntimeError("simulated indexing failure")
|
|
return fake_doc
|
|
|
|
with patch(
|
|
"app.routes.obsidian_plugin_routes.upsert_note",
|
|
new=AsyncMock(side_effect=_selective_upsert),
|
|
):
|
|
sync_resp = await obsidian_sync(
|
|
SyncBatchRequest(
|
|
vault_id=vault_id,
|
|
notes=[
|
|
_make_note_payload(vault_id, "ok.md", "h1"),
|
|
_make_note_payload(vault_id, "fail.md", "h2"),
|
|
],
|
|
),
|
|
user=db_user,
|
|
session=db_session,
|
|
)
|
|
assert sync_resp.indexed == 1
|
|
assert sync_resp.failed == 1
|
|
statuses = {it.path: it.status for it in sync_resp.items}
|
|
assert statuses == {"ok.md": "ok", "fail.md": "error"}
|
|
|
|
# 3. /rename — patch rename_note so we don't need a real Document.
|
|
async def _rename(*args, **kwargs) -> object:
|
|
if kwargs.get("old_path") == "missing.md":
|
|
return None
|
|
return fake_doc
|
|
|
|
with patch(
|
|
"app.routes.obsidian_plugin_routes.rename_note",
|
|
new=AsyncMock(side_effect=_rename),
|
|
):
|
|
rename_resp = await obsidian_rename(
|
|
RenameBatchRequest(
|
|
vault_id=vault_id,
|
|
renames=[
|
|
RenameItem(old_path="a.md", new_path="b.md"),
|
|
RenameItem(old_path="missing.md", new_path="x.md"),
|
|
],
|
|
),
|
|
user=db_user,
|
|
session=db_session,
|
|
)
|
|
assert isinstance(rename_resp, RenameAck)
|
|
assert rename_resp.renamed == 1
|
|
assert rename_resp.missing == 1
|
|
assert {it.status for it in rename_resp.items} == {"ok", "missing"}
|
|
# snake_case fields are deliberate — the plugin decoder maps them
|
|
# to camelCase explicitly.
|
|
assert all(
|
|
it.old_path and it.new_path for it in rename_resp.items
|
|
)
|
|
|
|
# 4. /notes DELETE
|
|
async def _delete(*args, **kwargs) -> bool:
|
|
return kwargs.get("path") != "ghost.md"
|
|
|
|
with patch(
|
|
"app.routes.obsidian_plugin_routes.delete_note",
|
|
new=AsyncMock(side_effect=_delete),
|
|
):
|
|
delete_resp = await obsidian_delete_notes(
|
|
DeleteBatchRequest(vault_id=vault_id, paths=["b.md", "ghost.md"]),
|
|
user=db_user,
|
|
session=db_session,
|
|
)
|
|
assert isinstance(delete_resp, DeleteAck)
|
|
assert delete_resp.deleted == 1
|
|
assert delete_resp.missing == 1
|
|
assert {it.path: it.status for it in delete_resp.items} == {
|
|
"b.md": "ok",
|
|
"ghost.md": "missing",
|
|
}
|
|
|
|
# 5. /manifest — empty (no real Documents were created because
|
|
# upsert_note was mocked) but the response shape is what we care
|
|
# about.
|
|
manifest_resp = await obsidian_manifest(
|
|
vault_id=vault_id, user=db_user, session=db_session
|
|
)
|
|
assert isinstance(manifest_resp, ManifestResponse)
|
|
assert manifest_resp.vault_id == vault_id
|
|
assert manifest_resp.items == {}
|
|
|
|
# 6. /stats — same; row count is 0 because upsert_note was mocked.
|
|
stats_resp = await obsidian_stats(
|
|
vault_id=vault_id, user=db_user, session=db_session
|
|
)
|
|
assert isinstance(stats_resp, StatsResponse)
|
|
assert stats_resp.vault_id == vault_id
|
|
assert stats_resp.files_synced == 0
|
|
assert stats_resp.last_sync_at is None
|