Files
booth/tests/test_flow.py
T
vh b9750d221a feat(r2): C3 server side — 204 on an explicit JSON Accept, and back=view
- wants_json: true only for an exact `application/json` entry with q > 0.
  Absent, empty, wildcard, application/*, near misses, q=0 and malformed
  headers all fall through to the 303.
- The four mark routes share one exit, _mark_done: 204 with no body for the
  in-place client, otherwise _mark_redirect unchanged.
- back=view lands on /b/<name>/view?f=<rel>#rail, only for a media item of
  this booth. It is built from the resolved rel and never echoed. Anything
  else takes the no-`back` landing.
- tests/golden/r2_mark_303.json: 108 responses recorded from the PRE-R2
  code (6 route cases x back absent|marks x 9 non-JSON Accepts), replayed
  byte for byte (INV-4). Two mutations (q>=0, substring match) turn it red.
- The contract now states the q=0 rule.
2026-09-23 08:36:54 -07:00

227 lines
9.5 KiB
Python

"""R2 — the review flow: the Desk, the lightbox, the review.
Contract: docs/contracts/r2_flow.contract.md. Tests are grouped by the
contract's components (C1-C7) and named for the behaviour they pin.
"""
from __future__ import annotations
import pathlib
import re
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
from booth.items import booth_items # noqa: E402
from booth.marks import set_flag # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
b = root / name
b.mkdir()
for rel, data in files.items():
p = b / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return b
def _client(root: pathlib.Path) -> TestClient:
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False))
def _ordinals(body: str) -> dict[str, str]:
"""rel -> the ordinal text its tile prints, in render order."""
out = {}
for fig in re.findall(r'<figure class="item[^"]*"[^>]*>.*?</figure>', body, re.S):
rel = re.search(r'data-item="([^"]+)"', fig).group(1)
m = re.search(r'class="ord"[^>]*>#(\d+)<', fig)
out[rel] = m.group(1) if m else None
return out
# ---- C1: Item.ordinal ------------------------------------------------------
def test_a_filtered_tile_keeps_its_number_in_the_whole_set(tmp_path):
"""The tracer. b.png is the second item of three; under ?filter=flagged it
is the ONLY tile rendered and must still print #2, because the number is a
property of the item, not of the view."""
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
set_flag(b, "b.png", True)
body = _client(tmp_path).get("/b/g/?filter=flagged").text
assert _ordinals(body) == {"b.png": "2"}
def test_ordinals_count_rendered_items_only(tmp_path):
"""A caption sidecar is not an item and takes no number, so the numbers
stay contiguous over what the operator can see. And an undecodable name
that the quote() guard skips takes none either — it is not rendered."""
b = _booth(tmp_path, "g", {"a.png": PNG, "a.png.txt": b"cap", "b.png": PNG,
"c.png": PNG})
import os
os.close(os.open(bytes(b) + b"/m\xff.png", os.O_CREAT | os.O_WRONLY, 0o644))
items = booth_items(b)
assert [(it.rel, it.ordinal) for it in items] == [("a.png", 1), ("b.png", 2), ("c.png", 3)]
def test_ordinals_pad_to_the_width_of_the_whole_set(tmp_path):
"""Twelve items: numbers are written two wide, so a column of them lines
up — and a filter showing only the first does not narrow it to #1."""
b = _booth(tmp_path, "g", {f"{n:02d}.png": PNG for n in range(1, 13)})
set_flag(b, "01.png", True)
c = _client(tmp_path)
assert _ordinals(c.get("/b/g/").text)["01.png"] == "01"
assert _ordinals(c.get("/b/g/").text)["12.png"] == "12"
assert _ordinals(c.get("/b/g/?filter=flagged").text) == {"01.png": "01"}
# ---- C2: review_chain and .seen -------------------------------------------
def test_the_review_ring_is_the_item_order_filtered_to_media(tmp_path):
"""Images, video and audio, in set order. A doc is not in the ring: it
keeps its reading page."""
from booth.items import review_chain
b = _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c",
"d.webm": b"\x1aE", "e.zip": b"PK"})
assert review_chain(booth_items(b)) == ["a.png", "b.mp3", "d.webm"]
def test_the_review_route_rings_through_audio_in_set_order(tmp_path):
"""From the only image, "next" is the audio track that follows it in the
set — today's image-only ring had nowhere to go."""
_booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c"})
body = _client(tmp_path).get("/b/g/view?f=a.png").text
assert 'class="vnav vnext" href="?f=b.mp3"' in body
assert 'class="vnav vprev" href="?f=b.mp3"' in body # a two-item ring wraps
def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path):
"""`.seen` answers WHICH items were looked at full size. Gated on the item
record like `record_view`: pointing `f` at a dotfile the service itself
wrote is not a look at anything."""
from booth.items import read_seen
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
(b / ".marks.lock").write_bytes(b"")
c = _client(tmp_path)
for f in ("a.png", "c.png", "a.png", ".marks.lock"):
c.get(f"/b/g/view?f={f}")
assert read_seen(b) == {"a.png", "c.png"}
assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated
def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path):
"""A deleted file drops out of `.seen` the next time anything is seen, so
the marker never outgrows the booth and never counts a ghost."""
from booth.items import read_seen
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
c = _client(tmp_path)
c.get("/b/g/view?f=a.png")
(b / "a.png").unlink()
c.get("/b/g/view?f=b.png")
assert read_seen(b) == {"b.png"}
def test_a_planted_seen_symlink_is_replaced_not_written_through(tmp_path):
"""Any fleet session can write into a booth. A `.seen` symlink aimed at a
file outside must not turn a page view into a write at that path."""
outside = tmp_path / "victim.txt"
outside.write_text("untouched")
b = _booth(tmp_path, "g", {"a.png": PNG})
(b / ".seen").symlink_to(outside)
r = _client(tmp_path).get("/b/g/view?f=a.png")
assert r.status_code == 200
assert outside.read_text() == "untouched"
assert not (b / ".seen").is_symlink()
def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path):
"""NEVER RAISES: a booth the service cannot write to costs the marker, not
the page."""
b = _booth(tmp_path, "g", {"a.png": PNG})
b.chmod(0o555)
try:
r = _client(tmp_path).get("/b/g/view?f=a.png")
finally:
b.chmod(0o755)
assert r.status_code == 200
assert not (b / ".seen").exists()
# ---- C3: in-place judgment --------------------------------------------------
GOLDEN = pathlib.Path(__file__).parent / "golden" / "r2_mark_303.json"
def _seed(root: pathlib.Path) -> TestClient:
"""The golden's fixture, byte for byte (see golden_gen in the R2 notes)."""
from booth.marks import declare_pick, write_note
root.mkdir(parents=True, exist_ok=True)
b = _booth(root, "g", {"a.png": PNG, "b b.png": PNG, "c.md": PNG})
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="a.png")
set_flag(b, "a.png", True)
write_note(b, "a.png", "seed")
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
follow_redirects=False)
def test_every_pre_r2_request_shape_gets_a_byte_identical_303(tmp_path):
"""INV-4. The golden was recorded from the PRE-R2 code: every mark route,
with `back` absent and `back=marks`, under nine Accept headers that must
NOT count as asking for JSON. Status, every header, and the body must match
exactly — the no-JS guarantee lives in these bytes."""
import json
cases = json.loads(GOLDEN.read_text())
assert len(cases) == 108
for i, case in enumerate(cases):
c = _seed(tmp_path / str(i))
headers = {} if case["accept"] is None else {"accept": case["accept"]}
r = c.post(case["path"], data=case["form"], headers=headers)
got = {"status": r.status_code,
"headers": sorted([k.lower(), v] for k, v in r.headers.items()),
"body": r.content.decode("latin-1")}
want = {k: case[k] for k in ("status", "headers", "body")}
assert got == want, (case["path"], case["form"], case["accept"])
@pytest.mark.parametrize("path,form", [
("/b/g/answer", {"ask": "q", "choice": "y"}),
("/b/g/note", {"target": "a.png", "text": "in place"}),
("/b/g/flag", {"target": "b b.png", "on": "1"}),
("/b/g/unmark", {"mark": "note-1"}),
])
@pytest.mark.parametrize("accept", ["application/json", "text/html, application/json;q=0.5"])
def test_an_explicit_json_accept_gets_204_and_the_write_still_lands(tmp_path, path, form, accept):
"""The in-place path: same write as the form, no redirect, no body."""
from booth.marks import marks_for
c = _seed(tmp_path)
before = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")]
r = c.post(path, data=form, headers={"accept": accept})
assert r.status_code == 204 and r.content == b""
assert "location" not in r.headers
after = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")]
assert after != before, "the write must happen exactly as for the form"
@pytest.mark.parametrize("f,landing", [
("a.png", "/b/g/view?f=a.png#rail"),
("b b.png", "/b/g/view?f=b%20b.png#rail"),
("c.md", "/b/g/#item-b%20b.png"), # a doc is not in the review ring
("gone.png", "/b/g/#item-b%20b.png"), # not an item
("", "/b/g/#item-b%20b.png"),
("../../etc/passwd", "/b/g/#item-b%20b.png"),
])
def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landing):
"""The JS-off fix for the bounce: a flag set at full size lands back at
full size. Anything that is not a media item in this booth falls back to
the booth page exactly as a form with no `back` does."""
c = _seed(tmp_path)
r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
assert r.status_code == 303
assert r.headers["location"] == landing