Files
rfc-app/backend/tests/test_metadata_migration.py
T
Ben Stull f05ee59763 v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:24:27 -07:00

136 lines
4.0 KiB
Python

"""SLICE-1 integration — frontmatter→sidecar migration tool (PUC-5).
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.5 / §7.2:
a tool walks a collection; for each entry with legacy frontmatter it writes
`<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per
collection, idempotent, preserving unknown keys (INV-7).
"""
from __future__ import annotations
import asyncio
import yaml
from app import gitea as gitea_mod, metadata
from app.config import load_config
from test_propose_vertical import ( # noqa: F401 (fixtures)
app_with_fake_gitea,
tmp_env,
)
ALPHA_MD = """---
slug: alpha
title: Alpha
state: active
owners:
- ben.stull
tags:
- one
priority: P1
---
Alpha body prose.
"""
BETA_MD = """---
slug: beta
title: Beta
state: super-draft
---
Beta body prose.
"""
def _seed_entries(fake):
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
"content": ALPHA_MD, "sha": "a0001"}
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
"content": BETA_MD, "sha": "b0001"}
def _run_migration(subfolder=""):
cfg = load_config()
gitea = gitea_mod.Gitea(cfg)
return asyncio.run(
metadata.migrate_collection(
gitea, org="wiggleverse", repo="meta", subfolder=subfolder
)
)
def test_migration_writes_sidecars_and_strips_bodies(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
_seed_entries(fake)
summary = _run_migration()
assert sorted(summary["migrated"]) == ["alpha", "beta"]
# Sidecars now exist.
alpha_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
beta_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/beta.meta.yaml")]["content"]
alpha_vals = yaml.safe_load(alpha_sc)
assert alpha_vals["slug"] == "alpha"
assert alpha_vals["title"] == "Alpha"
assert alpha_vals["tags"] == ["one"]
# INV-7: the unknown key rides along into the sidecar.
assert alpha_vals["priority"] == "P1"
# .md bodies are stripped of frontmatter (INV-2).
alpha_md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
assert "---" not in alpha_md
assert "priority:" not in alpha_md
assert alpha_md.strip() == "Alpha body prose."
assert beta_sc # beta got a sidecar too
def test_migration_is_idempotent(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
_seed_entries(fake)
first = _run_migration()
assert sorted(first["migrated"]) == ["alpha", "beta"]
assert first["committed"] is True
commits_after_first = fake._commit_counter
alpha_md_after_first = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
second = _run_migration()
assert second["migrated"] == []
assert second["committed"] is False
# No new commit; files untouched.
assert fake._commit_counter == commits_after_first
assert fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] == alpha_md_after_first
def test_migration_one_commit_for_whole_collection(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
_seed_entries(fake)
before = fake._commit_counter
_run_migration()
# Two entries migrated in exactly one commit (ChangeFiles batch).
assert fake._commit_counter == before + 1
def test_migration_dual_read_equivalence_after_migrate(app_with_fake_gitea):
"""An entry reads identically before and after migration (INV-6)."""
_app, fake = app_with_fake_gitea
_seed_entries(fake)
before, _ = metadata.read_entry(ALPHA_MD, None)
_run_migration()
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
after, malformed = metadata.read_entry(md, sc)
assert malformed is False
assert after.slug == before.slug
assert after.title == before.title
assert after.state == before.state
assert after.tags == before.tags
assert after.extra == before.extra
assert after.body.strip() == before.body.strip()