using System.Text.Json; using StellaOps.Scheduler.Models; namespace StellaOps.Scheduler.Models.Tests; public sealed class ScheduleSerializationTests { [Trait("Category", TestCategories.Unit)] [Fact] public void ScheduleSerialization_IsDeterministicRegardlessOfInputOrdering() { var selectionA = new Selector( SelectorScope.ByNamespace, tenantId: "tenant-alpha", namespaces: new[] { "team-b", "team-a" }, repositories: new[] { "app/service-api", "app/service-web" }, digests: new[] { "sha256:bb", "sha256:aa" }, includeTags: new[] { "prod", "canary" }, labels: new[] { new LabelSelector("env", new[] { "prod", "staging" }), new LabelSelector("app", new[] { "web", "api" }), }, resolvesTags: true); var selectionB = new Selector( scope: SelectorScope.ByNamespace, tenantId: "tenant-alpha", namespaces: new[] { "team-a", "team-b" }, repositories: new[] { "app/service-web", "app/service-api" }, digests: new[] { "sha256:aa", "sha256:bb" }, includeTags: new[] { "canary", "prod" }, labels: new[] { new LabelSelector("app", new[] { "api", "web" }), new LabelSelector("env", new[] { "staging", "prod" }), }, resolvesTags: true); var scheduleA = new Schedule( id: "sch_001", tenantId: "tenant-alpha", name: "Nightly Prod", enabled: true, cronExpression: "0 2 * * *", timezone: "UTC", mode: ScheduleMode.AnalysisOnly, selection: selectionA, onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 7, policyRevision: "policy@42"), notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true), limits: new ScheduleLimits(maxJobs: 1000, ratePerSecond: 25, parallelism: 4), createdAt: DateTimeOffset.Parse("2025-10-18T23:00:00Z"), createdBy: "svc_scheduler", updatedAt: DateTimeOffset.Parse("2025-10-18T23:00:00Z"), updatedBy: "svc_scheduler"); var scheduleB = new Schedule( id: scheduleA.Id, tenantId: scheduleA.TenantId, name: scheduleA.Name, enabled: scheduleA.Enabled, cronExpression: scheduleA.CronExpression, timezone: scheduleA.Timezone, mode: scheduleA.Mode, selection: selectionB, onlyIf: scheduleA.OnlyIf, notify: scheduleA.Notify, limits: scheduleA.Limits, createdAt: scheduleA.CreatedAt, createdBy: scheduleA.CreatedBy, updatedAt: scheduleA.UpdatedAt, updatedBy: scheduleA.UpdatedBy, subscribers: scheduleA.Subscribers); var jsonA = CanonicalJsonSerializer.Serialize(scheduleA); var jsonB = CanonicalJsonSerializer.Serialize(scheduleB); Assert.Equal(jsonA, jsonB); using var doc = JsonDocument.Parse(jsonA); using StellaOps.TestKit; var root = doc.RootElement; Assert.Equal(SchedulerSchemaVersions.Schedule, root.GetProperty("schemaVersion").GetString()); Assert.Equal("analysis-only", root.GetProperty("mode").GetString()); Assert.Equal("tenant-alpha", root.GetProperty("tenantId").GetString()); var namespaces = root.GetProperty("selection").GetProperty("namespaces").EnumerateArray().Select(e => e.GetString()).ToArray(); Assert.Equal(new[] { "team-a", "team-b" }, namespaces); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("")] [InlineData("not-a-timezone")] public void Schedule_ThrowsWhenTimezoneInvalid(string timezone) { var selection = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"); Assert.ThrowsAny(() => new Schedule( id: "sch_002", tenantId: "tenant-alpha", name: "Invalid timezone", enabled: true, cronExpression: "0 3 * * *", timezone: timezone, mode: ScheduleMode.AnalysisOnly, selection: selection, onlyIf: null, notify: null, limits: null, createdAt: DateTimeOffset.UtcNow, createdBy: "svc", updatedAt: DateTimeOffset.UtcNow, updatedBy: "svc")); } }