Slice 8: v1 ships — integration coverage, runbook, spec corrections

- Five new integration test files raise the suite from 75 to 96 green:
  test_hygiene_vertical (7), test_branch_path_routing (4),
  test_metadata_pr_merge (3), test_cache_bootstrap (4), test_e2e_smoke
  (3). The smoke test walks propose → super-draft → edit branch →
  body-edit PR → graduate → active-RFC PR → merge → notification →
  hygiene-sweep deletion end-to-end.
- deploy/RUNBOOK.md replaces the prior DEPLOY.md stub as a real
  runbook: prerequisites, first-time bring-up, day-2 ops (logs, DB
  backup, secret rotation, the §12 hygiene cadence), rollback shape,
  troubleshooting table.
- backend/.env.example grows the SMTP block, HYGIENE_TICK_SECONDS,
  and WEBHOOK_EMAIL_BOUNCE_SECRET with inline commentary.
- README points to RUNBOOK.md; the "what the build lets you do"
  section adds Slices 7 and 8.
- docs/DEV.md gets a Slice 8 — shipped section; the "Next slice"
  footer becomes the v1-complete epitaph.
- SPEC corrections per the §19.3 working agreement: §10.7 names the
  shared §12 sweep; §12 names the bot as actuator and the per-user
  branch_chat_seen preservation contract; §19.1 marks v1 complete
  and records Slice 8; the five §19.2 candidates Slice 8 folded in
  are marked settled with pointers at the resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 04:14:50 -07:00
