package sitemap_test import ( "context" "slices" "sync" "testing" "time" "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/summercms/modules/pact" ) // rebuildResult is the outcome of one classes.Rebuild run in a goroutine. type rebuildResult struct { res classes.RebuildResult err error } // waitFor polls cond until it holds or the deadline passes. func waitFor(t *testing.T, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(15 * time.Second) for !cond() { if time.Now().After(deadline) { t.Fatalf("timed out waiting for %s", what) } time.Sleep(20 * time.Millisecond) } } // TestConcurrentRebuilds pins L2 WR-02 (D-30, P33-63): a second rebuild // waits on the rebuild's advisory lock while the first one collects, so it // collects only after the first has committed; a record published while // the first rebuild ran is listed afterwards and never sent to IndexNow as // removed. func TestConcurrentRebuilds(t *testing.T) { e := bootIndex(t, filterPluginID) mod := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) contentStore.put(pageType, page1(1, "a", mod)) if _, err := classes.Rebuild(t.Context(), e.app); err != nil { t.Fatal(err) } entered, release := make(chan struct{}), make(chan struct{}) var once sync.Once theFilter.setHook(t, func() { first := false once.Do(func() { first = true; close(entered) }) if first { <-release } }) run := func() <-chan rebuildResult { out := make(chan rebuildResult, 1) go func() { res, err := classes.Rebuild(context.Background(), e.app) out <- rebuildResult{res, err} }() return out } a := run() select { case <-entered: case <-time.After(15 * time.Second): t.Fatal("rebuild A never asked the filter") } contentStore.put(pageType, page1(2, "x", mod)) b := run() waitFor(t, "rebuild B to wait on the advisory lock", func() bool { var waiting int64 e.gdb.Raw(`SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted`).Scan(&waiting) return waiting > 0 }) close(release) ra, rb := <-a, <-b if ra.err != nil || rb.err != nil { t.Fatalf("rebuilds: %v, %v", ra.err, rb.err) } var reason []string e.gdb.Raw(`SELECT excluded_reason FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '2'`, pageType).Scan(&reason) if len(reason) != 1 || reason[0] != "" { t.Fatalf("record x after both rebuilds = %v, want listed", reason) } x := "https://api.example.test/pages/x" if slices.Contains(ra.res.Removed, x) || slices.Contains(rb.res.Removed, x) { t.Fatalf("x sent as removed: A %v, B %v", ra.res.Removed, rb.res.Removed) } if !slices.Contains(rb.res.Added, x) { t.Fatalf("rebuild B added %v, want x", rb.res.Added) } } // TestRebuildKeepsMovedLastmod pins L2 WR-03 (D-30): a lastmod that // RecordSaved moves while a rebuild runs stays, also when the same rebuild // rewrites the row for another change (its title). func TestRebuildKeepsMovedLastmod(t *testing.T) { e := bootIndex(t) old := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) contentStore.put(pageType, page1(1, "a", old)) if _, err := classes.Rebuild(t.Context(), e.app); err != nil { t.Fatal(err) } renamed := page1(1, "a", old) renamed.Locales[0].Title = "Page a, renamed" contentStore.put(pageType, renamed) lastmod := func() time.Time { var at time.Time e.gdb.Raw(`SELECT lastmod FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '1'`, pageType).Scan(&at) return at } saved := make(chan error, 1) ctx := classes.WithAfterSlotRead(t.Context(), func() { go func() { saved <- e.app.Events.Fire(context.Background(), &pact.RecordSaved{Model: pageModel, ID: "1", Changes: []pact.RecordChange{{Attribute: "title"}}}) }() waitFor(t, "RecordSaved to move the lastmod", func() bool { return !lastmod().Equal(old) }) }) if _, err := classes.Rebuild(ctx, e.app); err != nil { t.Fatal(err) } if err := <-saved; err != nil { t.Fatal(err) } if got := lastmod(); got.Equal(old) { t.Fatal("the rebuild wrote the lastmod it read back over the moved one") } var title string e.gdb.Raw(`SELECT title FROM golem15_sitemap_urls WHERE type_key = ? AND record_id = '1'`, pageType).Scan(&title) if title != "Page a, renamed" { t.Fatalf("title = %q, want the rebuild's change", title) } }