Skip to content

Commit ec28834

Browse files
committed
review-stack 3/4: tests-changed (13 files, +1046/-1217)
Review-and-land stack for synap5e/feat/asset-record-content-split, generated by review-stack.py. Once approved, merges DOWN into the layer below (a fast-forward); only the bottom layer squash-merges into the real base. See ~/adocs/review-stack.md. Rule: remaining modified test files (incl. conftest.py / helpers) Question: Did the edits weaken an existing check? Source tip: 18b6fd5 Merge-base: 783545f
1 parent 2946045 commit ec28834

13 files changed

Lines changed: 1046 additions & 1217 deletions

File tree

tests-unit/assets_test/conftest.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import pytest
1414
import requests
1515

16+
from .helpers import assert_hash_fields_consistent
17+
1618

1719
def pytest_addoption(parser: pytest.Parser) -> None:
1820
"""
@@ -28,6 +30,18 @@ def pytest_addoption(parser: pytest.Parser) -> None:
2830
default=os.environ.get("ASSETS_TEST_DB_URL"),
2931
help="SQLAlchemy DB URL (e.g. sqlite:///path/to/db.sqlite3)",
3032
)
33+
parser.addoption(
34+
"--enable-asset-hashing",
35+
action="store_true",
36+
help="Start the assets subprocess with hash-mode behavior enabled.",
37+
)
38+
39+
40+
def pytest_configure(config: pytest.Config) -> None:
41+
config.addinivalue_line(
42+
"markers",
43+
"hashing_on: exercises the subprocess harness with --enable-asset-hashing",
44+
)
3145

3246

3347
def _free_port() -> int:
@@ -103,8 +117,7 @@ def comfy_url_and_proc(comfy_tmp_base_dir: Path, request: pytest.FixtureRequest)
103117
if not (comfy_root / "main.py").is_file():
104118
raise FileNotFoundError(f"main.py not found under {comfy_root}")
105119

106-
proc = subprocess.Popen(
107-
args=[
120+
command = [
108121
sys.executable,
109122
"main.py",
110123
f"--base-directory={str(comfy_tmp_base_dir)}",
@@ -115,7 +128,19 @@ def comfy_url_and_proc(comfy_tmp_base_dir: Path, request: pytest.FixtureRequest)
115128
"--port",
116129
str(port),
117130
"--cpu",
118-
],
131+
]
132+
if (
133+
request.config.getoption("--enable-asset-hashing")
134+
or "hashing_on" in request.config.getoption("markexpr")
135+
or any(
136+
item.get_closest_marker("hashing_on")
137+
for item in request.session.items
138+
)
139+
):
140+
command.append("--enable-asset-hashing")
141+
142+
proc = subprocess.Popen(
143+
args=command,
119144
stdout=out_log,
120145
stderr=err_log,
121146
cwd=str(comfy_root),
@@ -190,8 +215,9 @@ def _post_multipart_asset(
190215
@pytest.fixture
191216
def make_asset_bytes() -> Callable[[str, int], bytes]:
192217
# Salt content per test so it never collides with assets left over from
193-
# earlier tests. Delete is now always a soft delete (content is preserved),
194-
# so the suite can no longer rely on hard-deleting content for isolation.
218+
# earlier tests. Delete hard-deletes the record but preserves content
219+
# (content rows and files are untouched), so the suite cannot rely on delete
220+
# removing content for isolation.
195221
# Deterministic within a test: the same (name, size) yields the same bytes.
196222
salt = uuid.uuid4().bytes
197223

@@ -237,8 +263,9 @@ def seeded_asset(request: pytest.FixtureRequest, http: requests.Session, api_bas
237263
tags = ["models", "model_type:checkpoints", "unit-tests", "alpha"]
238264
meta = {"purpose": "test", "epoch": 1, "flags": ["x", "y"], "nullable": None}
239265
# Unique content per test so the seed always creates a fresh asset (201).
240-
# Delete is now always a soft delete, so content from a prior test survives
241-
# and would otherwise dedup this upload into an existing asset (200).
266+
# Delete preserves content (only the record is hard-deleted), so content
267+
# from a prior test survives and would otherwise dedup this upload into an
268+
# existing asset (200).
242269
content = uuid.uuid4().bytes + b"A" * (4096 - 16)
243270
files = {"file": (name, content, "application/octet-stream")}
244271
form_data = {
@@ -249,7 +276,6 @@ def seeded_asset(request: pytest.FixtureRequest, http: requests.Session, api_bas
249276
r = http.post(api_base + "/api/assets", files=files, data=form_data, timeout=120)
250277
body = r.json()
251278
assert r.status_code == 201, body
252-
from helpers import assert_hash_fields_consistent
253279
assert_hash_fields_consistent(body)
254280
return body
255281

tests-unit/assets_test/helpers.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,124 @@
11
"""Helper functions for assets integration tests."""
2+
from __future__ import annotations
3+
4+
import json
25
import time
6+
import uuid
7+
from collections.abc import Iterator, Mapping
8+
from dataclasses import dataclass
9+
from datetime import datetime, timedelta
10+
from typing import NotRequired, TypeAlias, TypedDict
311

12+
import pytest
413
import requests
14+
from aiohttp import web
15+
from aiohttp.test_utils import make_mocked_request
16+
from sqlalchemy import Engine, create_engine
17+
from sqlalchemy.orm import Session
18+
19+
from app.assets.api import routes
20+
from app.assets.database.models import Asset
21+
from app.assets.database.queries.records import create_content, create_record
22+
from app.database.models import Base
23+
24+
25+
class AssetItem(TypedDict):
26+
id: str
27+
name: str
28+
preview_id: NotRequired[str]
29+
30+
31+
class AssetListBody(TypedDict):
32+
assets: list[AssetItem]
33+
total: int
34+
has_more: bool
35+
next_cursor: NotRequired[str]
36+
37+
38+
class ErrorItem(TypedDict):
39+
code: str
40+
41+
42+
class ErrorBody(TypedDict):
43+
error: ErrorItem
44+
45+
46+
@dataclass(frozen=True, slots=True)
47+
class RecordSeed:
48+
name: str
49+
tags: tuple[str, ...] = ()
50+
size_bytes: int = 0
51+
52+
53+
RouteDatabase: TypeAlias = tuple[Engine, Session]
54+
55+
56+
@pytest.fixture(autouse=True)
57+
def autoclean_unit_test_assets() -> Iterator[None]:
58+
yield
59+
60+
61+
@pytest.fixture
62+
def route_database(monkeypatch: pytest.MonkeyPatch) -> Iterator[RouteDatabase]:
63+
engine = create_engine("sqlite:///:memory:")
64+
Base.metadata.create_all(engine)
65+
monkeypatch.setattr(routes, "create_session", lambda: Session(engine))
66+
monkeypatch.setattr(routes, "_ASSETS_ENABLED", True)
67+
with Session(engine) as session:
68+
yield engine, session
69+
engine.dispose()
70+
71+
72+
def seed_record(session: Session, seed: RecordSeed) -> Asset:
73+
content = create_content(
74+
session,
75+
path=f"/output/{uuid.uuid4()}-{seed.name}",
76+
size_bytes=seed.size_bytes,
77+
)
78+
return create_record(
79+
session,
80+
content_id=content.id,
81+
name=seed.name,
82+
mime_type="image/png",
83+
tags=seed.tags,
84+
)
85+
86+
87+
@pytest.fixture
88+
def sortable_record_ids(route_database: RouteDatabase) -> tuple[str, str]:
89+
_, session = route_database
90+
older = seed_record(session, RecordSeed("z.png", ("sort-case",), 100))
91+
newer = seed_record(session, RecordSeed("a.png", ("sort-case",), 200))
92+
base_time = datetime(2026, 1, 1)
93+
older.created_at = base_time
94+
newer.created_at = base_time + timedelta(days=1)
95+
newer.updated_at = base_time
96+
older.updated_at = base_time + timedelta(days=1)
97+
older.last_access_time = base_time
98+
newer.last_access_time = base_time + timedelta(days=1)
99+
session.commit()
100+
return newer.id, older.id
101+
102+
103+
async def request_assets(query: str = "") -> web.StreamResponse:
104+
suffix = f"?{query}" if query else ""
105+
return await routes.list_assets_route(
106+
make_mocked_request("GET", f"/api/assets{suffix}")
107+
)
108+
109+
110+
def asset_list_body(response: web.StreamResponse) -> AssetListBody:
111+
assert isinstance(response, web.Response)
112+
body = response.body
113+
assert isinstance(body, bytes | bytearray)
114+
return json.loads(body)
115+
116+
117+
def error_body(response: web.StreamResponse) -> ErrorBody:
118+
assert isinstance(response, web.Response)
119+
body = response.body
120+
assert isinstance(body, bytes | bytearray)
121+
return json.loads(body)
5122

6123

7124
def trigger_sync_seed_assets(session: requests.Session, base_url: str) -> None:
@@ -28,7 +145,10 @@ def get_asset_filename(asset_hash: str, extension: str) -> str:
28145
return asset_hash.removeprefix("blake3:") + extension
29146

30147

31-
def assert_hash_fields_consistent(body: dict, expected_hash: str | None = None) -> None:
148+
def assert_hash_fields_consistent(
149+
body: Mapping[str, str | None],
150+
expected_hash: str | None = None,
151+
) -> None:
32152
"""Assert hash and asset_hash invariants on an Asset response.
33153
34154
Both must be present or both absent (so a regression that drops only one

tests-unit/assets_test/queries/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
from app.assets.database.models import Base
66

77

8+
@pytest.fixture(scope="session", autouse=True)
9+
def assert_asset_metadata_tables():
10+
assert set(Base.metadata.tables) == {
11+
"assets",
12+
"asset_contents",
13+
"asset_meta",
14+
"asset_tags",
15+
"tags",
16+
"asset_system_state",
17+
}
18+
19+
820
@pytest.fixture
921
def session():
1022
"""In-memory SQLite session for fast unit tests."""
Lines changed: 24 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,33 @@
1-
"""Keyset-pagination tiebreaker tests for list_references_page.
2-
3-
When multiple rows share the same primary sort value (e.g. four assets
4-
created in the same microsecond), the secondary `ORDER BY id` is what keeps
5-
keyset pagination from losing or repeating rows. This file exercises that
6-
branch directly against an in-memory SQLite session — engineering identical
7-
timestamps via HTTP is unreliable enough that we work at the query layer.
8-
"""
9-
import uuid
10-
from datetime import datetime
11-
12-
import pytest
131
from sqlalchemy.orm import Session
142

15-
from app.assets.database.models import Asset, AssetReference
16-
from app.assets.database.queries.asset_reference import list_references_page
3+
from app.assets.database.queries import create_content, create_record, list_records_page
4+
from app.assets.database.queries.records import RecordCursorBoundary, RecordPageSpec
175

186

19-
def _make_ref(session: Session, created_at: datetime, name: str, owner: str = "") -> AssetReference:
20-
asset = Asset(hash=f"blake3:{uuid.uuid4().hex}", size_bytes=1024)
21-
session.add(asset)
7+
def test_record_keyset_cursor_pages_in_creation_order(session: Session) -> None:
8+
records = [
9+
create_record(session, create_content(session, f"/output/{name}").id, name)
10+
for name in ("one.png", "two.png", "three.png")
11+
]
12+
for index, record in enumerate(records, start=1):
13+
record.id = f"00000000-0000-0000-0000-{index:012d}"
2214
session.flush()
23-
ref = AssetReference(
24-
id=str(uuid.uuid4()),
25-
asset_id=asset.id,
26-
owner_id=owner,
27-
name=name,
28-
file_path=f"/tmp/{name}",
29-
created_at=created_at,
30-
updated_at=created_at,
31-
last_access_time=created_at,
32-
is_missing=False,
33-
)
34-
session.add(ref)
35-
return ref
36-
37-
38-
@pytest.mark.parametrize("order", ["desc", "asc"])
39-
def test_tiebreaker_walks_duplicate_sort_values(session: Session, order: str):
40-
"""Four rows with the SAME created_at must paginate cleanly under cursor
41-
mode — no row dropped, no row repeated, despite the primary sort column
42-
being non-discriminating.
43-
"""
44-
shared_ts = datetime(2024, 5, 20, 12, 0, 0) # naive UTC, like the DB stores
45-
refs = [_make_ref(session, shared_ts, f"tie_{i}.png") for i in range(4)]
46-
session.commit()
47-
48-
expected_ids = sorted([r.id for r in refs], reverse=(order == "desc"))
4915

50-
# Walk the cursor by hand: page size 2, take 3 pages (2 + 2 + 0).
51-
seen: list[str] = []
52-
after_value = None
53-
after_id = None
54-
for _ in range(4): # generous loop bound; ought to be 2 iterations
55-
page, _tag_map, _total = list_references_page(
56-
session,
57-
limit=2,
58-
sort="created_at",
59-
order=order,
60-
after_cursor_value=after_value,
61-
after_cursor_id=after_id,
62-
)
63-
if not page:
64-
break
65-
seen.extend(p.id for p in page)
66-
# Use the last row's (created_at, id) as the next cursor input.
67-
last = page[-1]
68-
after_value, after_id = last.created_at, last.id
69-
if len(page) < 2:
70-
break
71-
72-
assert seen == expected_ids, (
73-
f"keyset tiebreaker failed for order={order}: expected {expected_ids}, got {seen}"
16+
first_page, _, _ = list_records_page(
17+
session,
18+
RecordPageSpec(limit=2, order="asc"),
7419
)
75-
76-
77-
def test_tiebreaker_no_duplicates_under_mixed_collisions(session: Session):
78-
"""Some rows share a timestamp, some don't. The cursor must still walk
79-
every row exactly once regardless of where ties sit relative to a
80-
page boundary."""
81-
t1 = datetime(2024, 5, 20, 12, 0, 0)
82-
t2 = datetime(2024, 5, 20, 12, 0, 1)
83-
layout = [t1, t1, t1, t2, t2] # three rows at t1, two at t2
84-
refs = [_make_ref(session, ts, f"mix_{i}.png") for i, ts in enumerate(layout)]
85-
session.commit()
86-
87-
all_ids = {r.id for r in refs}
88-
seen_set: set[str] = set()
89-
seen_list: list[str] = []
90-
after_value = None
91-
after_id = None
92-
for _ in range(6):
93-
page, _, _ = list_references_page(
94-
session,
20+
boundary_record = first_page[-1]
21+
second_page, _, _ = list_records_page(
22+
session,
23+
RecordPageSpec(
9524
limit=2,
96-
sort="created_at",
97-
order="desc",
98-
after_cursor_value=after_value,
99-
after_cursor_id=after_id,
100-
)
101-
if not page:
102-
break
103-
for p in page:
104-
assert p.id not in seen_set, f"duplicate row {p.id} appeared in cursor walk"
105-
seen_set.add(p.id)
106-
seen_list.append(p.id)
107-
last = page[-1]
108-
after_value, after_id = last.created_at, last.id
109-
if len(page) < 2:
110-
break
25+
order="asc",
26+
after=RecordCursorBoundary(
27+
value=boundary_record.created_at,
28+
id=boundary_record.id,
29+
),
30+
),
31+
)
11132

112-
assert seen_set == all_ids, f"missing rows: expected {all_ids}, got {seen_set}"
33+
assert [record.id for record in first_page + second_page] == [record.id for record in records]

0 commit comments

Comments
 (0)