package catalogue import ( "strings" "testing" ) // A scheduled container is the recurring twin of run-once (novox/hq ADR 0053). The control plane's // part is small and exact: carry the field to the host unchanged, and refuse a malformed schedule — // or the contradictory pair run-once + schedule — near its author rather than on the machine. These // tests defend that. func TestAScheduledContainerRendersWithTheCronField(t *testing.T) { // The resolver carries `schedule` into the rendered host declaration untouched, exactly as it // carries run-once — a container's keys are copied through, so the host receives the field as // written. digest := "@sha256:" + strings.Repeat("a", 64) r := Resolution{Node: "laptop", Modules: []Manifest{{ Module: "kometa", Resources: []map[string]any{ {"id": "sync", "type": "container", "name": "sync", "image": "registry.example/runtime" + digest, "schedule": "0 3 * * *"}, }, }}} out, err := r.Declaration(Rendering{}) if err != nil { t.Fatal(err) } sync := indexOfID(out, "kometa.sync") if sync == -1 { t.Fatal("the scheduled container was lost in rendering") } if got, _ := out[sync]["schedule"].(string); got != "0 3 * * *" { t.Errorf("schedule did not reach the host declaration: %+v", out[sync]) } } func TestAMalformedScheduleIsRefused(t *testing.T) { digest := "@sha256:" + strings.Repeat("a", 64) bad := []byte(`{"module":"m","resources":[ {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"every night"} ]}`) if _, err := ParseManifest(bad); err == nil { t.Error("a malformed schedule was accepted") } else if !strings.Contains(err.Error(), "cron") { t.Errorf("refused for the wrong reason: %v", err) } // A schedule out of range is refused for the same reason, near its author. outOfRange := []byte(`{"module":"m","resources":[ {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"0 25 * * *"} ]}`) if _, err := ParseManifest(outOfRange); err == nil { t.Error("a schedule with an hour out of range was accepted") } good := []byte(`{"module":"m","resources":[ {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"*/15 * * * *"} ]}`) if _, err := ParseManifest(good); err != nil { t.Errorf("a valid scheduled container was refused: %v", err) } } func TestAScheduleThatIsNotAStringIsRefused(t *testing.T) { digest := "@sha256:" + strings.Repeat("a", 64) bad := []byte(`{"module":"m","resources":[ {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":true} ]}`) if _, err := ParseManifest(bad); err == nil { t.Error("a schedule that is not a string was accepted") } else if !strings.Contains(err.Error(), "schedule") { t.Errorf("refused for the wrong reason: %v", err) } } func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { // A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053). digest := "@sha256:" + strings.Repeat("a", 64) bad := []byte(`{"module":"m","resources":[ {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","run-once":true,"schedule":"0 3 * * *"} ]}`) if _, err := ParseManifest(bad); err == nil { t.Error("a container that was both run-once and scheduled was accepted") } else if !strings.Contains(err.Error(), "both run-once and schedule") { t.Errorf("refused for the wrong reason: %v", err) } }