package handler import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strconv" "testing" "time" "vctp/db" "vctp/db/queries" "vctp/server/models" "github.com/jmoiron/sqlx" _ "modernc.org/sqlite" ) type snapshotRepairTestDatabase struct { dbConn *sqlx.DB logger *slog.Logger } func (d *snapshotRepairTestDatabase) DB() *sqlx.DB { return d.dbConn } func (d *snapshotRepairTestDatabase) Queries() db.Querier { return queries.New(d.dbConn.DB) } func (d *snapshotRepairTestDatabase) Logger() *slog.Logger { if d.logger != nil { return d.logger } return slog.New(slog.NewTextHandler(io.Discard, nil)) } func (d *snapshotRepairTestDatabase) Close() error { return d.dbConn.Close() } func newSnapshotRepairTestDB(t *testing.T) *sqlx.DB { t.Helper() dbConn, err := sqlx.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open sqlite test db: %v", err) } t.Cleanup(func() { _ = dbConn.Close() }) return dbConn } func TestSnapshotRepairSuite_RebuildsRegistryTotalsAndLifecycle(t *testing.T) { ctx := context.Background() dbConn := newSnapshotRepairTestDB(t) logger := newTestLogger() h := &Handler{ Logger: logger, Database: &snapshotRepairTestDatabase{dbConn: dbConn, logger: logger}, } dayStart := time.Date(2026, time.March, 16, 0, 0, 0, 0, time.UTC) hourlyTs := dayStart.Add(2 * time.Hour).Unix() hourlyTable := fmt.Sprintf("inventory_hourly_%d", hourlyTs) dailyTable := fmt.Sprintf("inventory_daily_summary_%s", dayStart.Format("20060102")) monthlyTable := fmt.Sprintf("inventory_monthly_summary_%s", dayStart.Format("200601")) if err := db.EnsureSnapshotTable(ctx, dbConn, hourlyTable); err != nil { t.Fatalf("failed to ensure hourly table: %v", err) } if err := db.EnsureSummaryTable(ctx, dbConn, dailyTable); err != nil { t.Fatalf("failed to ensure daily summary table: %v", err) } if err := db.EnsureSummaryTable(ctx, dbConn, monthlyTable); err != nil { t.Fatalf("failed to ensure monthly summary table: %v", err) } if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(` INSERT INTO %s ( "Name","Vcenter","VmId","VmUuid","CreationTime","DeletionTime","ResourcePool","Datacenter","Cluster","Folder", "ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder","SnapshotTime" ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `, hourlyTable), "vm-a", "vc-a", "vm-a", "uuid-a", dayStart.Add(-24*time.Hour).Unix(), int64(0), "Tin", "dc-a", "cluster-a", "/prod", 100.0, int64(2), int64(8), "FALSE", "TRUE", "FALSE", hourlyTs, ); err != nil { t.Fatalf("failed to seed hourly table: %v", err) } if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(` INSERT INTO %s ( "Name","Vcenter","VmId","VmUuid","CreationTime","DeletionTime","ResourcePool","Datacenter","Cluster","Folder", "ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder","SnapshotTime","SamplesPresent", "AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","AvgIsPresent","PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct","Tin","Bronze","Silver","Gold" ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `, dailyTable), "vm-a", "vc-a", "vm-a", "uuid-a", int64(0), int64(0), "Tin", "dc-a", "cluster-a", "/prod", 100.0, int64(2), int64(8), "FALSE", "TRUE", "FALSE", int64(0), int64(1), 2.0, 8.0, 100.0, 1.0, 100.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ); err != nil { t.Fatalf("failed to seed daily summary table: %v", err) } if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(` INSERT INTO %s ( "Name","Vcenter","VmId","VmUuid","CreationTime","DeletionTime","ResourcePool","Datacenter","Cluster","Folder", "ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder","SnapshotTime","SamplesPresent", "AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","AvgIsPresent","PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct","Tin","Bronze","Silver","Gold" ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `, monthlyTable), "vm-a", "vc-a", "vm-a", "uuid-a", int64(0), int64(0), "Tin", "dc-a", "cluster-a", "/prod", 100.0, int64(2), int64(8), "FALSE", "TRUE", "FALSE", dayStart.Unix(), int64(1), 2.0, 8.0, 100.0, 1.0, 100.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ); err != nil { t.Fatalf("failed to seed monthly summary table: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/snapshots/repair/all", nil) rr := httptest.NewRecorder() h.SnapshotRepairSuite(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String()) } var payload models.SnapshotRepairSuiteResponse if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to decode response: %v", err) } if payload.Status != "OK" { t.Fatalf("unexpected repair suite status: %q", payload.Status) } dailyRepaired, err := strconv.Atoi(payload.DailyRepaired) if err != nil { t.Fatalf("failed to parse daily_repaired: %v", err) } if dailyRepaired < 1 { t.Fatalf("expected at least one daily table repaired, got %d", dailyRepaired) } monthlyRefined, err := strconv.Atoi(payload.MonthlyRefined) if err != nil { t.Fatalf("failed to parse monthly_refined: %v", err) } if monthlyRefined < 1 { t.Fatalf("expected at least one monthly table refined, got %d", monthlyRefined) } monthlyFailed, err := strconv.Atoi(payload.MonthlyFailed) if err != nil { t.Fatalf("failed to parse monthly_failed: %v", err) } if monthlyFailed != 0 { t.Fatalf("expected monthly_failed=0, got %d", monthlyFailed) } assertSnapshotRegistryTypeCount(t, ctx, dbConn, "hourly", 1) assertSnapshotRegistryTypeCount(t, ctx, dbConn, "daily", 1) assertSnapshotRegistryTypeCount(t, ctx, dbConn, "monthly", 1) var totalsRows int if err := dbConn.GetContext(ctx, &totalsRows, `SELECT COUNT(1) FROM vcenter_totals WHERE "Vcenter" = ?`, "vc-a"); err != nil { t.Fatalf("failed to query vcenter_totals: %v", err) } if totalsRows < 1 { t.Fatalf("expected vcenter_totals to be backfilled, got %d rows", totalsRows) } var dailySnapshotTime int64 if err := dbConn.GetContext(ctx, &dailySnapshotTime, fmt.Sprintf(`SELECT COALESCE("SnapshotTime",0) FROM %s WHERE "Vcenter" = ? AND "VmId" = ?`, dailyTable), "vc-a", "vm-a"); err != nil { t.Fatalf("failed to query repaired daily snapshot time: %v", err) } if dailySnapshotTime == 0 { t.Fatal("expected repaired daily summary SnapshotTime to be backfilled") } } func assertSnapshotRegistryTypeCount(t *testing.T, ctx context.Context, dbConn *sqlx.DB, snapshotType string, want int) { t.Helper() var got int if err := dbConn.GetContext(ctx, &got, `SELECT COUNT(1) FROM snapshot_registry WHERE snapshot_type = ?`, snapshotType); err != nil { t.Fatalf("failed to query snapshot_registry for type %s: %v", snapshotType, err) } if got != want { t.Fatalf("unexpected snapshot_registry count for %s: got %d want %d", snapshotType, got, want) } }