using System.Text.Json.Nodes; using StellaOps.Scheduler.Models; using StellaOps.TestKit; namespace StellaOps.Scheduler.Models.Tests; public sealed class SchedulerSchemaMigrationTests { [Trait("Category", TestCategories.Unit)] [Fact] public void UpgradeSchedule_DefaultsSchemaVersionWhenMissing() { var schedule = new Schedule( id: "sch-01", tenantId: "tenant-alpha", name: "Nightly", enabled: true, cronExpression: "0 2 * * *", timezone: "UTC", mode: ScheduleMode.AnalysisOnly, selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"), onlyIf: null, notify: null, limits: null, createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"), createdBy: "svc-scheduler", updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"), updatedBy: "svc-scheduler"); var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(schedule))!.AsObject(); json.Remove("schemaVersion"); var result = SchedulerSchemaMigration.UpgradeSchedule(json); Assert.Equal(SchedulerSchemaVersions.Schedule, result.Value.SchemaVersion); Assert.Equal(SchedulerSchemaVersions.Schedule, result.ToVersion); Assert.Empty(result.Warnings); } [Trait("Category", TestCategories.Unit)] [Fact] public void UpgradeRun_StrictModeRemovesUnknownProperties() { var run = new Run( id: "run-01", tenantId: "tenant-alpha", trigger: RunTrigger.Manual, state: RunState.Queued, stats: RunStats.Empty, createdAt: DateTimeOffset.Parse("2025-10-18T01:10:00Z")); var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(run))!.AsObject(); json["extraField"] = "to-be-removed"; var result = SchedulerSchemaMigration.UpgradeRun(json, strict: true); Assert.Contains(result.Warnings, warning => warning.Contains("extraField", StringComparison.Ordinal)); } [Trait("Category", TestCategories.Unit)] [Fact] public void UpgradeImpactSet_ThrowsForUnsupportedVersion() { var impactSet = new ImpactSet( selector: new Selector(SelectorScope.AllImages, "tenant-alpha"), images: Array.Empty(), usageOnly: false, generatedAt: DateTimeOffset.Parse("2025-10-18T02:00:00Z")); var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(impactSet))!.AsObject(); json["schemaVersion"] = "scheduler.impact-set@99"; var ex = Assert.Throws(() => SchedulerSchemaMigration.UpgradeImpactSet(json)); Assert.Contains("Unsupported scheduler schema version", ex.Message, StringComparison.Ordinal); } [Trait("Category", TestCategories.Unit)] [Fact] public void UpgradeSchedule_Legacy0_UpgradesToLatestVersion() { var legacy = new JsonObject { ["schemaVersion"] = SchedulerSchemaVersions.ScheduleLegacy0, ["id"] = "sch-legacy", ["tenantId"] = "tenant-alpha", ["name"] = "Legacy Nightly", ["enabled"] = true, ["cronExpression"] = "0 2 * * *", ["timezone"] = "UTC", ["mode"] = "analysis-only", ["selection"] = new JsonObject { ["scope"] = "all-images", ["tenantId"] = "tenant-alpha", }, ["notify"] = new JsonObject { ["onNewFindings"] = "true", ["minSeverity"] = "HIGH", }, ["limits"] = new JsonObject { ["maxJobs"] = "5", ["parallelism"] = -2, }, ["subscribers"] = "ops-team", ["createdAt"] = "2025-10-10T00:00:00Z", ["createdBy"] = "system", ["updatedAt"] = "2025-10-10T01:00:00Z", ["updatedBy"] = "system", }; var result = SchedulerSchemaMigration.UpgradeSchedule(legacy, strict: true); Assert.Equal(SchedulerSchemaVersions.ScheduleLegacy0, result.FromVersion); Assert.Equal(SchedulerSchemaVersions.Schedule, result.ToVersion); Assert.Equal(SchedulerSchemaVersions.Schedule, result.Value.SchemaVersion); Assert.True(result.Value.Notify.IncludeKev); Assert.Empty(result.Value.Subscribers); Assert.Contains(result.Warnings, warning => warning.Contains("schedule.limits.parallelism", StringComparison.Ordinal)); Assert.Contains(result.Warnings, warning => warning.Contains("schedule.subscribers", StringComparison.Ordinal)); } [Trait("Category", TestCategories.Unit)] [Fact] public void UpgradeRun_Legacy0_BackfillsMissingStats() { var legacy = new JsonObject { ["schemaVersion"] = SchedulerSchemaVersions.RunLegacy0, ["id"] = "run-legacy", ["tenantId"] = "tenant-alpha", ["trigger"] = "manual", ["state"] = "queued", ["stats"] = new JsonObject { ["candidates"] = "4", ["queued"] = 2, }, ["createdAt"] = "2025-10-10T02:00:00Z", }; var result = SchedulerSchemaMigration.UpgradeRun(legacy, strict: true); Assert.Equal(SchedulerSchemaVersions.RunLegacy0, result.FromVersion); Assert.Equal(SchedulerSchemaVersions.Run, result.ToVersion); Assert.Equal(SchedulerSchemaVersions.Run, result.Value.SchemaVersion); Assert.Equal(4, result.Value.Stats.Candidates); Assert.Equal(0, result.Value.Stats.NewMedium); Assert.Equal(RunState.Queued, result.Value.State); Assert.Empty(result.Value.Deltas); Assert.Contains(result.Warnings, warning => warning.Contains("run.stats.newMedium", StringComparison.Ordinal)); } [Trait("Category", TestCategories.Unit)] [Fact] public void UpgradeImpactSet_Legacy0_ComputesTotal() { var legacy = new JsonObject { ["schemaVersion"] = SchedulerSchemaVersions.ImpactSetLegacy0, ["selector"] = JsonNode.Parse("""{"scope":"all-images","tenantId":"tenant-alpha"}"""), ["images"] = new JsonArray( JsonNode.Parse("""{"imageDigest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","registry":"docker.io","repository":"library/nginx"}"""), JsonNode.Parse("""{"imageDigest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","registry":"docker.io","repository":"library/httpd"}""")), ["usageOnly"] = "false", ["generatedAt"] = "2025-10-10T03:00:00Z", }; var result = SchedulerSchemaMigration.UpgradeImpactSet(legacy, strict: true); Assert.Equal(SchedulerSchemaVersions.ImpactSetLegacy0, result.FromVersion); Assert.Equal(SchedulerSchemaVersions.ImpactSet, result.ToVersion); Assert.Equal(2, result.Value.Total); Assert.Equal(2, result.Value.Images.Length); Assert.Contains(result.Warnings, warning => warning.Contains("impact set total", StringComparison.OrdinalIgnoreCase)); } }