Files
hyperguild/ingestion/internal/legacymove/legacymove_test.go
T
mathiasandClaude Sonnet 5.5 b66b45b8fd
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
fix(legacymove): second run never reuses an archive; Apply re-checks the planned bytes; rollback checks first and is repeatable (review of #213)
From the independent review:
- the archive dir is unique per run (-2, -3 ...): a second Apply the same day
  replaced the first run's archived original and rewrote its manifest
- every Move/Delete carries the SHA-256 it was planned on; Apply skips and
  reports (Result.Changed) a page whose bytes changed since planning, so a note
  dropped again after PlanRaw is not deleted unprocessed
- Rollback verifies all targets before changing anything, recognises pages
  already restored (so it can be run again), and refuses to delete a moved copy
  that was edited since the move
- names whose flattened archive key exceeds NAME_MAX are skipped at planning
  time instead of wedging the run
- a page that already has wing/hall keys, or whose type sits in a nested/block
  scalar line or an unterminated block, is not moved
Each has a test that fails without it (mutation-checked). Residual: a swap
between Apply's read and its remove; the archived copy is still the bytes read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:42:44 +02:00

446 lines
18 KiB
Go

package legacymove_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/legacymove"
)
func page(t *testing.T, dir, rel, body string) {
t.Helper()
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
}
func src(typ string) string {
return "---\ntitle: T\ntype: " + typ + "\n---\n\n## Summary\n\nBody with [[a-link|A]] kept as is.\n"
}
func byFrom(items []legacymove.Item) map[string]legacymove.Item {
m := map[string]legacymove.Item{}
for _, it := range items {
m[it.From] = it
}
return m
}
func TestPlan_VideosAndArticlesMoveToTheDefaultWing(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/sources/an-article.md", src("article"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Move, m["wiki/sources/a-talk.md"].Action)
assert.Equal(t, "wiki/web-clip/sources/a-talk.md", m["wiki/sources/a-talk.md"].To)
assert.Equal(t, legacymove.Move, m["wiki/sources/an-article.md"].Action)
}
func TestPlan_NotesProjectsAndUntypedPagesStayWhereTheyAre(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-note.md", src("note"))
page(t, dir, "wiki/sources/a-project.md", src("project"))
page(t, dir, "wiki/sources/untyped.md", "---\ntitle: T\n---\nbody")
page(t, dir, "wiki/sources/_index.md", "index")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
m := byFrom(items)
for _, f := range []string{"a-note", "a-project", "untyped"} {
assert.Equal(t, legacymove.Skip, m["wiki/sources/"+f+".md"].Action, f)
}
_, listed := m["wiki/sources/_index.md"]
assert.False(t, listed, "the index is not a page")
}
func TestPlan_AutoTunnelLogsAreDeleted(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
assert.Equal(t, legacymove.Delete, byFrom(items)["wiki/sources/tunnel-candidates-2026-06-14.md"].Action,
"never moved, whatever the type says")
}
func TestPlan_ASlugAlreadyUsedElsewhereIsSkippedNotOverwritten(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/entities/a-talk.md", "an entity with the same slug")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
it := byFrom(items)["wiki/sources/a-talk.md"]
assert.Equal(t, legacymove.Skip, it.Action)
assert.Contains(t, it.Reason, "already used")
}
func TestPlan_ADefaultWingThatIsNotAPlainNameIsRefused(t *testing.T) {
_, err := legacymove.Plan(t.TempDir(), "../x")
assert.Error(t, err)
}
func TestApply_MovesStampsAndArchivesEverythingItTouches(t *testing.T) {
dir := t.TempDir()
talk := src("video")
junk := "tunnel log"
page(t, dir, "wiki/sources/a-talk.md", talk)
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", junk)
page(t, dir, "wiki/sources/a-note.md", src("note"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
assert.Equal(t, 1, res.Moved)
assert.Equal(t, 1, res.Deleted)
moved, err := os.ReadFile(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
require.NoError(t, err)
assert.Contains(t, string(moved), "wing: web-clip")
assert.Contains(t, string(moved), "hall: sources")
assert.Contains(t, string(moved), "Body with [[a-link|A]] kept as is.", "body and links are byte-preserved")
for _, gone := range []string{"wiki/sources/a-talk.md", "wiki/sources/tunnel-candidates-2026-06-14.md"} {
_, statErr := os.Stat(filepath.Join(dir, gone))
assert.ErrorIs(t, statErr, os.ErrNotExist, gone)
}
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-note.md"))
assert.NoError(t, statErr, "a skipped page is left exactly where it is")
again, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
for _, it := range again {
assert.Equal(t, legacymove.Skip, it.Action, "nothing is left to do: %s", it.From)
}
}
func TestRollback_RestoresEveryTouchedPageByteForByte(t *testing.T) {
dir := t.TempDir()
talk := src("video")
junk := "tunnel log"
page(t, dir, "wiki/sources/a-talk.md", talk)
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", junk)
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
got, err := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
require.NoError(t, err)
assert.Equal(t, talk, string(got))
got, err = os.ReadFile(filepath.Join(dir, "wiki/sources/tunnel-candidates-2026-06-14.md"))
require.NoError(t, err)
assert.Equal(t, junk, string(got))
_, statErr := os.Stat(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "the moved copy is removed")
}
func TestApply_NeverOverwritesAndKeepsTheSourceIfTheDestinationAppearsLate(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
page(t, dir, "wiki/web-clip/sources/a-talk.md", "appeared after the plan")
_, err = legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.Error(t, err)
got, _ := os.ReadFile(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
assert.Equal(t, "appeared after the plan", string(got))
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.NoError(t, statErr, "the source survives")
}
func TestApply_TheArchiveIsOutsideRawSoTheWatcherNeverIngestsIt(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", "x")
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
assert.False(t, strings.Contains(res.ArchiveDir, "/raw"), res.ArchiveDir)
assert.True(t, strings.HasPrefix(res.ArchiveDir, "archive/"), res.ArchiveDir)
}
// --- raw/ leftovers from before inbox zero ---
func rawFile(t *testing.T, dir, rel, body string) { page(t, dir, rel, body) }
func TestPlanRaw_AnArchivedOriginalAndItsMarkerAreDeleted(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/clip.md", "the clip text")
rawFile(t, dir, "raw/clip.md.processed", "2026-10-19")
rawFile(t, dir, "raw/processed/2026-10-19/clip.md", "the clip text")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Delete, m["raw/clip.md"].Action)
assert.Equal(t, legacymove.Delete, m["raw/clip.md.processed"].Action)
}
func TestPlanRaw_AnOriginalWhoseArchiveCopyDiffersIsLeftAlone(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/clip.md", "the clip text, edited since")
rawFile(t, dir, "raw/clip.md.processed", "2026-10-19")
rawFile(t, dir, "raw/processed/2026-10-19/clip.md", "the clip text")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Skip, m["raw/clip.md"].Action, "no identical archive copy: not ours to delete")
assert.Contains(t, m["raw/clip.md"].Reason, "no identical copy")
_, hasMarker := m["raw/clip.md.processed"]
assert.False(t, hasMarker, "the marker of a note we keep is kept")
}
func TestPlanRaw_ACopyUnderATimestampedNameCounts(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/clip.md", "same text")
rawFile(t, dir, "raw/clip.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/clip-180200.md", "same text")
rawFile(t, dir, "raw/processed/2026-10-19/clip-2.md", "other text")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
assert.Equal(t, legacymove.Delete, byFrom(items)["raw/clip.md"].Action)
}
func TestPlanRaw_AnOrphanMarkerIsDeleted(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/gone.md.processed", "2026-06-16")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
assert.Equal(t, legacymove.Delete, byFrom(items)["raw/gone.md.processed"].Action)
}
func TestPlanRaw_TheAutoTunnelReviewQueueAndANoteWithoutAMarkerAreNotTouched(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/tunnel-candidates-2026-06-14.md", "AutoTunnel's own queue")
rawFile(t, dir, "raw/tunnel-candidates-2026-06-14.md.processed", "x")
rawFile(t, dir, "raw/fresh.md", "an unprocessed clip")
rawFile(t, dir, "raw/processed/2026-10-19/fresh.md", "an unprocessed clip") // same bytes, but no marker: still in the inbox
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
for _, f := range []string{"raw/tunnel-candidates-2026-06-14.md", "raw/tunnel-candidates-2026-06-14.md.processed", "raw/fresh.md"} {
_, listed := m[f]
assert.False(t, listed, "%s is not a leftover", f)
}
}
func TestApplyAndRollback_HandleRawLeftoversAndNeverCollideWithSourcesOfTheSameName(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/same.md", "raw text")
rawFile(t, dir, "raw/same.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/same.md", "raw text")
page(t, dir, "wiki/sources/same.md", src("video")) // a wiki source with the same basename
rawItems, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
srcItems, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
res, err := legacymove.Apply(dir, append(rawItems, srcItems...), time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
for _, gone := range []string{"raw/same.md", "raw/same.md.processed", "wiki/sources/same.md"} {
_, statErr := os.Stat(filepath.Join(dir, gone))
assert.ErrorIs(t, statErr, os.ErrNotExist, gone)
}
_, statErr := os.Stat(filepath.Join(dir, "raw/processed/2026-10-19/same.md"))
assert.NoError(t, statErr, "the archive copy in raw/processed is never touched")
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
got, err := os.ReadFile(filepath.Join(dir, "raw/same.md"))
require.NoError(t, err)
assert.Equal(t, "raw text", string(got), "the raw note and the wiki source of one name did not overwrite each other in the archive")
got, err = os.ReadFile(filepath.Join(dir, "wiki/sources/same.md"))
require.NoError(t, err)
assert.Equal(t, src("video"), string(got))
}
// --- review of #213 ---
var day = time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
// F1: a second Apply on the same day must not touch the first run's archive.
func TestApply_ASecondRunTheSameDayGetsItsOwnArchiveAndKeepsTheFirst(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/sources/tunnel-candidates-1.md", "FIRST tunnel bytes")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
first, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
page(t, dir, "wiki/sources/tunnel-candidates-1.md", "SECOND tunnel bytes") // same name reappears
items2, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
second, err := legacymove.Apply(dir, items2, day)
require.NoError(t, err)
assert.NotEqual(t, first.ArchiveDir, second.ArchiveDir)
got, err := os.ReadFile(filepath.Join(dir, first.ArchiveDir, "originals", "wiki__sources__tunnel-candidates-1.md"))
require.NoError(t, err)
assert.Equal(t, "FIRST tunnel bytes", string(got), "the first run's archived original is intact")
require.NoError(t, legacymove.Rollback(dir, first.ArchiveDir))
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.NoError(t, statErr, "the first run's move is still rolled back by its own manifest")
}
// F2: Plan decided on the bytes it saw. Apply must not delete different bytes.
func TestApply_ARawNoteThatChangedSincePlanningIsNotDeleted(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/a.md", "bytes that have an archive copy")
rawFile(t, dir, "raw/a.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/a.md", "bytes that have an archive copy")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
rawFile(t, dir, "raw/a.md", "NEW UNPROCESSED BYTES") // dropped again before Apply runs
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(dir, "raw/a.md"))
require.NoError(t, err, "the changed note is still in the inbox")
assert.Equal(t, "NEW UNPROCESSED BYTES", string(got))
assert.Contains(t, strings.Join(res.Changed, ","), "raw/a.md")
}
func TestApply_AWikiSourceThatChangedSincePlanningIsNotMoved(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
page(t, dir, "wiki/sources/a-talk.md", src("video")+"\nan edit made after the plan\n")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
assert.Zero(t, res.Moved)
got, _ := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.Contains(t, string(got), "an edit made after the plan")
assert.NotEmpty(t, res.Changed)
}
// F3: rollback checks everything first, changes nothing on a conflict, can be
// repeated, and never throws away an edit.
func TestRollback_RefusesBeforeChangingAnythingWhenATargetExists(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/sources/b-talk.md", src("video"))
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
page(t, dir, "wiki/sources/b-talk.md", "someone put a different b here")
err = legacymove.Rollback(dir, res.ArchiveDir)
require.Error(t, err)
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was restored: a was not touched either")
_, statErr = os.Stat(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
assert.NoError(t, statErr, "and the moved copies are still there")
}
func TestRollback_CanBeRunTwice(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir), "a repeat finds everything already restored")
got, _ := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.Equal(t, src("video"), string(got))
}
func TestRollback_DoesNotDeleteAMovedPageThatWasEditedSinceTheMove(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
moved := filepath.Join(dir, "wiki/web-clip/sources/a-talk.md")
b, _ := os.ReadFile(moved)
require.NoError(t, os.WriteFile(moved, append(b, []byte("\nmy edit\n")...), 0o644))
err = legacymove.Rollback(dir, res.ArchiveDir)
require.Error(t, err)
got, readErr := os.ReadFile(moved)
require.NoError(t, readErr)
assert.Contains(t, string(got), "my edit", "the edit survives")
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was changed")
}
// F4: a name whose archive key cannot fit must be skipped at planning time.
func TestPlanRaw_ANameTooLongToArchiveIsSkippedNotAbortedOn(t *testing.T) {
dir := t.TempDir()
long := strings.Repeat("a", 240) + ".md"
rawFile(t, dir, "raw/"+long, "text")
rawFile(t, dir, "raw/"+long+".processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/"+long, "text")
rawFile(t, dir, "raw/ok.md", "fine")
rawFile(t, dir, "raw/ok.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/ok.md", "fine")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Skip, m["raw/"+long].Action)
assert.Contains(t, m["raw/"+long].Reason, "too long")
assert.Equal(t, legacymove.Delete, m["raw/ok.md"].Action)
_, err = legacymove.Apply(dir, items, day)
require.NoError(t, err, "the long name does not wedge the run")
_, statErr := os.Stat(filepath.Join(dir, "raw/"+long))
assert.NoError(t, statErr, "and it is left where it is")
}
// F6: do not stamp a page that already carries a wing or hall; only a top-level
// type inside a closed frontmatter block decides.
func TestPlan_AMovedPageIsNeverGivenDuplicateWingKeys(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/has-wing.md", "---\ntitle: T\ntype: video\nwing: old\nhall: facts\n---\nbody")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
it := byFrom(items)["wiki/sources/has-wing.md"]
assert.Equal(t, legacymove.Skip, it.Action)
assert.Contains(t, it.Reason, "already has")
}
func TestPlan_OnlyATopLevelTypeInAClosedBlockClassifiesAPage(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/nested.md", "---\ntitle: T\nmeta:\n type: video\n---\nbody")
page(t, dir, "wiki/sources/blockscalar.md", "---\ntitle: T\ndesc: |\n type: article\n---\nbody")
page(t, dir, "wiki/sources/unterminated.md", "---\ntitle: T\ntype: video\nbody with no closing fence")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
m := byFrom(items)
for _, f := range []string{"nested", "blockscalar", "unterminated"} {
assert.Equal(t, legacymove.Skip, m["wiki/sources/"+f+".md"].Action, f)
}
}
func TestPlan_AWikiSourceWithATooLongNameIsSkipped(t *testing.T) {
dir := t.TempDir()
long := strings.Repeat("a", 240) + ".md"
page(t, dir, "wiki/sources/"+long, src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
it := byFrom(items)["wiki/sources/"+long]
assert.Equal(t, legacymove.Skip, it.Action)
assert.Contains(t, it.Reason, "too long")
}