package jobs import ( "errors" "strings" "testing" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/scheduler" ) // buildValidJob liefert einen brauchbaren Auftrag als Ausgangspunkt. func buildValidJob() Job { return Job{ Name: "Nächtliche Sicherung", Status: JobStatusActive, Priority: scheduler.PriorityNormal, Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2}, RepositoryID: uuid.New(), Sources: []JobSource{ {SourceType: SourceTypeFilesystem, SourceID: "/daten", SourceName: "Dateiserver"}, }, MaximumConcurrency: 1, RetryPolicy: scheduler.DefaultRetryPolicy(), } } // TestValidJobIsAccepted prüft den Normalfall. func TestValidJobIsAccepted(testInstance *testing.T) { validJob := buildValidJob() if validationError := validJob.Validate(); validationError != nil { testInstance.Fatalf("ein gültiger Auftrag wurde abgelehnt: %v", validationError) } } // TestJobWithoutSourceIsRejected ist der wichtigste Prüftest. // // Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern — die // gefährlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht. func TestJobWithoutSourceIsRejected(testInstance *testing.T) { jobWithoutSource := buildValidJob() jobWithoutSource.Sources = nil validationError := jobWithoutSource.Validate() if !errors.Is(validationError, ErrInvalidJob) { testInstance.Fatalf("ein Auftrag ohne Quelle wurde angenommen: %v", validationError) } if !strings.Contains(validationError.Error(), "quelle") { testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", validationError) } } // TestDuplicateSourceIsRejected prüft die doppelte Quelle. // // Sie verdoppelte die Datenmenge, ohne mehr zu sichern. func TestDuplicateSourceIsRejected(testInstance *testing.T) { jobWithDuplicate := buildValidJob() jobWithDuplicate.Sources = append(jobWithDuplicate.Sources, JobSource{ SourceType: SourceTypeFilesystem, SourceID: "/daten", }) if validationError := jobWithDuplicate.Validate(); validationError == nil { testInstance.Fatal("eine doppelt aufgeführte Quelle wurde angenommen") } // Dieselbe Kennung bei anderer Quellart ist dagegen zulässig. jobWithDistinctTypes := buildValidJob() jobWithDistinctTypes.Sources = append(jobWithDistinctTypes.Sources, JobSource{ SourceType: SourceTypeLinuxSystem, SourceID: "/daten", }) if validationError := jobWithDistinctTypes.Validate(); validationError != nil { testInstance.Errorf("verschiedene Quellarten mit gleicher Kennung wurden abgelehnt: %v", validationError) } } // TestRecoveryPointObjectiveMustMatchSchedule ist der Test, der eine stille // Fehlannahme verhindert. // // Ein RPO, den der Zeitplan nicht einhalten kann, ist keine Kleinigkeit: Der // Betreiber glaubt, höchstens vier Stunden zu verlieren, während der Auftrag // nur täglich läuft. Das gehört beim Anlegen gesagt, nicht nach dem Ausfall. func TestRecoveryPointObjectiveMustMatchSchedule(testInstance *testing.T) { impossibleJob := buildValidJob() impossibleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2} impossibleJob.RecoveryPointObjective = 4 * time.Hour validationError := impossibleJob.Validate() if validationError == nil { testInstance.Fatal("ein täglicher Zeitplan mit 4-Stunden-RPO wurde angenommen") } if !strings.Contains(validationError.Error(), "wiederherstellungspunkt") { testInstance.Errorf("die Fehlermeldung erklärt den Widerspruch nicht: %v", validationError) } // Ein stündlicher Plan hält vier Stunden dagegen mühelos ein. feasibleJob := buildValidJob() feasibleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly} feasibleJob.RecoveryPointObjective = 4 * time.Hour if validationError := feasibleJob.Validate(); validationError != nil { testInstance.Errorf("ein einhaltbarer Wiederherstellungspunkt wurde abgelehnt: %v", validationError) } } // TestManualScheduleCannotPromiseRecoveryPoint prüft den Sonderfall. func TestManualScheduleCannotPromiseRecoveryPoint(testInstance *testing.T) { manualJob := buildValidJob() manualJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual} manualJob.RecoveryPointObjective = 24 * time.Hour if validationError := manualJob.Validate(); validationError == nil { testInstance.Fatal("ein Zeitplan auf Anforderung versprach einen Wiederherstellungspunkt") } } // TestInvalidJobsAreRejected prüft die übrigen Prüfungen. func TestInvalidJobsAreRejected(testInstance *testing.T) { invalidJobs := map[string]func(*Job){ "ohne Namen": func(job *Job) { job.Name = " " }, "Name zu lang": func(job *Job) { job.Name = strings.Repeat("x", 201) }, "ohne Repository": func(job *Job) { job.RepositoryID = uuid.Nil }, "unbekannte Quellart": func(job *Job) { job.Sources[0].SourceType = "vmware_vm" }, "Quelle ohne Kennung": func(job *Job) { job.Sources[0].SourceID = "" }, "unbekannte Dringlichkeit": func(job *Job) { job.Priority = "sehr-dringend" }, "kaputter Zeitplan": func(job *Job) { job.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 99} }, "keine Nebenläufigkeit": func(job *Job) { job.MaximumConcurrency = 0 }, "negative Bandbreite": func(job *Job) { job.BandwidthLimitBytesPerSecond = -1 }, "kaputte Wiederholung": func(job *Job) { job.RetryPolicy = scheduler.RetryPolicy{MaximumAttempts: 0} }, } for caseName, applyDefect := range invalidJobs { defectiveJob := buildValidJob() applyDefect(&defectiveJob) if validationError := defectiveJob.Validate(); validationError == nil { testInstance.Errorf("%s: der Auftrag wurde angenommen", caseName) } } } // TestSelfDependencyIsRejected prüft die Selbstabhängigkeit. func TestSelfDependencyIsRejected(testInstance *testing.T) { selfDependentJob := buildValidJob() selfDependentJob.ID = uuid.New() selfDependentJob.DependsOnJobIDs = []uuid.UUID{selfDependentJob.ID} if validationError := selfDependentJob.Validate(); validationError == nil { testInstance.Fatal("ein Auftrag, der von sich selbst abhängt, wurde angenommen") } } // TestSourceReferenceIsUniquePerSource prüft die Kettenkennung. // // Ein Auftrag mit mehreren Quellen erzeugt je Quelle eine eigene Kette: Wären // sie in einer, ließe sich eine einzelne Quelle nicht mehr eigenständig // wiederherstellen. func TestSourceReferenceIsUniquePerSource(testInstance *testing.T) { firstSource := JobSource{SourceType: SourceTypeProxmoxVM, SourceID: "qemu/100"} secondSource := JobSource{SourceType: SourceTypeProxmoxVM, SourceID: "qemu/101"} sameIDOtherType := JobSource{SourceType: SourceTypeFilesystem, SourceID: "qemu/100"} if firstSource.SourceReference() == secondSource.SourceReference() { testInstance.Error("zwei verschiedene Gäste ergaben dieselbe Kettenkennung") } if firstSource.SourceReference() == sameIDOtherType.SourceReference() { testInstance.Error("gleiche Kennung bei anderer Quellart ergab dieselbe Kettenkennung") } } // TestIsRunnableOnlyWhenActive prüft den Ausführungszustand. func TestIsRunnableOnlyWhenActive(testInstance *testing.T) { notRunnableStatuses := []JobStatus{JobStatusPaused, JobStatusDisabled, JobStatusError} for _, jobStatus := range notRunnableStatuses { testJob := buildValidJob() testJob.Status = jobStatus if testJob.IsRunnable() { testInstance.Errorf("ein Auftrag im Zustand %q wurde als ausführbar gemeldet", jobStatus) } } activeJob := buildValidJob() if !activeJob.IsRunnable() { testInstance.Error("ein aktiver Auftrag wurde als nicht ausführbar gemeldet") } }