package reconcile import ( "context" "errors" "path/filepath" "testing" "time" "yovision/sense/internal/device" "yovision/sense/internal/onvif" "yovision/sense/internal/store" ) type recordingMedia struct { calls int changed int paths map[string]string } func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (bool, error) { m.calls++ if m.paths == nil { m.paths = make(map[string]string) } if m.paths[name] == source { return false, nil } m.paths[name] = source m.changed++ return true, nil } func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) { t.Parallel() repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db")) createReconcileDevice(t, repository) discovery := onvif.NewFake(map[string]onvif.FakeScenario{ "onvif://camera-1": {Result: onvif.ProbeResult{ Profiles: []onvif.Profile{{Token: "main", Name: "Main", VideoEncoder: true}}, StreamURI: "rtsp://media.invalid/camera-1", }}, }) media := &recordingMedia{} reconciler := New(repository, discovery, media) reconciler.now = func() time.Time { return time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) } if err := reconciler.RunOnce(context.Background()); err != nil { t.Fatal(err) } if err := reconciler.RunOnce(context.Background()); err != nil { t.Fatal(err) } if media.calls != 1 || media.changed != 1 { t.Fatalf("converged generation should not repeat: calls=%d changed=%d", media.calls, media.changed) } } func TestBackoffSurvivesStoreRestart(t *testing.T) { t.Parallel() databasePath := filepath.Join(t.TempDir(), "sense.db") repository := openRepository(t, databasePath) createReconcileDevice(t, repository) failingDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{ "onvif://camera-1": {ProbeError: onvif.ErrorAuthentication}, }) media := &recordingMedia{} initialTime := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) first := New(repository, failingDiscovery, media) first.now = func() time.Time { return initialTime } err := first.RunOnce(context.Background()) if err == nil || onvif.CodeOf(err) != onvif.ErrorAuthentication { t.Fatalf("expected authentication failure, got %v", err) } if err := repository.Close(); err != nil { t.Fatal(err) } reopened, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(databasePath)) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = reopened.Close() }) successDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{ "onvif://camera-1": {Result: onvif.ProbeResult{ Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}}, StreamURI: "rtsp://media.invalid/camera-1", }}, }) afterRestart := New(reopened, successDiscovery, media) afterRestart.now = func() time.Time { return initialTime.Add(500 * time.Millisecond) } if err := afterRestart.RunOnce(context.Background()); err != nil { t.Fatal(err) } if media.calls != 0 { t.Fatal("backoff window must survive restart") } afterRestart.now = func() time.Time { return initialTime.Add(time.Second) } if err := afterRestart.RunOnce(context.Background()); err != nil { t.Fatal(err) } if media.calls != 1 { t.Fatal("device must retry when persisted backoff expires") } } func TestCancellationDoesNotPersistFailure(t *testing.T) { t.Parallel() repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db")) createReconcileDevice(t, repository) discovery := onvif.NewFake(map[string]onvif.FakeScenario{ "onvif://camera-1": { DelayMillis: 100, Result: onvif.ProbeResult{ Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}}, StreamURI: "rtsp://media.invalid/camera-1", }, }, }) reconciler := New(repository, discovery, &recordingMedia{}) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) defer cancel() err := reconciler.RunOnce(ctx) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("expected cancellation, got %v", err) } candidates, err := repository.ListDueReconcile(context.Background(), time.Now().Add(time.Hour), 10) if err != nil { t.Fatal(err) } if len(candidates) != 1 || candidates[0].FailureCount != 0 { t.Fatalf("cancellation must not consume retry budget: %+v", candidates) } } func openRepository(t *testing.T, path string) *store.SQLite { t.Helper() repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path)) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = repository.Close() }) return repository } func createReconcileDevice(t *testing.T, repository *store.SQLite) { t.Helper() ctx := context.Background() if err := repository.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil { t.Fatal(err) } if err := repository.CreateDevice(ctx, device.Device{ ID: "camera-1", TenantID: "tenant", SiteID: "site", SerialNumber: "camera-1", Name: "Camera 1", Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture}, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending, EndpointRef: "onvif://camera-1", CredentialRef: "secret://camera-1", PathName: "camera-1", }); err != nil { t.Fatal(err) } }