package store import ( "context" "errors" "testing" "time" ) func TestSourceGroupCRUDAndVersionBump(t *testing.T) { t.Parallel() s := openTestStore(t) ctx := context.Background() hostID := makeSchedHost(t, s) keepLast := 7 g := SourceGroup{ ID: "01HSGRP00000000000000001", HostID: hostID, Name: "default", Includes: []string{"/etc", "/home"}, Excludes: []string{"*.tmp"}, RetentionPolicy: RetentionPolicy{KeepLast: &keepLast}, } if err := s.CreateSourceGroup(ctx, &g); err != nil { t.Fatalf("create: %v", err) } if g.RetryMax != 3 || g.RetryBackoffSeconds != 60 { t.Fatalf("retry defaults not applied: %+v", g) } v, _ := s.GetHostScheduleVersion(ctx, hostID) if v != 1 { t.Fatalf("version after create: got %d, want 1", v) } // Round-trip. got, err := s.GetSourceGroup(ctx, hostID, g.ID) if err != nil { t.Fatalf("get: %v", err) } if got.Name != "default" || len(got.Includes) != 2 || len(got.Excludes) != 1 { t.Fatalf("round-trip: %+v", got) } if got.RetentionPolicy.KeepLast == nil || *got.RetentionPolicy.KeepLast != 7 { t.Fatalf("retention round-trip: %+v", got.RetentionPolicy) } // By name. byName, err := s.GetSourceGroupByName(ctx, hostID, "default") if err != nil || byName.ID != g.ID { t.Fatalf("get by name: err=%v got=%v", err, byName) } // Update — rename + new retention. Version bumps. keepDaily := 14 g.Name = "system" g.RetentionPolicy = RetentionPolicy{KeepDaily: &keepDaily} if err := s.UpdateSourceGroup(ctx, &g); err != nil { t.Fatal(err) } v, _ = s.GetHostScheduleVersion(ctx, hostID) if v != 2 { t.Fatalf("version after update: got %d, want 2", v) } got, _ = s.GetSourceGroup(ctx, hostID, g.ID) if got.Name != "system" || got.RetentionPolicy.KeepLast != nil || got.RetentionPolicy.KeepDaily == nil { t.Fatalf("update did not persist: %+v", got) } // Conflict cache (no version bump). if err := s.SetSourceGroupConflict(ctx, g.ID, "hourly"); err != nil { t.Fatal(err) } got, _ = s.GetSourceGroup(ctx, hostID, g.ID) if got.ConflictDimension != "hourly" { t.Fatalf("conflict not cached: %q", got.ConflictDimension) } v2, _ := s.GetHostScheduleVersion(ctx, hostID) if v2 != v { t.Fatalf("conflict cache should not bump version: %d → %d", v, v2) } // List. list, _ := s.ListSourceGroupsByHost(ctx, hostID) if len(list) != 1 || list[0].ID != g.ID { t.Fatalf("list: %v", list) } // Delete bumps version. if err := s.DeleteSourceGroup(ctx, hostID, g.ID); err != nil { t.Fatal(err) } v3, _ := s.GetHostScheduleVersion(ctx, hostID) if v3 != 3 { t.Fatalf("version after delete: got %d, want 3", v3) } if err := s.DeleteSourceGroup(ctx, hostID, g.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("delete after delete: want ErrNotFound, got %v", err) } } func TestSourceGroupNameUniquePerHost(t *testing.T) { t.Parallel() s := openTestStore(t) ctx := context.Background() hostID := makeSchedHost(t, s) if err := s.CreateSourceGroup(ctx, &SourceGroup{ ID: "01HUNIQGRP00000000000001", HostID: hostID, Name: "shared", }); err != nil { t.Fatal(err) } err := s.CreateSourceGroup(ctx, &SourceGroup{ ID: "01HUNIQGRP00000000000002", HostID: hostID, Name: "shared", }) if err == nil { t.Fatal("expected unique-constraint error on duplicate name within host") } } func TestRepoMaintenanceDefaultsAndUpdate(t *testing.T) { t.Parallel() s := openTestStore(t) ctx := context.Background() hostID := makeSchedHost(t, s) if _, err := s.GetRepoMaintenance(ctx, hostID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound before seed, got %v", err) } if err := s.CreateDefaultRepoMaintenance(ctx, hostID); err != nil { t.Fatal(err) } m, err := s.GetRepoMaintenance(ctx, hostID) if err != nil { t.Fatal(err) } if m.ForgetCron != "0 3 * * *" || !m.ForgetEnabled { t.Fatalf("forget defaults: %+v", m) } if m.PruneCron != "0 4 * * 0" || m.CheckSubsetPct != 5 { t.Fatalf("other defaults: %+v", m) } m.ForgetCron = "0 4 * * *" m.PruneEnabled = false m.CheckSubsetPct = 10 if err := s.UpdateRepoMaintenance(ctx, m); err != nil { t.Fatal(err) } m2, _ := s.GetRepoMaintenance(ctx, hostID) if m2.ForgetCron != "0 4 * * *" || m2.PruneEnabled || m2.CheckSubsetPct != 10 { t.Fatalf("update did not persist: %+v", m2) } // CreateDefaultRepoMaintenance is idempotent (INSERT OR IGNORE). if err := s.CreateDefaultRepoMaintenance(ctx, hostID); err != nil { t.Fatal(err) } m3, _ := s.GetRepoMaintenance(ctx, hostID) if m3.ForgetCron != "0 4 * * *" { t.Fatalf("INSERT OR IGNORE clobbered existing row: %+v", m3) } } func TestPendingRunQueue(t *testing.T) { t.Parallel() s := openTestStore(t) ctx := context.Background() hostID := makeSchedHost(t, s) gid := makeGroup(t, s, hostID, "default", "01HPENDGRP00000000000001") schedID := "01HPENDSCHED0000000000001" if err := s.CreateSchedule(ctx, &Schedule{ ID: schedID, HostID: hostID, CronExpr: "@hourly", Enabled: true, SourceGroupIDs: []string{gid}, }); err != nil { t.Fatal(err) } now := time.Now().UTC() if err := s.EnqueuePendingRun(ctx, &PendingRun{ ID: "01HPEND00000000000000001", ScheduleID: schedID, SourceGroupID: gid, HostID: hostID, NextAttemptAt: now.Add(-time.Second), // already due ScheduledAt: now.Add(-time.Minute), }); err != nil { t.Fatal(err) } due, err := s.DuePendingRuns(ctx, now, 10) if err != nil { t.Fatal(err) } if len(due) != 1 { t.Fatalf("due: got %d, want 1", len(due)) } if due[0].Attempt != 1 { t.Fatalf("attempt: %d", due[0].Attempt) } // Bump. next := now.Add(2 * time.Minute) if err := s.BumpPendingRunAttempt(ctx, due[0].ID, next, "agent offline"); err != nil { t.Fatal(err) } // No longer due at `now`. due, _ = s.DuePendingRuns(ctx, now, 10) if len(due) != 0 { t.Fatalf("should not be due yet: %v", due) } // Due at `next`. due, _ = s.DuePendingRuns(ctx, next, 10) if len(due) != 1 || due[0].Attempt != 2 || due[0].LastError != "agent offline" { t.Fatalf("after bump: %+v", due) } if err := s.DeletePendingRun(ctx, due[0].ID); err != nil { t.Fatal(err) } due, _ = s.DuePendingRuns(ctx, next, 10) if len(due) != 0 { t.Fatalf("after delete: %v", due) } }