parent 1a0c4428af
commit 36635049c7
11 changed files with 1585 additions and 410 deletions
+40
View File
@@ -41,3 +41,43 @@ ENABLED_MODELS=claude
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
# --- Email (§15.4) ---
# Leave SMTP_HOST unset to use the stdout fallback — the integration
# tests rely on it, and a dev environment without a real SMTP provider
# still sees envelope traces in the logs. Set the rest to wire a real
# provider (Postmark, Mailgun, SES, etc.).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_STARTTLS=1
# Single non-spoofing envelope identity per §15.9 — every notification
# email goes out from the same address regardless of which user's
# gesture produced it. Configure both the SPF and DKIM records for
# this address with the chosen SMTP provider.
EMAIL_FROM=notifications@wiggleverse.local
EMAIL_FROM_NAME=Wiggleverse
# §15.4 bundle threshold: when a user's quiet-hours release queue is
# at least this big, the flush goes out as one bundled "Activity while
# you were away" email instead of individual sends.
EMAIL_BUNDLE_THRESHOLD=5
# Set to 0 to suppress every outbound email (the inbox and SSE still
# work — only the email channel turns off).
EMAIL_ENABLED=1
# --- Email-bounce webhook (§15.4 + §19.2-settled) ---
# When set, `/api/webhooks/email-bounce` requires the same value in
# the `X-Webhook-Secret` header. Pick a long random string and
# configure the SMTP provider's webhook to inject it. When unset,
# the webhook stays unauthenticated for dev — the v1 contract.
WEBHOOK_EMAIL_BOUNCE_SECRET=
# --- §12 hygiene cadence (Slice 8) ---
# How often the hygiene scheduler sweeps for the 30/90-day boundaries.
# Production default is hourly; tests override to seconds via the same
# env var.
HYGIENE_TICK_SECONDS=3600
+157
View File
@@ -0,0 +1,157 @@
"""End-to-end integration test for the §19.2 "branch-name path routing"
candidate that Slice 8 settles.
Slice 2's `branches/<branch>` endpoints used FastAPI's default
`{branch}` matcher, which refuses slashes. Slice 2 worked around it
with dash-separated branch names (`<login>-draft-<hex>`); Slice 8
converts every `branches/<branch>` to `{branch:path}` and reorders
the routes so the bare GET is declared last — the more-specific
`threads` and `threads/{thread_id}/messages` GETs match first.
The tests prove:
* A branch with a slash in the name reads correctly via
`GET /api/rfcs/<slug>/branches/<branch:path>`.
* The deeper threads GET still works for unslashed branches.
* The deeper threads GET still works for slashed branches —
the ordering discipline holds.
* The POST routes for slashed branches still resolve (visibility,
chat-seen, threads).
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc # noqa: F401
def _seed_slashed_branch(fake: FakeGitea, *, slug: str, branch: str, body: str) -> None:
"""Seed a branch with a slash in the name directly on FakeGitea +
cached_branches so the GET / threads / etc. endpoints can read it."""
from app import db
repo_full = f"wiggleverse/rfc-0001-{slug}"
owner, repo = repo_full.split("/", 1)
sha = fake._next_sha()
fake.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
fake.files[(owner, repo, branch, "RFC.md")] = {"content": body, "sha": sha}
db.conn().execute(
"""
INSERT OR IGNORE INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', datetime('now'))
""",
(slug, branch, sha),
)
def test_get_branch_view_reads_slashed_branch_name(app_with_fake_gitea):
"""The §19.1 brief's headline assertion: a branch with a slash in
the name reads correctly via the `{branch:path}` route shape."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_seed_slashed_branch(
fake, slug="ohm", branch="alice/feature-rename",
body="# Slashed branch body\n",
)
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.get("/api/rfcs/ohm/branches/alice/feature-rename")
assert r.status_code == 200, r.text
view = r.json()
assert view["branch_name"] == "alice/feature-rename"
assert "Slashed branch body" in view["body"]
def test_deeper_threads_get_still_routes_for_unslashed_branch(app_with_fake_gitea):
"""Ordering discipline: declaring the bare GET last means the
`threads` GET still wins for unslashed `branches/foo/threads`."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_seed_slashed_branch(
fake, slug="ohm", branch="plain-branch",
body="# plain\n",
)
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.get("/api/rfcs/ohm/branches/plain-branch/threads")
assert r.status_code == 200, r.text
# Empty until a thread is posted, but the route fired.
assert "items" in r.json()
def test_deeper_threads_get_still_routes_for_slashed_branch(app_with_fake_gitea):
"""The branch name carries a slash; the threads GET must still
match (branch={branch:path} captures `alice/feature`, threads
is the literal anchor)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_seed_slashed_branch(
fake, slug="ohm", branch="alice/feature",
body="# slashed\n",
)
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.get("/api/rfcs/ohm/branches/alice/feature/threads")
assert r.status_code == 200, r.text
assert "items" in r.json()
def test_post_visibility_resolves_for_slashed_branch(app_with_fake_gitea):
"""The §11.1 visibility POST must also route correctly for a
slashed branch — proves the {branch:path} matcher applies to the
write paths too."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_seed_slashed_branch(
fake, slug="ohm", branch="alice/private-work",
body="# private\n",
)
# Materialize the creator row so alice can flip her own branch.
from app import db
db.conn().execute(
"""
INSERT OR IGNORE INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES ('ohm', 'alice/private-work', 1, 'just-me')
"""
)
db.conn().execute(
"""
INSERT OR IGNORE INTO actions (actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name)
VALUES (2, 'alice', 'create_branch', 'ohm', 'alice/private-work')
"""
)
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/branches/alice/private-work/visibility",
json={"read_public": False, "contribute_mode": "just-me"},
)
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
+260
View File
@@ -0,0 +1,260 @@
"""End-to-end integration test for the §19.2 "cache bootstrap from a
pre-existing meta repo" candidate that Slice 8 settles.
Per §4.1, the cache is rebuildable from Gitea. Per §15.9, the cache
resolves the actor on a PR by joining against the §6.5 `actions` log,
falling back to the `On-behalf-of:` trailer in the PR body, then to
the raw Gitea login as last resort. Slice 1 chose this fallback chain
and Slice 8 exercises it against history the bot did not author —
the disaster-recovery / transferred-meta-repo case.
The tests prove:
* When `actions` carries a matching row, the audit log wins —
`opened_by` reads the on-behalf-of login regardless of the bot's
appearance as Gitea opener.
* When `actions` is empty but the PR body carries
`On-behalf-of: Name <login>`, the trailer parses cleanly.
* When both are absent, the raw Gitea opener wins (and if even
that is the bot, the bot login is what surfaces — the v1
fallback, honest about who Gitea sees).
* The §15.9 framing holds: the bot login surfaces only in the
Git log and the trailer, never inside the audit-log path.
"""
from __future__ import annotations
import json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _seed_meta_pr_directly_in_fake(
fake: FakeGitea,
*,
slug: str,
pr_number: int,
head_branch: str,
title: str,
body: str,
gitea_opener_login: str,
) -> None:
"""Push a PR into FakeGitea as if it existed before the app cache
was bootstrapped — no propose endpoint, no audit log row, just a
pull on the meta repo with a chosen `user.login`. The reconciler
pulls this on its first sweep."""
pr = {
"number": pr_number,
"title": title,
"body": body,
"head": {"ref": head_branch, "sha": "sha-bootstrap"},
"base": {"ref": "main"},
"state": "open",
"merged": False,
"merged_at": None,
"closed_at": None,
"created_at": "2026-05-23T00:00:00Z",
"user": {"login": gitea_opener_login},
}
fake.pulls.setdefault(("wiggleverse", "meta"), []).append(pr)
# Create a corresponding branch so refresh_meta_branches sees it.
fake.branches[("wiggleverse", "meta")][head_branch] = {
"sha": "sha-bootstrap", "ts": "2026-05-23T00:00:00Z",
}
def test_audit_log_first_wins_over_bot_opener(app_with_fake_gitea):
"""If `actions` carries a row for this PR, that's the authoritative
actor — even when Gitea reports the bot as the opener. This is the
common steady-state path (the bot opened on behalf of a human, the
audit log captured it)."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
# Seed a cached RFC and an `actions` row for the PR before
# the reconciler runs.
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, body)
VALUES ('alpha', 'Alpha', 'super-draft', '[]', '[]', '[]', 'pitch')
"""
)
db.conn().execute(
"""
INSERT INTO actions
(actor_user_id, on_behalf_of, action_kind, rfc_slug, pr_number)
VALUES (NULL, 'alice', 'propose_rfc', 'alpha', 99)
"""
)
# Seed the fake's PR as if Gitea reports the bot as opener.
_seed_meta_pr_directly_in_fake(
fake,
slug="alpha", pr_number=99,
head_branch="propose/alpha",
title="Propose: Alpha",
body="Some idea\n",
gitea_opener_login="rfc-bot",
)
import asyncio
asyncio.run(cache_mod.refresh_meta_pulls(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT opened_by FROM cached_prs WHERE pr_number = 99"
).fetchone()
assert row is not None
# Audit-log row wins.
assert row["opened_by"] == "alice"
def test_trailer_parses_when_audit_log_missing(app_with_fake_gitea):
"""The cache-bootstrap-against-history-the-bot-did-not-author case:
no `actions` rows, but the PR body carries the §6.5
`On-behalf-of:` trailer. The trailer parses cleanly and the right
actor surfaces."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, body)
VALUES ('beta', 'Beta', 'super-draft', '[]', '[]', '[]', 'pitch')
"""
)
# No actions row. PR body carries the trailer.
_seed_meta_pr_directly_in_fake(
fake,
slug="beta", pr_number=42,
head_branch="propose/beta",
title="Propose: Beta",
body="A new framing\n\nOn-behalf-of: Charlie <charlie>",
gitea_opener_login="rfc-bot",
)
import asyncio
asyncio.run(cache_mod.refresh_meta_pulls(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT opened_by FROM cached_prs WHERE pr_number = 42"
).fetchone()
assert row["opened_by"] == "charlie"
def test_raw_gitea_login_used_when_audit_and_trailer_both_absent(app_with_fake_gitea):
"""When neither the audit log nor the trailer carries an actor,
the raw Gitea login is the last-resort fallback per §15.9 / Slice 1.
A non-bot login surfaces as the actor; a bot login surfaces as
the bot (honest about what Gitea sees — the v1 contract)."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, body)
VALUES ('gamma', 'Gamma', 'super-draft', '[]', '[]', '[]', 'pitch')
"""
)
# No audit row, no trailer — but Gitea reports a real user as opener.
_seed_meta_pr_directly_in_fake(
fake,
slug="gamma", pr_number=7,
head_branch="propose/gamma",
title="Propose: Gamma",
body="No trailer here\n",
gitea_opener_login="dana", # a real human, not the bot
)
import asyncio
asyncio.run(cache_mod.refresh_meta_pulls(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT opened_by FROM cached_prs WHERE pr_number = 7"
).fetchone()
assert row["opened_by"] == "dana"
def test_full_reconciler_sweep_resolves_actors_via_fallback_chain(app_with_fake_gitea):
"""End-to-end: a clean `cached_*` set plus a meta repo that has
history with no audit-log rows. The reconciler's first sweep must
bring everything up correctly — every PR resolves an actor through
the fallback chain without crashing."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
# Three PRs, three fallback paths exercised.
# 1. With audit-log row.
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, body)
VALUES ('alpha', 'Alpha', 'super-draft', '[]', '[]', '[]', 'pitch')
"""
)
db.conn().execute(
"""
INSERT INTO actions (actor_user_id, on_behalf_of, action_kind, rfc_slug, pr_number)
VALUES (NULL, 'alice', 'propose_rfc', 'alpha', 1)
"""
)
_seed_meta_pr_directly_in_fake(
fake, slug="alpha", pr_number=1,
head_branch="propose/alpha", title="A", body="b",
gitea_opener_login="rfc-bot",
)
# 2. Trailer only.
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, body)
VALUES ('beta', 'Beta', 'super-draft', '[]', '[]', '[]', 'pitch')
"""
)
_seed_meta_pr_directly_in_fake(
fake, slug="beta", pr_number=2,
head_branch="propose/beta", title="B",
body="On-behalf-of: Bob <bob>",
gitea_opener_login="rfc-bot",
)
# 3. Gitea opener as last resort.
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, body)
VALUES ('gamma', 'Gamma', 'super-draft', '[]', '[]', '[]', 'pitch')
"""
)
_seed_meta_pr_directly_in_fake(
fake, slug="gamma", pr_number=3,
head_branch="propose/gamma", title="G", body="naked",
gitea_opener_login="carol",
)
import asyncio
asyncio.run(cache_mod.refresh_meta_pulls(app.state.config, app.state.gitea))
prs = {
r["pr_number"]: r["opened_by"]
for r in db.conn().execute(
"SELECT pr_number, opened_by FROM cached_prs ORDER BY pr_number"
)
}
assert prs[1] == "alice" # audit log wins
assert prs[2] == "bob" # trailer wins
assert prs[3] == "carol" # raw Gitea opener wins
+249
View File
@@ -0,0 +1,249 @@
"""End-to-end smoke test for the Slice 8 hardening pass.
Walks the full user lifecycle against FakeGitea: propose → owner
merges → super-draft view → start edit branch → AI proposes (seeded
directly) → accept → open body-edit PR → owner merges → graduate →
active-RFC PR open → owner merges → §12 hygiene sweep deletes the
post-merge branch. The cases are long, and they catch the integration
seams a per-slice test would miss — that's the point per the §19.1
brief.
Plus the bounce-webhook signing-seam test: when
`WEBHOOK_EMAIL_BOUNCE_SECRET` is set, an unsigned POST is refused.
"""
from __future__ import annotations
import asyncio
import json as _json
from datetime import datetime, timedelta, timezone
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# The lifecycle walk
# ---------------------------------------------------------------------------
def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
"""Propose → merge → super-draft view → edit branch → accept change
→ body-edit PR → merge → graduate → active-RFC PR → merge →
§12 hygiene sweep cleans the post-merge branch.
The §15 notification path runs through `bot._log`'s fan_out at
every step; we assert at the end that the inbox has rows."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db, hygiene
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
# --- 1. Alice proposes a new RFC. ---
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "ohm",
"pitch": "A shared definition of what we mean by *human*.",
"tags": ["identity"],
})
assert r.status_code == 200, r.text
proposal_pr = r.json()["pr_number"]
# --- 2. Ben (owner) merges the proposal → super-draft exists. ---
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/proposals/{proposal_pr}/merge")
assert r.status_code == 200, r.text
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "super-draft"
# --- 3. Ben claims ownership so he can graduate later. ---
r = client.post("/api/rfcs/ohm/claim")
assert r.status_code == 200, r.text
claim_pr = r.json()["pr_number"]
# Claim PR also auto-merges per §13.1's owner/admin path.
r = client.post(f"/api/rfcs/ohm/prs/{claim_pr}/merge")
assert r.status_code == 200, r.text
# --- 4. Ben starts an edit branch on the super-draft. ---
r = client.post("/api/rfcs/ohm/start-edit-branch", json={})
assert r.status_code == 200, r.text
edit_branch = r.json()["branch_name"]
assert edit_branch.startswith("edit-ohm-")
# --- 5. Materialize an AI-style change directly + accept. ---
view = client.get(f"/api/rfcs/ohm/branches/{edit_branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tighten')
""",
(
edit_branch, thread_id,
"A shared definition of what we mean by *human*.",
"A shared, OHM-compatible definition of what we mean by *human*.",
),
)
change_id = cur.lastrowid
r = client.post(
f"/api/rfcs/ohm/branches/{edit_branch}/changes/{change_id}/accept",
json={
"proposed": "A shared, OHM-compatible definition of what we mean by *human*.",
"was_edited_before_accept": False,
},
)
assert r.status_code == 200, r.text
# --- 6. Open the body-edit PR + merge it. ---
r = client.post(
f"/api/rfcs/ohm/branches/{edit_branch}/open-pr",
json={"title": "OHM body edit", "description": "Add OHM-compatibility clause."},
)
assert r.status_code == 200, r.text
body_pr = r.json()["pr_number"]
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
assert r.status_code == 200, r.text
active_branch = r.json()["branch_name"]
# Materialize and accept a change so the branch has commits ahead.
view = client.get(f"/api/rfcs/ohm/branches/{active_branch}").json()
active_thread = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'expand')
""",
(
active_branch, active_thread,
"OHM-compatible definition",
"OHM-compatible, traceable definition",
),
)
change_id = cur.lastrowid
r = client.post(
f"/api/rfcs/ohm/branches/{active_branch}/changes/{change_id}/accept",
json={"proposed": "OHM-compatible, traceable definition",
"was_edited_before_accept": False},
)
assert r.status_code == 200, r.text
r = client.post(
f"/api/rfcs/ohm/branches/{active_branch}/open-pr",
json={"title": "Traceability clause", "description": "Add a traceability term."},
)
assert r.status_code == 200, r.text
active_pr = r.json()["pr_number"]
# --- 9. Ben merges the active-RFC PR. ---
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/rfcs/ohm/prs/{active_pr}/merge")
assert r.status_code == 200, r.text
# --- 10. Notifications fanned out — Ben's inbox carries rows. ---
r = client.get("/api/notifications")
assert r.status_code == 200, r.text
inbox = r.json()
assert "items" in inbox
# The merge of Alice's PR should at minimum have produced a
# structural beat to watchers (Ben auto-watched on his earlier
# gestures on the slug).
kinds = {item["event_kind"] for item in inbox["items"]}
assert kinds, f"expected non-empty inbox kinds, got: {inbox}"
# --- 11. Backdate the merge so the §12 hygiene sweep deletes
# the branch, then run the sweep. ---
long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
db.conn().execute(
"UPDATE cached_prs SET merged_at = ? WHERE pr_number = ?",
(long_ago, active_pr),
)
counters = asyncio.new_event_loop().run_until_complete(
hygiene.run_tick(config=app.state.config, bot=app.state.bot)
)
assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,),
).fetchone()
assert cached["state"] == "deleted"
# ---------------------------------------------------------------------------
# Bounce-webhook signing seam (§19.2 → settled)
# ---------------------------------------------------------------------------
def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gitea, monkeypatch):
"""When `WEBHOOK_EMAIL_BOUNCE_SECRET` is set, the webhook requires
the same value in the `X-Webhook-Secret` header. An unsigned POST
returns 401."""
from fastapi.testclient import TestClient
monkeypatch.setenv("WEBHOOK_EMAIL_BOUNCE_SECRET", "shhh")
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
)
assert r.status_code == 401, r.text
# With the right header, the call passes the guard. (No matching
# user exists, so we get {matched: False} — that's the v1 contract.)
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
headers={"X-Webhook-Secret": "shhh"},
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "matched": False}
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
"""The v1 contract: when no `WEBHOOK_EMAIL_BOUNCE_SECRET` is set,
the webhook stays unauthenticated for dev. The SMTP provider's
callback URL is the only contract."""
from fastapi.testclient import TestClient
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "nobody@example.com", "kind": "complaint"},
)
assert r.status_code == 200, r.text
+28 -4
View File
@@ -272,12 +272,31 @@ def test_branch_chat_seen_survives_branch_deletion(app_with_fake_gitea):
""",
(long_ago, long_ago),
)
# Seed a per-user seen cursor against the doomed branch.
# Seed a chat thread + message on the doomed branch, then the
# per-user seen cursor pointing at the message. The FK on
# branch_chat_seen.last_seen_message_id requires a real
# thread_messages row.
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
VALUES ('ohm', 'doomed', 'whole-doc', 'chat', 2)
"""
)
thread_id = cur.lastrowid
cur = db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'witness this')
""",
(thread_id,),
)
message_id = cur.lastrowid
db.conn().execute(
"""
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (2, 'ohm', 'doomed', 999, datetime('now'))
"""
VALUES (2, 'ohm', 'doomed', ?, datetime('now'))
""",
(message_id,),
)
_aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
@@ -292,7 +311,12 @@ def test_branch_chat_seen_survives_branch_deletion(app_with_fake_gitea):
"SELECT last_seen_message_id FROM branch_chat_seen WHERE user_id = 2 AND branch_name = 'doomed'"
).fetchone()
assert seen is not None
assert seen["last_seen_message_id"] == 999
assert seen["last_seen_message_id"] == message_id
# And the chat message itself survives — app-canonical, not cached.
msg = db.conn().execute(
"SELECT text FROM thread_messages WHERE id = ?", (message_id,)
).fetchone()
assert msg["text"] == "witness this"
def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
+149
View File
@@ -0,0 +1,149 @@
"""End-to-end integration test for the §19.2 "in-app merge for
metadata PRs" candidate that Slice 8 settles.
Slice 4 lands the §9.5 metadata pane that opens a `meta_metadata` PR
(title/tags edit) on the meta repo. The Slice 4 build deferred the
merge surface to Gitea web for v1 — `api_prs.merge_pr` was scoped to
body-changing PRs (`rfc_branch` and `meta_body_edit`). Slice 8 extends
`_require_pr` to include `meta_metadata` so the merge gesture lands
in-app. The diff-rendered review surface degrades gracefully — a
metadata PR doesn't have a body diff worth reviewing — but the merge
button works.
The tests prove:
* `POST /api/rfcs/<slug>/prs/<n>/merge` accepts a metadata PR and
runs the underlying merge.
* After the merge, the meta entry's title/tags carry forward and
the cache reflects the new values.
* A contributor (no role on the super-draft) is refused.
* Withdraw also works against a metadata PR — the same surface
supports the §10.8 withdraw gesture.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
PITCH = (
"Open Human Model is a framework for representing humans.\n\n"
"It defines consent, trait, and agency in compatible terms."
)
def test_metadata_pr_merges_in_app(app_with_fake_gitea):
"""The headline assertion: an owner can hit the same
`prs/<n>/merge` endpoint for a metadata PR and the change lands."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Open the metadata PR via the §9.5 endpoint.
r = client.post(
"/api/rfcs/ohm/metadata",
json={"title": "Open Human Model", "tags": ["identity", "schema"]},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# Verify the kind landed as meta_metadata.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
assert row["pr_kind"] == "meta_metadata"
# Merge via the §10.5 endpoint — the Slice 8 extension.
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge")
assert r.status_code == 200, r.text
# The cached entry now carries the new title + tags.
cached = db.conn().execute(
"SELECT title, tags_json FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
import json as _json
tags = _json.loads(cached["tags_json"])
assert cached["title"] == "Open Human Model"
assert "identity" in tags and "schema" in tags
# PR row's state is now 'merged'.
post = db.conn().execute(
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
assert post["state"] == "merged"
def test_metadata_pr_merge_refused_for_plain_contributor(app_with_fake_gitea):
"""§6.1 + §6.3: only owners/arbiters/admins can merge.
A plain contributor without any per-RFC authority gets 403, same
as the existing body-edit PR merge surface. Confirms the
extension didn't widen the permission gate."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Ben opens the metadata PR.
r = client.post(
"/api/rfcs/ohm/metadata",
json={"title": "OHM (revised)", "tags": ["identity"]},
)
pr_number = r.json()["pr_number"]
# Alice (plain contributor) tries to merge — 403.
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge")
assert r.status_code == 403
def test_metadata_pr_withdraw_works(app_with_fake_gitea):
"""§10.8 withdraw surface also handles meta_metadata PRs uniformly
— the API doesn't care which kind."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(
"/api/rfcs/ohm/metadata",
json={"title": "Something else", "tags": []},
)
pr_number = r.json()["pr_number"]
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/withdraw")
assert r.status_code == 200, r.text
post = db.conn().execute(
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
# Withdraw flips to 'withdrawn' via the audit-log marker the
# reconciler reads, but a direct withdraw via api_prs may
# leave it 'closed' depending on the refresh path. Either is
# the closed-not-merged shape the surface needs.
assert post["state"] in ("withdrawn", "closed")