a117fbb521
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>
96 lines
3.4 KiB
Python
96 lines
3.4 KiB
Python
"""§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')")
|