package store import ( "context" "errors" "fmt" "path/filepath" "testing" "time" "yovision/sense/internal/device" ) func TestDefaultVideoQuotaRejectsSeventeenthChannel(t *testing.T) { t.Parallel() store := openTestStore(t) ctx := context.Background() if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-a", ID: "site-a", Name: "Site A"}); err != nil { t.Fatal(err) } for index := 1; index <= device.DefaultVideoChannels; index++ { if err := store.CreateDevice(ctx, videoDevice(index, "tenant-a", "site-a")); err != nil { t.Fatalf("create channel %d: %v", index, err) } } err := store.CreateDevice(ctx, videoDevice(17, "tenant-a", "site-a")) var quotaError *device.QuotaExceededError if !errors.As(err, "aError) || quotaError.Limit != 16 { t.Fatalf("expected 16-channel quota error, got %v", err) } } func TestConfiguredMaximumAccepts128AndRejects129(t *testing.T) { t.Parallel() store := openTestStore(t) ctx := context.Background() if err := store.EnsureSite(ctx, device.Site{ TenantID: "tenant-b", ID: "site-b", Name: "Site B", MaxVideoChannels: 128, }); err != nil { t.Fatal(err) } for index := 1; index <= 128; index++ { if err := store.CreateDevice(ctx, videoDevice(index, "tenant-b", "site-b")); err != nil { t.Fatalf("create channel %d: %v", index, err) } } err := store.CreateDevice(ctx, videoDevice(129, "tenant-b", "site-b")) var quotaError *device.QuotaExceededError if !errors.As(err, "aError) || quotaError.Limit != 128 { t.Fatalf("expected 128-channel quota error, got %v", err) } } func TestSiteRejectsCapacityAbove128(t *testing.T) { t.Parallel() store := openTestStore(t) err := store.EnsureSite(context.Background(), device.Site{ TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 129, }) if err == nil { t.Fatal("expected capacity 129 to be rejected") } } func TestNonVideoDeviceDoesNotConsumeVideoQuota(t *testing.T) { t.Parallel() store := openTestStore(t) ctx := context.Background() if err := store.EnsureSite(ctx, device.Site{ TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 1, }); err != nil { t.Fatal(err) } radar := device.Device{ ID: "radar-1", TenantID: "tenant", SiteID: "site", SerialNumber: "radar-1", Name: "Radar", Modality: device.ModalityRadar, Capabilities: []device.Capability{device.CapabilityTelemetry}, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending, } if err := store.CreateDevice(ctx, radar); err != nil { t.Fatal(err) } if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil { t.Fatalf("video channel should remain available: %v", err) } } func TestEnablingSeventeenthVideoDeviceIsRejected(t *testing.T) { t.Parallel() store := openTestStore(t) ctx := context.Background() if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-c", ID: "site-c", Name: "Site C"}); err != nil { t.Fatal(err) } for index := 1; index <= 17; index++ { value := videoDevice(index, "tenant-c", "site-c") if index == 17 { value.DesiredState = device.DesiredDisabled } if err := store.CreateDevice(ctx, value); err != nil { t.Fatalf("create device %d: %v", index, err) } } err := store.SetDesiredState(ctx, "camera-017", device.DesiredEnabled) var quotaError *device.QuotaExceededError if !errors.As(err, "aError) || quotaError.Limit != 16 { t.Fatalf("expected enable to enforce quota, got %v", err) } value, err := store.GetDevice(ctx, "camera-017") if err != nil { t.Fatal(err) } if value.DesiredState != device.DesiredDisabled { t.Fatal("failed enable must leave the existing desired state unchanged") } } func TestLowerQuotaDoesNotDisableExistingStreams(t *testing.T) { t.Parallel() store := openTestStore(t) ctx := context.Background() if err := store.EnsureSite(ctx, device.Site{ TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 2, }); err != nil { t.Fatal(err) } for index := 1; index <= 2; index++ { if err := store.CreateDevice(ctx, videoDevice(index, "tenant-d", "site-d")); err != nil { t.Fatal(err) } } if err := store.EnsureSite(ctx, device.Site{ TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 1, }); err != nil { t.Fatal(err) } for index := 1; index <= 2; index++ { value, err := store.GetDevice(ctx, fmt.Sprintf("camera-%03d", index)) if err != nil { t.Fatal(err) } if value.DesiredState != device.DesiredEnabled { t.Fatalf("existing channel %d was disabled", index) } } } func TestConvergenceSnapshotAndRuntimeReconcileRequest(t *testing.T) { t.Parallel() store := openTestStore(t) ctx := context.Background() if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil { t.Fatal(err) } if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil { t.Fatal(err) } now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) if err := store.MarkReconciled(ctx, "camera-001", 1, now); err != nil { t.Fatal(err) } if err := store.UpdateActualState(ctx, "camera-001", device.ActualOnline, now); err != nil { t.Fatal(err) } snapshot, err := store.ConvergenceSnapshot(ctx) if err != nil { t.Fatal(err) } if snapshot.Total != 1 || snapshot.Unconverged != 0 { t.Fatalf("expected converged snapshot, got %+v", snapshot) } if err := store.RequestReconcile(ctx, "camera-001", now.Add(time.Second)); err != nil { t.Fatal(err) } snapshot, err = store.ConvergenceSnapshot(ctx) if err != nil { t.Fatal(err) } if snapshot.Unconverged != 1 || snapshot.Devices[0].ObservedGeneration != 0 { t.Fatalf("runtime loss must invalidate convergence: %+v", snapshot) } } func openTestStore(t *testing.T) *SQLite { t.Helper() dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sense.db")) store, err := OpenSQLite(context.Background(), dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) return store } func videoDevice(index int, tenantID, siteID string) device.Device { id := fmt.Sprintf("camera-%03d", index) return device.Device{ ID: id, TenantID: tenantID, SiteID: siteID, SerialNumber: id, Name: id, Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule}, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending, EndpointRef: "onvif://" + id, CredentialRef: "secret://" + id, PathName: "sense/" + tenantID + "/" + siteID + "/" + id, } }