From d6cd4ad57de4afb84ee7368a5b95c10b45d3fa90 Mon Sep 17 00:00:00 2001 From: Serhii Drozdov Date: Sun, 4 Oct 2026 15:36:06 +0300 Subject: [PATCH 1/2] feat: add reversible review storage transitions --- src/rowbridge/models.py | 3 + src/rowbridge/storage.py | 212 ++++++++++++++++++++++++++++++++++++- tests/test_storage.py | 223 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 435 insertions(+), 3 deletions(-) diff --git a/src/rowbridge/models.py b/src/rowbridge/models.py index f2f5ca0..0912b78 100644 --- a/src/rowbridge/models.py +++ b/src/rowbridge/models.py @@ -18,6 +18,9 @@ class ReviewAction(StrEnum): ACCEPT = "accept" REJECT = "reject" MANUAL_LINK = "manual_link" + REOPEN = "reopen" + UNLINK = "unlink" + RESTORE_REJECTED = "restore_rejected" class RuleKind(StrEnum): diff --git a/src/rowbridge/storage.py b/src/rowbridge/storage.py index 82728df..2434996 100644 --- a/src/rowbridge/storage.py +++ b/src/rowbridge/storage.py @@ -143,6 +143,7 @@ def initialize(self) -> None: previous_status TEXT, resulting_status TEXT NOT NULL, score REAL, + evidence_json TEXT, detail TEXT NOT NULL ); @@ -155,6 +156,14 @@ def initialize(self) -> None: ON matches(run_id, b_row_id) WHERE b_row_id IS NOT NULL; """ ) + review_event_columns = { + str(row["name"]) + for row in connection.execute("PRAGMA table_info(review_events)").fetchall() + } + if "evidence_json" not in review_event_columns: + connection.execute( + "ALTER TABLE review_events ADD COLUMN evidence_json TEXT" + ) def save_run( self, @@ -581,6 +590,7 @@ def reject_review(self, run_id: str, match_id: int) -> None: resulting_status=MatchStatus.UNMATCHED.value, score=float(row["score"]), detail="Rejected proposed match; both rows returned to unmatched.", + evidence_json=str(row["evidence_json"]), ) self._refresh_run_counts(connection, run_id) @@ -646,6 +656,202 @@ def create_manual_link(self, run_id: str, a_match_id: int, b_match_id: int) -> N ) self._refresh_run_counts(connection, run_id) + def reopen_confirmed(self, run_id: str, match_id: int) -> None: + with self._connect() as connection: + row = self._get_match_row(connection, run_id, match_id) + self._require_status(row, MatchStatus.CONFIRMED) + connection.execute( + "UPDATE matches SET status = ? WHERE id = ?", + (MatchStatus.REVIEW.value, match_id), + ) + self._record_event( + connection=connection, + run_id=run_id, + action=ReviewAction.REOPEN, + source_match_id=match_id, + a_row_id=self._optional_int(row["a_row_id"]), + b_row_id=self._optional_int(row["b_row_id"]), + previous_status=MatchStatus.CONFIRMED.value, + resulting_status=MatchStatus.REVIEW.value, + score=float(row["score"]), + detail="Reopened confirmed match for review.", + evidence_json=str(row["evidence_json"]), + ) + self._refresh_run_counts(connection, run_id) + + def unlink_manual(self, run_id: str, match_id: int) -> None: + with self._connect() as connection: + row = self._get_match_row(connection, run_id, match_id) + self._require_status(row, MatchStatus.MANUAL_MATCHED) + a_row_id = self._optional_int(row["a_row_id"]) + b_row_id = self._optional_int(row["b_row_id"]) + if a_row_id is None or b_row_id is None: + raise ValueError("A manual match must contain one row from each side") + + connection.execute("DELETE FROM matches WHERE id = ?", (match_id,)) + self._insert_unmatched(connection, run_id, a_row_id, side="a") + self._insert_unmatched(connection, run_id, b_row_id, side="b") + self._record_event( + connection=connection, + run_id=run_id, + action=ReviewAction.UNLINK, + source_match_id=match_id, + a_row_id=a_row_id, + b_row_id=b_row_id, + previous_status=MatchStatus.MANUAL_MATCHED.value, + resulting_status=MatchStatus.UNMATCHED.value, + score=None, + detail="Unlinked manual match; both rows returned to unmatched.", + ) + self._refresh_run_counts(connection, run_id) + + def restore_rejected(self, run_id: str, event_id: int) -> None: + with self._connect() as connection: + event = connection.execute( + """ + SELECT * + FROM review_events + WHERE id = ? AND run_id = ? AND action = ? + """, + (event_id, run_id, ReviewAction.REJECT.value), + ).fetchone() + if event is None: + raise ValueError("Rejected review event not found for this run") + + evidence_json = event["evidence_json"] + if evidence_json is None: + raise ValueError( + "This rejected proposal predates reversible review and has no saved evidence" + ) + + a_row_id = self._optional_int(event["a_row_id"]) + b_row_id = self._optional_int(event["b_row_id"]) + if a_row_id is None or b_row_id is None: + raise ValueError("Rejected proposal is missing one of its source rows") + + newer_rejection = connection.execute( + """ + SELECT 1 + FROM review_events + WHERE run_id = ? AND action = ? + AND a_row_id = ? AND b_row_id = ? + AND id > ? + LIMIT 1 + """, + ( + run_id, + ReviewAction.REJECT.value, + a_row_id, + b_row_id, + event_id, + ), + ).fetchone() + if newer_rejection is not None: + raise ValueError("A newer rejection exists for this proposal") + + a_unmatched = connection.execute( + """ + SELECT id + FROM matches + WHERE run_id = ? AND status = ? + AND a_row_id = ? AND b_row_id IS NULL + """, + (run_id, MatchStatus.UNMATCHED.value, a_row_id), + ).fetchone() + b_unmatched = connection.execute( + """ + SELECT id + FROM matches + WHERE run_id = ? AND status = ? + AND b_row_id = ? AND a_row_id IS NULL + """, + (run_id, MatchStatus.UNMATCHED.value, b_row_id), + ).fetchone() + if a_unmatched is None or b_unmatched is None: + raise ValueError( + "Rejected proposal can only be restored while both rows are unmatched" + ) + + score = event["score"] + if score is None: + raise ValueError("Rejected proposal is missing its original score") + + connection.execute( + "DELETE FROM matches WHERE id IN (?, ?)", + (int(a_unmatched["id"]), int(b_unmatched["id"])), + ) + cursor = connection.execute( + """ + INSERT INTO matches ( + run_id, a_row_id, b_row_id, score, status, evidence_json + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + run_id, + a_row_id, + b_row_id, + float(score), + MatchStatus.REVIEW.value, + str(evidence_json), + ), + ) + if cursor.lastrowid is None: + raise RuntimeError("SQLite did not return a match id") + + self._record_event( + connection=connection, + run_id=run_id, + action=ReviewAction.RESTORE_REJECTED, + source_match_id=cursor.lastrowid, + a_row_id=a_row_id, + b_row_id=b_row_id, + previous_status=MatchStatus.UNMATCHED.value, + resulting_status=MatchStatus.REVIEW.value, + score=float(score), + detail="Restored rejected proposal to review.", + evidence_json=str(evidence_json), + ) + self._refresh_run_counts(connection, run_id) + + def restorable_rejected_event_ids(self, run_id: str) -> frozenset[int]: + with self._connect() as connection: + rows = connection.execute( + """ + SELECT e.id + FROM review_events e + JOIN matches a_match + ON a_match.run_id = e.run_id + AND a_match.status = ? + AND a_match.a_row_id = e.a_row_id + AND a_match.b_row_id IS NULL + JOIN matches b_match + ON b_match.run_id = e.run_id + AND b_match.status = ? + AND b_match.b_row_id = e.b_row_id + AND b_match.a_row_id IS NULL + WHERE e.run_id = ? + AND e.action = ? + AND e.evidence_json IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM review_events newer + WHERE newer.run_id = e.run_id + AND newer.action = ? + AND newer.a_row_id = e.a_row_id + AND newer.b_row_id = e.b_row_id + AND newer.id > e.id + ) + """, + ( + MatchStatus.UNMATCHED.value, + MatchStatus.UNMATCHED.value, + run_id, + ReviewAction.REJECT.value, + ReviewAction.REJECT.value, + ), + ).fetchall() + return frozenset(int(row["id"]) for row in rows) + def list_review_events( self, run_id: str, @@ -801,13 +1007,14 @@ def _record_event( resulting_status: str, score: float | None, detail: str, + evidence_json: str | None = None, ) -> None: connection.execute( """ INSERT INTO review_events ( run_id, created_at, action, source_match_id, a_row_id, b_row_id, - previous_status, resulting_status, score, detail - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + previous_status, resulting_status, score, evidence_json, detail + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( run_id, @@ -819,6 +1026,7 @@ def _record_event( previous_status, resulting_status, score, + evidence_json, detail, ), ) diff --git a/tests/test_storage.py b/tests/test_storage.py index cf74c98..4daf712 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -4,7 +4,16 @@ from datetime import UTC, datetime from pathlib import Path -from rowbridge.models import FieldMapping, MatchDecision, MatchSettings, MatchStatus +import pytest + +from rowbridge.models import ( + Evidence, + FieldMapping, + MatchDecision, + MatchSettings, + MatchStatus, + ReviewAction, +) from rowbridge.storage import Repository @@ -211,3 +220,215 @@ def test_delete_run_cascades_children_without_touching_other_runs( assert kept_matches is not None assert kept_source_rows[0] == 2 assert kept_matches[0] == 1 + + +def _save_review_run( + repository: Repository, + run_id: str, + *, + extra_unmatched_b: bool = False, +) -> None: + rows_b = ({"id": "B-1"}, {"id": "B-2"}) if extra_unmatched_b else ({"id": "B-1"},) + decisions = [ + MatchDecision( + 0, + 0, + 0.81, + MatchStatus.REVIEW, + (Evidence(field="primary", detail="A-1 vs B-1", score=0.81),), + ) + ] + if extra_unmatched_b: + decisions.append(MatchDecision(None, 1, 0.0, MatchStatus.UNMATCHED, ())) + + repository.save_run( + run_id=run_id, + created_at="2026-10-04T12:30:00+00:00", + filename_a="a.csv", + filename_b="b.csv", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": "A-1"},), + rows_b=rows_b, + decisions=tuple(decisions), + ) + + +def test_initialize_migrates_legacy_review_events_with_evidence_column( + tmp_path: Path, +) -> None: + database_path = tmp_path / "legacy.sqlite3" + with sqlite3.connect(database_path) as connection: + connection.execute( + """ + CREATE TABLE review_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + created_at TEXT NOT NULL, + action TEXT NOT NULL, + source_match_id INTEGER, + a_row_id INTEGER, + b_row_id INTEGER, + previous_status TEXT, + resulting_status TEXT NOT NULL, + score REAL, + detail TEXT NOT NULL + ) + """ + ) + + repository = Repository(database_path) + repository.initialize() + repository.initialize() + + with sqlite3.connect(database_path) as connection: + columns = { + str(row[1]) + for row in connection.execute("PRAGMA table_info(review_events)").fetchall() + } + + assert "evidence_json" in columns + + +def test_confirmed_match_can_be_reopened_without_losing_evidence(tmp_path: Path) -> None: + repository = make_repository(tmp_path) + run_id = "1" * 32 + _save_review_run(repository, run_id) + + review = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + original_evidence = review.evidence + repository.accept_review(run_id, review.id) + repository.reopen_confirmed(run_id, review.id) + + reopened = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + summary = repository.get_summary(run_id) + events = repository.list_review_events(run_id) + + assert reopened.id == review.id + assert reopened.score == review.score + assert reopened.evidence == original_evidence + assert summary.review == 1 + assert summary.human_matched == 0 + assert [event.action for event in events[:2]] == [ + ReviewAction.REOPEN, + ReviewAction.ACCEPT, + ] + + +def test_manual_match_can_be_unlinked_back_to_two_unmatched_rows(tmp_path: Path) -> None: + repository = make_repository(tmp_path) + run_id = "2" * 32 + repository.save_run( + run_id=run_id, + created_at="2026-10-04T12:31:00+00:00", + filename_a="a.csv", + filename_b="b.csv", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": "A-1"},), + rows_b=({"id": "B-1"},), + decisions=( + MatchDecision(0, None, 0.0, MatchStatus.UNMATCHED, ()), + MatchDecision(None, 0, 0.0, MatchStatus.UNMATCHED, ()), + ), + ) + a_match = repository.list_unmatched_side(run_id, "a", limit=1)[0] + b_match = repository.list_unmatched_side(run_id, "b", limit=1)[0] + repository.create_manual_link(run_id, a_match.id, b_match.id) + + manual = repository.list_matches(run_id, (MatchStatus.MANUAL_MATCHED,))[0] + repository.unlink_manual(run_id, manual.id) + + summary = repository.get_summary(run_id) + events = repository.list_review_events(run_id) + + assert repository.count_unmatched_side(run_id, "a") == 1 + assert repository.count_unmatched_side(run_id, "b") == 1 + assert summary.unmatched == 2 + assert summary.human_matched == 0 + assert [event.action for event in events[:2]] == [ + ReviewAction.UNLINK, + ReviewAction.MANUAL_LINK, + ] + + +def test_rejected_proposal_can_be_restored_with_original_score_and_evidence( + tmp_path: Path, +) -> None: + repository = make_repository(tmp_path) + run_id = "3" * 32 + _save_review_run(repository, run_id) + + original = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + repository.reject_review(run_id, original.id) + rejected = repository.list_review_events(run_id)[0] + + assert rejected.action == ReviewAction.REJECT + assert rejected.id in repository.restorable_rejected_event_ids(run_id) + + repository.restore_rejected(run_id, rejected.id) + + restored = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + summary = repository.get_summary(run_id) + events = repository.list_review_events(run_id) + + assert restored.score == original.score + assert restored.evidence == original.evidence + assert summary.review == 1 + assert summary.unmatched == 0 + assert repository.restorable_rejected_event_ids(run_id) == frozenset() + assert [event.action for event in events[:2]] == [ + ReviewAction.RESTORE_REJECTED, + ReviewAction.REJECT, + ] + + +def test_rejected_proposal_cannot_be_restored_after_a_row_is_reused( + tmp_path: Path, +) -> None: + repository = make_repository(tmp_path) + run_id = "4" * 32 + _save_review_run(repository, run_id, extra_unmatched_b=True) + + review = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + repository.reject_review(run_id, review.id) + rejected = repository.list_review_events(run_id)[0] + + a_match = repository.get_unmatched_match_by_row(run_id, "a", 2) + other_b_match = repository.get_unmatched_match_by_row(run_id, "b", 3) + assert a_match is not None + assert other_b_match is not None + repository.create_manual_link(run_id, a_match.id, other_b_match.id) + + assert rejected.id not in repository.restorable_rejected_event_ids(run_id) + with pytest.raises(ValueError, match="both rows are unmatched"): + repository.restore_rejected(run_id, rejected.id) + + assert repository.count_matches(run_id, (MatchStatus.MANUAL_MATCHED,)) == 1 + assert repository.count_unmatched_side(run_id, "b") == 1 + + +def test_only_latest_rejection_for_same_pair_can_be_restored(tmp_path: Path) -> None: + repository = make_repository(tmp_path) + run_id = "5" * 32 + _save_review_run(repository, run_id) + + first_review = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + repository.reject_review(run_id, first_review.id) + first_rejection = repository.list_review_events(run_id)[0] + repository.restore_rejected(run_id, first_rejection.id) + + restored_review = repository.list_matches(run_id, (MatchStatus.REVIEW,))[0] + repository.reject_review(run_id, restored_review.id) + events = repository.list_review_events(run_id) + rejection_events = [ + event for event in events if event.action == ReviewAction.REJECT + ] + second_rejection = rejection_events[0] + + assert second_rejection.id != first_rejection.id + assert repository.restorable_rejected_event_ids(run_id) == frozenset( + {second_rejection.id} + ) + with pytest.raises(ValueError, match="newer rejection"): + repository.restore_rejected(run_id, first_rejection.id) From ef52cf8af4d77de8c041a4d48c9be66c1205c8e9 Mon Sep 17 00:00:00 2001 From: Serhii Drozdov Date: Sun, 4 Oct 2026 16:08:25 +0300 Subject: [PATCH 2/2] feat: add reversible review actions --- src/rowbridge/static/app.css | 1 + src/rowbridge/templates/results.html | 20 +++- src/rowbridge/web.py | 33 +++++++ tests/test_web.py | 139 +++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/src/rowbridge/static/app.css b/src/rowbridge/static/app.css index ceec39c..a6cb14b 100644 --- a/src/rowbridge/static/app.css +++ b/src/rowbridge/static/app.css @@ -354,6 +354,7 @@ select:focus, input[type="number"]:focus { outline: 2px solid #bfd0e9; outline-o .history-item p { margin-top: 5px; font-size: 13px; line-height: 1.4; } .history-item small { display: block; margin-top: 7px; color: #8b948f; font-size: 11px; line-height: 1.4; } .history-detail { color: #6f7873; } +.history-action { margin-top: 9px; } .panel-empty { margin-top: 13px; font-size: 13px; line-height: 1.45; } @media (max-width: 1050px) { diff --git a/src/rowbridge/templates/results.html b/src/rowbridge/templates/results.html index a80408a..a0a59f8 100644 --- a/src/rowbridge/templates/results.html +++ b/src/rowbridge/templates/results.html @@ -45,7 +45,20 @@

Reconciliation results

{% if match.a_payload %}{{ display_value(match, 'a', run.mapping.primary_a) }}row {{ match.a_row_number }}{% else %}No row{% endif %} {% if match.b_payload %}{{ display_value(match, 'b', run.mapping.primary_b) }}row {{ match.b_row_number }}{% else %}No row{% endif %} {% if match.evidence %}
{% for item in match.evidence %}
{{ item.field|replace('_', ' ') }}
{{ item.detail }}{% if item.score %}+{{ "%.0f"|format(item.score * 100) }}{% endif %}
{% endfor %}
{% else %}No candidate above review threshold.{% endif %} - {% if match.status.value == 'review' %}
{% else %}No action{% endif %} + + {% if match.status.value == 'review' %} +
+
+
+
+ {% elif match.status.value == 'confirmed' %} +
+ {% elif match.status.value == 'manual_matched' %} +
+ {% else %} + No action + {% endif %} + {% else %}No rows in this view.{% endfor %} @@ -96,6 +109,11 @@

Review history

{% if event.a_payload %}{{ payload_value(event.a_payload, run.mapping.primary_a) }}{% else %}No Side A row{% endif %} ↔ {% if event.b_payload %}{{ payload_value(event.b_payload, run.mapping.primary_b) }}{% else %}No Side B row{% endif %}

{{ event.detail }}

{{ event.previous_status or 'none' }} → {{ event.resulting_status }} · {{ event.created_at }} + {% if event.id in restorable_rejected_event_ids %} +
+ +
+ {% endif %} {% endfor %}{% else %}

No human decisions yet.

{% endif %} diff --git a/src/rowbridge/web.py b/src/rowbridge/web.py index 1cffe9a..ad11025 100644 --- a/src/rowbridge/web.py +++ b/src/rowbridge/web.py @@ -397,6 +397,9 @@ def results( "summary": repository.get_summary(run_id), "matches": visible_matches, "events": repository.list_review_events(run_id, limit=100), + "restorable_rejected_event_ids": repository.restorable_rejected_event_ids( + run_id + ), "unmatched_a": unmatched_a, "unmatched_b": unmatched_b, "unmatched_a_count": unmatched_a_count, @@ -432,6 +435,36 @@ def reject_match(run_id: str, match_id: int) -> RedirectResponse: raise HTTPException(status_code=409, detail=str(exc)) from exc return RedirectResponse(url=f"/runs/{run_id}#manual-link", status_code=303) + @app.post("/runs/{run_id}/matches/{match_id}/reopen") + def reopen_match(run_id: str, match_id: int) -> RedirectResponse: + if repository.get_run(run_id) is None: + raise HTTPException(status_code=404, detail="Run not found") + try: + repository.reopen_confirmed(run_id, match_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return RedirectResponse(url=f"/runs/{run_id}#review-history", status_code=303) + + @app.post("/runs/{run_id}/matches/{match_id}/unlink") + def unlink_match(run_id: str, match_id: int) -> RedirectResponse: + if repository.get_run(run_id) is None: + raise HTTPException(status_code=404, detail="Run not found") + try: + repository.unlink_manual(run_id, match_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return RedirectResponse(url=f"/runs/{run_id}#manual-link", status_code=303) + + @app.post("/runs/{run_id}/review-events/{event_id}/restore") + def restore_rejected(run_id: str, event_id: int) -> RedirectResponse: + if repository.get_run(run_id) is None: + raise HTTPException(status_code=404, detail="Run not found") + try: + repository.restore_rejected(run_id, event_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return RedirectResponse(url=f"/runs/{run_id}?view=review#review-history", status_code=303) + @app.post("/runs/{run_id}/manual-links") def manual_link( run_id: str, diff --git a/tests/test_web.py b/tests/test_web.py index 376d8c7..3ee9357 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -249,6 +249,145 @@ def test_accept_review_persists_confirmation_and_audit_event(tmp_path: Path) -> assert repeated.status_code == 409 +def test_confirmed_match_can_be_reopened_from_results(tmp_path: Path) -> None: + client = make_client(tmp_path) + location = create_sample_run(client) + results = client.get(location) + match_id = review_match_id(results.text) + + accepted = client.post( + f"{location}/matches/{match_id}/accept", + follow_redirects=False, + ) + assert accepted.status_code == 303 + + confirmed = client.get(location) + assert f'action="{location}/matches/{match_id}/reopen"' in confirmed.text + assert ">Reopen review<" in confirmed.text + + reopened = client.post( + f"{location}/matches/{match_id}/reopen", + follow_redirects=False, + ) + assert reopened.status_code == 303 + assert reopened.headers["location"] == f"{location}#review-history" + + persisted = client.get(location) + assert f'action="{location}/matches/{match_id}/accept"' in persisted.text + assert "Reopened confirmed match for review." in persisted.text + assert "1Needs review" in persisted.text + assert "0Human matched" in persisted.text + + +def test_rejected_proposal_can_be_restored_from_review_history(tmp_path: Path) -> None: + client = make_client(tmp_path) + location = create_sample_run(client) + results = client.get(location) + match_id = review_match_id(results.text) + + rejected = client.post( + f"{location}/matches/{match_id}/reject", + follow_redirects=False, + ) + assert rejected.status_code == 303 + + after_reject = client.get(location) + restore_action = re.search( + rf'action="{re.escape(location)}/review-events/(\d+)/restore"', + after_reject.text, + ) + assert restore_action is not None + assert ">Restore proposal<" in after_reject.text + + restored = client.post( + f"{location}/review-events/{restore_action.group(1)}/restore", + follow_redirects=False, + ) + assert restored.status_code == 303 + assert restored.headers["location"] == f"{location}?view=review#review-history" + + persisted = client.get(location) + assert "Restored rejected proposal to review." in persisted.text + assert "1Needs review" in persisted.text + assert "0Unmatched rows" in persisted.text + assert ">Restore proposal<" not in persisted.text + + +def test_manual_match_can_be_unlinked_from_results(tmp_path: Path) -> None: + client = make_client(tmp_path) + location = create_sample_run(client) + results = client.get(location) + match_id = review_match_id(results.text) + + rejected = client.post( + f"{location}/matches/{match_id}/reject", + follow_redirects=False, + ) + assert rejected.status_code == 303 + + after_reject = client.get(location) + a_option = re.search(r'', after_reject.text) + b_option = re.search(r'', after_reject.text) + assert a_option is not None + assert b_option is not None + + linked = client.post( + f"{location}/manual-links", + data={ + "a_match_id": a_option.group(1), + "b_match_id": b_option.group(1), + }, + follow_redirects=False, + ) + assert linked.status_code == 303 + + manual = client.get(location) + unlink_action = re.search( + rf'action="{re.escape(location)}/matches/(\d+)/unlink"', + manual.text, + ) + assert unlink_action is not None + assert ">Unlink<" in manual.text + + unlinked = client.post( + f"{location}/matches/{unlink_action.group(1)}/unlink", + follow_redirects=False, + ) + assert unlinked.status_code == 303 + assert unlinked.headers["location"] == f"{location}#manual-link" + + persisted = client.get(location) + assert "Unlinked manual match; both rows returned to unmatched." in persisted.text + assert "0Human matched" in persisted.text + assert "2Unmatched rows" in persisted.text + assert "row 3 · INV-2" in persisted.text + assert "row 3 · INV-9" in persisted.text + + +def test_reversible_review_routes_reject_invalid_transitions(tmp_path: Path) -> None: + client = make_client(tmp_path) + location = create_sample_run(client) + results = client.get(location) + match_id = review_match_id(results.text) + + reopen_review = client.post( + f"{location}/matches/{match_id}/reopen", + follow_redirects=False, + ) + unlink_review = client.post( + f"{location}/matches/{match_id}/unlink", + follow_redirects=False, + ) + missing_restore = client.post( + f"{location}/review-events/999999/restore", + follow_redirects=False, + ) + + assert reopen_review.status_code == 409 + assert unlink_review.status_code == 409 + assert missing_restore.status_code == 409 + + def test_reject_review_then_manually_link_unmatched_rows(tmp_path: Path) -> None: client = make_client(tmp_path) location = create_sample_run(client)