package archive import ( "bytes" "log/slog" "os" "path/filepath" "sort" "strings" "testing" ) func mkTransmittal(t *testing.T, fsRoot, folderName string, files ...string) { t.Helper() dir := filepath.Join(fsRoot, folderName) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } for _, f := range files { path := filepath.Join(dir, f) if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } } func TestCompareRevisions_DraftOrdering(t *testing.T) { cases := []struct { a, b string want int // sign only }{ {"~A", "A", -1}, {"~A", "~B", -1}, {"A", "B", -1}, {"~A", "~A", 0}, {"A", "~A", 1}, } for _, c := range cases { got := compareRevisions(c.a, c.b) var sign int if got < 0 { sign = -1 } else if got > 0 { sign = 1 } if sign != c.want { t.Errorf("compareRevisions(%q, %q) sign = %d, want %d", c.a, c.b, sign, c.want) } } } func TestIndexAndResolve_DraftOnly(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_~A (IFR) - Title.pdf", ) idx, err := BuildIndex(root) if err != nil { t.Fatalf("BuildIndex: %v", err) } pe, ok := idx.ByProject["ProjectA"] if !ok { t.Fatalf("ProjectA bucket not indexed; ByProject = %v", idx.ByProject) } te, ok := pe.ByTracking["123"] if !ok { t.Fatalf("tracking 123 not indexed in ProjectA") } if te.HighestBaseRev != "~A" { t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev) } if _, ok := Resolve(idx, "ProjectA", "123.html"); !ok { t.Errorf("Resolve(ProjectA, 123.html) failed") } if _, ok := Resolve(idx, "ProjectA", "123_~A.html"); !ok { t.Errorf("Resolve(ProjectA, 123_~A.html) failed") } // Same tracking number queried under a different project must NOT resolve. if _, ok := Resolve(idx, "ProjectB", "123.html"); ok { t.Errorf("Resolve(ProjectB, 123.html) should fail — 123 belongs to ProjectA") } // Empty project — /.archive/ at the very root — never resolves. if _, ok := Resolve(idx, "", "123.html"); ok { t.Errorf("Resolve(\"\", 123.html) should fail — empty project must 404") } } func TestIndexAndResolve_DraftWithModifier(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_~A (IFR) - Title.pdf", ) mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", "123_~A+C1 (RTN) - Comments.pdf", ) idx, _ := BuildIndex(root) if _, ok := Resolve(idx, "ProjectA", "123_~A+C1.html"); !ok { t.Errorf("Resolve(ProjectA, 123_~A+C1.html) failed") } } // "First chronologically found version of the latest rev": when the same rev // appears in two transmittals within ONE project, the earlier date's copy // wins. (Cross-project duplicates are handled separately — see // TestCrossProject_NoCollision.) func TestRecordFile_FirstChronologicalWins(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-03-01_Late (IFR) - Title", "123_A (IFR) - Title.pdf", ) mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title", "123_A (IFR) - Title.pdf", ) idx, _ := BuildIndex(root) target, ok := Resolve(idx, "ProjectA", "123_A.html") if !ok { t.Fatalf("Resolve(ProjectA, 123_A.html) failed") } if !contains(target, "2025-01-01_Early") { t.Errorf("got %q, want path under 2025-01-01_Early/", target) } } // Same tracking number issued under two different projects must NOT collide: // each project's bucket carries its own copy and resolves independently. func TestCrossProject_NoCollision(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_A (IFR) - Title.pdf", ) mkTransmittal(t, root, "ProjectB/2025-06-01_T9 (IFR) - Different Title", "123_A (IFR) - Different Title.pdf", ) idx, _ := BuildIndex(root) a, okA := Resolve(idx, "ProjectA", "123_A.html") if !okA { t.Fatalf("Resolve(ProjectA, 123_A.html) failed") } if !contains(a, "ProjectA/") { t.Errorf("ProjectA target = %q, want path under ProjectA/", a) } b, okB := Resolve(idx, "ProjectB", "123_A.html") if !okB { t.Fatalf("Resolve(ProjectB, 123_A.html) failed") } if !contains(b, "ProjectB/") { t.Errorf("ProjectB target = %q, want path under ProjectB/", b) } if a == b { t.Errorf("ProjectA and ProjectB targets must differ; got %q == %q", a, b) } // Each project's listing surfaces only its own tracking numbers. aNames := entryNames(idx.AllEntries("ProjectA")) bNames := entryNames(idx.AllEntries("ProjectB")) for _, n := range aNames { for _, m := range bNames { if n == m && n == "123_A.html" { // Same URLName is expected; targets just differ. } } } } // AllEntries: every (tracking) gets .html (highest) AND a // _.html for every base revision present, scoped to project. func TestAllEntries_PerRevisionSurfaced(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_~A (IFR) - Title.pdf", ) mkTransmittal(t, root, "ProjectA/2025-03-01_T3 (IFC) - Title", "123_A (IFC) - Title.pdf", "456_0 (IFR) - Other.pdf", ) idx, _ := BuildIndex(root) entries := idx.AllEntries("ProjectA") got := make(map[string]string, len(entries)) for _, e := range entries { got[e.URLName] = e.TargetPath } // Highest-rev shortcut + each per-rev redirect should be present. wantNames := []string{ "123.html", // highest of 123 → A "123_A.html", // explicit A "123_~A.html", // explicit draft "456.html", // highest of 456 → 0 "456_0.html", // explicit 0 } for _, n := range wantNames { if _, ok := got[n]; !ok { t.Errorf("missing entry %q; got %v", n, sortedKeys(got)) } } // 123.html should resolve to the same path as 123_A.html (both point to // the highest-rev's first-chronological copy). if got["123.html"] != got["123_A.html"] { t.Errorf("123.html (%q) != 123_A.html (%q); should both resolve to highest", got["123.html"], got["123_A.html"]) } // Sort: .html sorts before _*.html (because '.'<'_'). for i := 1; i < len(entries); i++ { if entries[i-1].URLName > entries[i].URLName { t.Errorf("AllEntries not sorted: %q before %q", entries[i-1].URLName, entries[i].URLName) } } // Empty project key returns nil — root .archive doesn't exist. if got := idx.AllEntries(""); got != nil { t.Errorf("AllEntries(\"\") = %v, want nil", got) } if got := idx.AllEntries("NoSuchProject"); got != nil { t.Errorf("AllEntries(NoSuchProject) = %v, want nil", got) } } // Modifier-only files (no base) don't get a .html or // _.html entry — the redirect would have nowhere to go since // re.BasePath is empty. They remain reachable via _+.html // through the resolver but are not surfaced in the listing. func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", "123_~A+C1 (RTN) - Comments.pdf", ) idx, _ := BuildIndex(root) for _, e := range idx.AllEntries("ProjectA") { if e.URLName == "123.html" || e.URLName == "123_~A.html" { t.Errorf("unexpected entry %q (no base file exists)", e.URLName) } } } // Within-project collision: two different files claim to be the same // (project, tracking, rev). Chronological winner still wins, but a WARN log // is emitted with both paths so the authoring mistake is diagnosable. // // (Cross-project duplicates are NOT collisions — they live in separate // buckets. See TestCrossProject_NoCollision.) func TestRecordFile_WithinProjectCollisionLogged(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-03-01_Late (IFR) - Title", "123_A (IFR) - Title.pdf", ) // Different transmittal folder, same tracking+rev — e.g. operator // re-issued under a different cover sheet by mistake. mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title", "123_A (IFR) - Title.pdf", ) // Capture slog output during BuildIndex. var buf bytes.Buffer prev := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) defer slog.SetDefault(prev) idx, err := BuildIndex(root) if err != nil { t.Fatalf("BuildIndex: %v", err) } logged := buf.String() if !strings.Contains(logged, "within-project revision collision") { t.Errorf("expected collision WARN; got log:\n%s", logged) } if !strings.Contains(logged, "project=ProjectA") { t.Errorf("expected project field in log; got:\n%s", logged) } if !strings.Contains(logged, "tracking=123") { t.Errorf("expected tracking field in log; got:\n%s", logged) } // Chronological winner still wins. target, ok := Resolve(idx, "ProjectA", "123_A.html") if !ok { t.Fatalf("Resolve failed") } if !contains(target, "2025-01-01_Early") { t.Errorf("got %q, want path under 2025-01-01_Early/ (chronological winner)", target) } } // Re-indexing the same transmittal folder (e.g. via the watcher) must NOT // trip the collision detector — same path is a no-op, not a conflict. func TestRecordFile_ReindexSamePathNoCollisionLog(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_A (IFR) - Title.pdf", ) var buf bytes.Buffer prev := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) defer slog.SetDefault(prev) idx, err := BuildIndex(root) if err != nil { t.Fatalf("BuildIndex: %v", err) } // Simulate the watcher firing again on the same transmittal folder. transmittalAbs := filepath.Join(root, "ProjectA", "2025-01-01_T1 (IFR) - Title") if err := idx.UpdateFromDir(root, transmittalAbs); err != nil { t.Fatalf("UpdateFromDir: %v", err) } if strings.Contains(buf.String(), "within-project revision collision") { t.Errorf("re-index should not log collision; got:\n%s", buf.String()) } } // Rebuild atomically replaces the index contents with a fresh walk of fsRoot. // Files added after the initial build but before Rebuild are picked up; // files removed are dropped. Counts and duration come back for the caller. func TestRebuild_PicksUpAddsAndDrops(t *testing.T) { root := t.TempDir() mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "100_A (IFR) - Title.pdf", ) idx, err := BuildIndex(root) if err != nil { t.Fatalf("BuildIndex: %v", err) } if _, ok := Resolve(idx, "ProjectA", "100.html"); !ok { t.Fatalf("pre-rebuild: 100 should resolve") } if _, ok := Resolve(idx, "ProjectA", "200.html"); ok { t.Fatalf("pre-rebuild: 200 should NOT resolve yet") } // Add a second tracking number into a NEW transmittal folder (the // inotify watcher would miss this on SMB; Rebuild must catch it). mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (IFR) - Other", "200_A (IFR) - Other.pdf", ) dur, projects, tracking, err := idx.Rebuild(root) if err != nil { t.Fatalf("Rebuild: %v", err) } if dur <= 0 { t.Errorf("Rebuild duration = %v, want > 0", dur) } if projects != 1 { t.Errorf("projects = %d, want 1", projects) } if tracking != 2 { t.Errorf("tracking = %d, want 2 (100, 200)", tracking) } if _, ok := Resolve(idx, "ProjectA", "100.html"); !ok { t.Errorf("post-rebuild: 100 lost") } if _, ok := Resolve(idx, "ProjectA", "200.html"); !ok { t.Errorf("post-rebuild: 200 not picked up") } // Remove the original transmittal entirely; Rebuild should drop it. if err := os.RemoveAll(filepath.Join(root, "ProjectA/2025-01-01_T1 (IFR) - Title")); err != nil { t.Fatalf("RemoveAll: %v", err) } if _, _, _, err := idx.Rebuild(root); err != nil { t.Fatalf("Rebuild: %v", err) } if _, ok := Resolve(idx, "ProjectA", "100.html"); ok { t.Errorf("post-second-rebuild: 100 should be gone") } if _, ok := Resolve(idx, "ProjectA", "200.html"); !ok { t.Errorf("post-second-rebuild: 200 lost") } } // projectOf is the canonical place to derive the project key. Validate the // edge cases so the contract doesn't drift silently. func TestProjectOf(t *testing.T) { cases := []struct { path string want string }{ {"ProjectA/2025-01-01_T1/100_A.pdf", "ProjectA"}, {"ProjectA/sub/deep/file.pdf", "ProjectA"}, // Files at the root with no slash have no project. {"top-level-loose-file.pdf", ""}, {"", ""}, // Defensive: leading slash should never reach this helper, but if it // did, we'd return "" rather than picking up an empty leading segment. {"/ProjectA/file", ""}, } for _, c := range cases { got := projectOf(c.path) if got != c.want { t.Errorf("projectOf(%q) = %q, want %q", c.path, got, c.want) } } } func contains(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return true } } return false } func sortedKeys(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func entryNames(entries []Entry) []string { out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.URLName) } sort.Strings(out) return out }