package creator import ( "errors" "reflect" "testing" "time" ) func TestAISettingsValidation(t *testing.T) { if _, err := validateAISettings(SettingsUpdate{}); err != nil { t.Fatalf("unconfigured settings: %v", err) } for _, input := range []SettingsUpdate{ {AIBaseURL: "https://example.com/v1"}, {AIAPIKey: "key"}, {AIModel: "model"}, {AIBaseURL: "https://example.com/v1", AIAPIKey: "key"}, {AIBaseURL: "bad-url", AIAPIKey: "key", AIModel: "model"}, } { if _, err := validateAISettings(input); !errors.Is(err, ErrInvalid) { t.Fatalf("incomplete settings accepted: %+v err=%v", input, err) } } input, err := validateAISettings(SettingsUpdate{AIBaseURL: " https://example.com/custom/v1/ ", AIAPIKey: " key ", AIModel: " model "}) if err != nil || input.AIBaseURL != "https://example.com/custom/v1" || input.AIAPIKey != "key" || input.AIModel != "model" { t.Fatalf("normalization=%+v err=%v", input, err) } } func TestAISettingsDoNotResetActiveCollectionOrMetrics(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) accountID := createIntegrationAccount(t, ctx, phaseAStore, "ai-settings-owner") now := time.Now().UTC().Truncate(time.Second) start, end, err := NewCollectionWindow(now, 30) if err != nil { t.Fatal(err) } lease, err := store.beginCheckpoint(ctx, SourceOwned, accountID, "works", start, end) if err != nil { t.Fatal(err) } if lease == "" { t.Fatal("checkpoint lease missing") } if _, err := store.db.ExecContext(ctx, `UPDATE creator_collection_checkpoint SET cursor='page-4',last_error='keep this',last_completed_at=$1 WHERE source_type=$2 AND source_id=$3`, now.Add(-time.Hour), SourceOwned, accountID); err != nil { t.Fatal(err) } before, err := store.checkpoint(ctx, SourceOwned, accountID, "works") if err != nil { t.Fatal(err) } work, _, err := store.UpsertWork(ctx, WorkInput{Platform: "douyin", WorkKey: "ai-settings-work", SourceType: SourceOwned, SourceID: accountID, Title: "work", PublishedAt: &now, PublishedAtStatus: "verified"}, now) if err != nil { t.Fatal(err) } var nextBefore, nextAfter *time.Time if err := store.db.QueryRowContext(ctx, `SELECT metric_plan_next_at FROM creator_work WHERE work_id=$1`, work.ID).Scan(&nextBefore); err != nil { t.Fatal(err) } settings, err := store.GetSettings(ctx) if err != nil { t.Fatal(err) } input := SettingsUpdate{LookbackDays: settings.LookbackDays, NewWorkIntervalSeconds: settings.NewWorkIntervalSeconds, MetricInitialIntervalSeconds: settings.MetricInitialIntervalSeconds, MetricMultiplier: settings.MetricMultiplier, MetricMaxIntervalSeconds: settings.MetricMaxIntervalSeconds, MetricAgeSeconds: settings.MetricAgeSeconds, AIBaseURL: "https://example.com/v1", AIAPIKey: "key", AIModel: "model"} if _, err := store.UpdateSettings(ctx, input); err != nil { t.Fatalf("AI-only save must work during collection: %v", err) } after, err := store.checkpoint(ctx, SourceOwned, accountID, "works") if err != nil { t.Fatal(err) } if !reflect.DeepEqual(before, after) { t.Fatalf("checkpoint changed: before=%+v after=%+v", before, after) } if err := store.db.QueryRowContext(ctx, `SELECT metric_plan_next_at FROM creator_work WHERE work_id=$1`, work.ID).Scan(&nextAfter); err != nil { t.Fatal(err) } if !reflect.DeepEqual(nextBefore, nextAfter) { t.Fatalf("metric schedule changed: %v -> %v", nextBefore, nextAfter) } if _, err := store.UpdateSettings(ctx, input); err != nil { t.Fatalf("unchanged save: %v", err) } input.AIBaseURL, input.AIAPIKey, input.AIModel = "", "", "" if _, err := store.UpdateSettings(ctx, input); err != nil { t.Fatalf("clear AI: %v", err) } saved, err := store.GetSettings(ctx) if err != nil || saved.AIBaseURL != "" || saved.AIAPIKey != "" || saved.AIModel != "" { t.Fatalf("clear was not persisted: %+v err=%v", saved, err) } input.LookbackDays++ if _, err := store.UpdateSettings(ctx, input); !errors.Is(err, ErrConflict) { t.Fatalf("collection change while running: %v", err) } if err := store.finishCheckpoint(ctx, SourceOwned, accountID, "works", lease, "succeeded", "", nil); err != nil { t.Fatal(err) } if _, err := store.UpdateSettings(ctx, input); err != nil { t.Fatalf("collection change after completion: %v", err) } if err := store.db.QueryRowContext(ctx, `SELECT metric_plan_next_at FROM creator_work WHERE work_id=$1`, work.ID).Scan(&nextAfter); err != nil || nextAfter == nil { t.Fatalf("metric plan not recalculated: %v err=%v", nextAfter, err) } input.AIBaseURL = "not-a-url" if _, err := store.UpdateSettings(ctx, input); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid URL: %v", err) } input.AIBaseURL = "" input.LookbackDays = 0 if _, err := store.UpdateSettings(ctx, input); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid collection setting: %v", err) } }