package retention import ( "context" "os" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // connectTestDatabase oeffnet die Testdatenbank. func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool { testInstance.Helper() connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL") if connectionString == "" { testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden uebersprungen") } connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second) defer cancelConnect() connectionPool, poolError := pgxpool.New(connectContext, connectionString) if poolError != nil { testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError) } if pingError := connectionPool.Ping(connectContext); pingError != nil { connectionPool.Close() testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError) } testInstance.Cleanup(connectionPool.Close) return connectionPool } // seedRepositoryWithBackups legt ein Repository mit Backups in der Datenbank an. func seedRepositoryWithBackups(testInstance *testing.T, connectionPool *pgxpool.Pool, backupIdentifiers []string) uuid.UUID { testInstance.Helper() backgroundContext := context.Background() uniqueSuffix := uuid.NewString()[:8] var repositoryID uuid.UUID if scanError := connectionPool.QueryRow(backgroundContext, `INSERT INTO repositories (name, location) VALUES ($1, $2) RETURNING id`, "ret-"+uniqueSuffix, "/tmp/ret-"+uniqueSuffix).Scan(&repositoryID); scanError != nil { testInstance.Fatalf("das Repository liess sich nicht eintragen: %v", scanError) } for _, backupIdentifier := range backupIdentifiers { if _, execError := connectionPool.Exec(backgroundContext, `INSERT INTO backups (repository_id, backup_id_in_repository, backup_type, status, manifest_ref, completed_at) VALUES ($1, $2, 'full', 'complete', 'manifests/x.manifest.json', now())`, repositoryID, backupIdentifier); execError != nil { testInstance.Fatalf("das Backup liess sich nicht eintragen: %v", execError) } } testInstance.Cleanup(func() { cleanupContext := context.Background() _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM retention_runs WHERE repository_id = $1`, repositoryID) _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backups WHERE repository_id = $1`, repositoryID) _, _ = connectionPool.Exec(cleanupContext, `DELETE FROM repositories WHERE id = $1`, repositoryID) }) return repositoryID } // TestMarkBackupsDeletedRecordsEveryIdentifier prueft den Vermerk geloeschter Backups. // // Der Test stammt aus einem Fund: Nach einem Aufbewahrungslauf waren im // Repository zwei Backups geloescht, in der Control Plane aber **keines** // vermerkt. Die Liste der Wiederherstellungspunkte zeigte damit Punkte, die es // nicht mehr gab — und der Fehler war unsichtbar, weil er nur protokolliert und // nicht geworfen wird. func TestMarkBackupsDeletedRecordsEveryIdentifier(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) store := NewStore(connectionPool) backupIdentifiers := []string{"backup-alpha", "backup-beta", "backup-gamma"} repositoryIdentifier := seedRepositoryWithBackups(testInstance, connectionPool, backupIdentifiers) deletedIdentifiers := []string{"backup-alpha", "backup-beta"} if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier, deletedIdentifiers, nil, "Aufbewahrungsregel 7 Tage"); markError != nil { testInstance.Fatalf("der Vermerk schlug fehl: %v", markError) } var deletedCount int if scanError := connectionPool.QueryRow(context.Background(), `SELECT count(*) FROM backups WHERE repository_id = $1 AND deleted_at IS NOT NULL`, repositoryIdentifier).Scan(&deletedCount); scanError != nil { testInstance.Fatalf("die Zaehlung schlug fehl: %v", scanError) } if deletedCount != len(deletedIdentifiers) { testInstance.Fatalf("%d von %d Backups wurden als geloescht vermerkt", deletedCount, len(deletedIdentifiers)) } // Der Grund muss mitkommen: Ohne ihn bleibt die Frage „warum ist das Backup // von vorletzter Woche weg?" unbeantwortet. var deletionReason string if scanError := connectionPool.QueryRow(context.Background(), `SELECT COALESCE(deletion_reason, '') FROM backups WHERE repository_id = $1 AND backup_id_in_repository = 'backup-alpha'`, repositoryIdentifier).Scan(&deletionReason); scanError != nil { testInstance.Fatalf("der Grund liess sich nicht lesen: %v", scanError) } if deletionReason != "Aufbewahrungsregel 7 Tage" { testInstance.Errorf("der Grund war %q, erwartet wurde die Aufbewahrungsregel", deletionReason) } // Das dritte Backup bleibt unberuehrt. var remainingCount int if scanError := connectionPool.QueryRow(context.Background(), `SELECT count(*) FROM backups WHERE repository_id = $1 AND deleted_at IS NULL`, repositoryIdentifier).Scan(&remainingCount); scanError != nil { testInstance.Fatalf("die Zaehlung schlug fehl: %v", scanError) } if remainingCount != 1 { testInstance.Errorf("es blieben %d Backups unberuehrt, erwartet wurde 1", remainingCount) } } // TestMarkBackupsDeletedIsIdempotent prueft den zweiten Aufruf. // // Ein bereits vermerktes Backup darf seinen Loeschzeitpunkt nicht verlieren: // Der erste Vermerk ist der richtige. func TestMarkBackupsDeletedIsIdempotent(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) store := NewStore(connectionPool) repositoryIdentifier := seedRepositoryWithBackups(testInstance, connectionPool, []string{"backup-solo"}) if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier, []string{"backup-solo"}, nil, "erster Lauf"); markError != nil { testInstance.Fatalf("der erste Vermerk schlug fehl: %v", markError) } var firstDeletionTime time.Time if scanError := connectionPool.QueryRow(context.Background(), `SELECT deleted_at FROM backups WHERE repository_id = $1`, repositoryIdentifier).Scan(&firstDeletionTime); scanError != nil { testInstance.Fatalf("der Zeitpunkt liess sich nicht lesen: %v", scanError) } if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier, []string{"backup-solo"}, nil, "zweiter Lauf"); markError != nil { testInstance.Fatalf("der zweite Vermerk schlug fehl: %v", markError) } var secondDeletionTime time.Time var deletionReason string if scanError := connectionPool.QueryRow(context.Background(), `SELECT deleted_at, COALESCE(deletion_reason, '') FROM backups WHERE repository_id = $1`, repositoryIdentifier).Scan(&secondDeletionTime, &deletionReason); scanError != nil { testInstance.Fatalf("der Zeitpunkt liess sich nicht lesen: %v", scanError) } if !secondDeletionTime.Equal(firstDeletionTime) { testInstance.Error("der zweite Aufruf hat den Loeschzeitpunkt ueberschrieben") } if deletionReason != "erster Lauf" { testInstance.Errorf("der Grund wurde auf %q ueberschrieben", deletionReason) } } // TestMarkBackupsDeletedIgnoresEmptyList prueft den Aufruf ohne Kennungen. func TestMarkBackupsDeletedIgnoresEmptyList(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) store := NewStore(connectionPool) repositoryIdentifier := seedRepositoryWithBackups(testInstance, connectionPool, []string{"backup-eins"}) if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier, nil, nil, "nichts"); markError != nil { testInstance.Fatalf("der leere Aufruf schlug fehl: %v", markError) } var deletedCount int if scanError := connectionPool.QueryRow(context.Background(), `SELECT count(*) FROM backups WHERE repository_id = $1 AND deleted_at IS NOT NULL`, repositoryIdentifier).Scan(&deletedCount); scanError != nil { testInstance.Fatalf("die Zaehlung schlug fehl: %v", scanError) } if deletedCount != 0 { testInstance.Error("ein leerer Aufruf hat Backups als geloescht vermerkt") } }