§22 M3-backend Plan B (1/2): slug-keyed PK rebuilds activate project #2 (v0.36.0)
Lands the table rebuilds migration 026 deferred until a second project exists, shipped separately from per-project serving for migration hygiene. No behavior change (deployments stay single-project 'default'). - migration 028_project_scoped_keys.sql: folds project_id into the slug-keyed PK/UNIQUE of 13 tables (cached_rfcs PK (slug)->(project_id,slug); the UNIQUE/PK on cached_branches, branch_visibility, branch_contribute_grants, stars, watches, pr_seen, branch_chat_seen, funder_consents, rfc_collaborators, contribution_requests, proposed_use_cases gain project_id) and makes the FKs to cached_rfcs on rfc_invitations/rfc_collaborators/contribution_requests composite (project_id, rfc_slug) -> cached_rfcs(project_id, slug). - db.run_migrations: a `-- migrate:no-foreign-keys` migration runs with PRAGMA foreign_keys toggled OFF around it (required by SQLite's table-rebuild procedure) + foreign_key_check after, failing loudly on dangling refs. - ON CONFLICT upsert targets for the rebuilt tables gain project_id so they match the new composite indexes (value still defaults to 'default'). - test_migration_028_project_scoped_keys.py: proves two-project same-slug coexistence, within-project uniqueness, composite-FK enforcement. 442 pass. - design doc §2 marked shipped. Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
|
||||
project #2. Proves two projects can hold the same slug, that (project_id, slug)
|
||||
is still unique within a project, that the rebuilt FK is composite + enforced,
|
||||
and that the no-foreign-keys migration runner left no dangling references."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _seed_two_projects(conn):
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
|
||||
(pid, pid.title(), pid + "-content"),
|
||||
)
|
||||
|
||||
|
||||
def test_same_slug_coexists_across_projects():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', ?)",
|
||||
(pid,),
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
|
||||
).fetchall()
|
||||
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
|
||||
|
||||
|
||||
def test_slug_still_unique_within_a_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'default')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Dup', 'active', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_rfc_collaborators_composite_fk_enforced():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
|
||||
)
|
||||
# Matching (project_id, slug) — FK holds.
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'ecomm')"
|
||||
)
|
||||
# Same slug but a project with no such entry — composite FK must reject.
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_stars_unique_now_scoped_by_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
|
||||
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
Reference in New Issue
Block a user