package creator import ( "context" "errors" "fmt" "testing" "time" ) func TestCreatorPostgresRecoversReceivedEventWithoutReplay(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) stamp := time.Now().UnixNano() bigID, _, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) platformAt := time.Now().UTC().Add(-2 * time.Minute) recorded, err := store.RecordEvent(ctx, InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: fmt.Sprintf("received-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID, PlatformEventAt: &platformAt}) if err != nil { t.Fatalf("record event: result=%+v err=%v", recorded, err) } old := time.Now().UTC().Add(-3 * time.Minute) if _, err := store.db.ExecContext(ctx, `UPDATE creator_event SET state='received',received_at=$2 WHERE id=$1`, recorded.Event.ID, old); err != nil { t.Fatal(err) } count, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()) if err != nil || count != 1 { t.Fatalf("recover received event: count=%d err=%v", count, err) } recovered, err := store.GetEvent(ctx, recorded.Event.ID) if err != nil || recovered.State != "uncertain" || recovered.Reason != "事件已收到但未开始处理,未补发" { t.Fatalf("received event was not held without replay: event=%+v err=%v", recovered, err) } } type integrationActionExecutor struct { called chan struct{} } func (e integrationActionExecutor) Execute(context.Context, ActionRequest) (ActionResult, error) { e.called <- struct{}{} return ActionResult{State: "succeeded"}, nil } func TestCreatorPostgresRecoveryDoesNotReleaseQueuedAutomaticOperation(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) stamp := time.Now().UnixNano() bigID, smallID, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) input := InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: fmt.Sprintf("queued-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID} lock := store.automaticExecutionLock(smallID) lock.Lock() executor := integrationActionExecutor{called: make(chan struct{}, 1)} done := make(chan struct{}) go func() { _, _ = store.ProcessAutomaticEvent(ctx, input, executor, nil) close(done) }() var operationID string deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { err := store.db.QueryRowContext(ctx, `SELECT id FROM creator_operation WHERE idempotency_key=$1 AND state='processing'`, "event:douyin:"+bigID+":"+input.EventKey).Scan(&operationID) if err == nil { break } time.Sleep(20 * time.Millisecond) } if operationID == "" { lock.Unlock() t.Fatal("automatic operation did not reach the execution lock") } if _, err := store.db.ExecContext(ctx, `UPDATE creator_operation SET updated_at=$2 WHERE id=$1`, operationID, time.Now().UTC().Add(-10*time.Minute)); err != nil { lock.Unlock() t.Fatal(err) } count, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()) if err != nil || count != 1 { lock.Unlock() t.Fatalf("recover queued operation: count=%d err=%v", count, err) } lock.Unlock() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("automatic operation did not observe recovery") } select { case <-executor.called: t.Fatal("recovered automatic operation reached the platform executor") default: } op, err := store.GetOperation(ctx, operationID) if err != nil || op.State != "uncertain" { t.Fatalf("recovered operation state: operation=%+v err=%v", op, err) } } func TestCreatorPostgresInvalidatedListenerBlocksAutomaticWrite(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) stamp := time.Now().UnixNano() bigID, _, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) boundary := time.Now().UTC() generation := "runtime:network:1" if _, err := store.UpsertListenerState(ctx, ListenerState{AccountID: bigID, Platform: PlatformDouyin, Generation: generation, SessionToken: "session-1", Status: "ready", BoundaryAt: &boundary}); err != nil { t.Fatal(err) } if err := store.InvalidateListener(ctx, bigID, "strategy changed"); err != nil { t.Fatal(err) } result, err := store.ProcessAutomaticEvent(ctx, InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, Generation: generation, EventKey: fmt.Sprintf("invalidated-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID}, integrationActionExecutor{called: make(chan struct{}, 1)}, nil) if err != nil { t.Fatalf("invalidated listener processing failed: result=%+v err=%v", result, err) } if result.Operation != nil || result.Event.State != "blocked" || result.Event.Reason != "监听代际未就绪" { t.Fatalf("invalidated listener was allowed to process: result=%+v", result) } } func TestCreatorPostgresInvalidatedListenerRejectsStaleSession(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) stamp := time.Now().UnixNano() accountID, _, _, _, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) boundary := time.Now().UTC() initial := ListenerState{AccountID: accountID, Platform: PlatformDouyin, Generation: "runtime:network:1", SessionToken: "session-1", Status: "ready", BoundaryAt: &boundary} if _, err := store.UpsertListenerState(ctx, initial); err != nil { t.Fatalf("upsert initial listener: %v", err) } if err := store.InvalidateListener(ctx, accountID, "strategy changed"); err != nil { t.Fatalf("invalidate listener: %v", err) } if _, err := store.UpsertListenerState(ctx, initial); !errors.Is(err, ErrConflict) { t.Fatalf("stale listener session was accepted: err=%v", err) } invalidated, err := store.GetListenerState(ctx, accountID) if err != nil || invalidated.Status != "gap" || !invalidated.Invalidated || invalidated.SessionToken != "session-1" { t.Fatalf("invalidated listener state changed unexpectedly: state=%+v err=%v", invalidated, err) } fresh := initial fresh.SessionToken = "session-2" fresh.Status = "ready" if _, err := store.UpsertListenerState(ctx, fresh); err != nil { t.Fatalf("upsert fresh listener: %v", err) } active, err := store.GetListenerState(ctx, accountID) if err != nil || active.Status != "ready" || active.Invalidated || active.SessionToken != "session-2" { t.Fatalf("fresh listener session was not activated: state=%+v err=%v", active, err) } } func TestCreatorPostgresMaterialClaimDoesNotAcceptStaleCompletion(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) stamp := time.Now().UnixNano() _, _, work, _, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) if _, _, err := store.SelectMaterial(ctx, work.ID); err != nil { t.Fatalf("select material: %v", err) } claimed, ok, err := store.ClaimMaterialStep(ctx, work.ID, "download", "old-token") if err != nil || !ok || claimed.ProcessingToken != "old-token" { t.Fatalf("claim material: job=%+v claimed=%v err=%v", claimed, ok, err) } if _, err := store.db.ExecContext(ctx, `UPDATE creator_material_job SET processing_started_at=$2 WHERE work_id=$1`, work.ID, time.Now().UTC().Add(-time.Hour)); err != nil { t.Fatal(err) } recovered, ok, err := store.ClaimMaterialStep(ctx, work.ID, "download", "new-token") if err != nil || ok || recovered.DownloadStatus != "failed" || recovered.ProcessingToken != "" || recovered.FailureReason != "上次处理结果不明,未自动重试" { t.Fatalf("stale claim was retried or not recovered: job=%+v claimed=%v err=%v", recovered, ok, err) } if _, err := store.CompleteMaterialStep(ctx, work.ID, "download", "old-token", "succeeded", "late-video", ""); !errors.Is(err, ErrConflict) { t.Fatalf("stale completion was accepted: err=%v", err) } } func TestCreatorPostgresRecoversStaleProcessingWithoutRetry(t *testing.T) { store, phaseAStore, ctx := openCreatorIntegrationStore(t) stamp := time.Now().UnixNano() bigID, smallID, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) recorded, err := store.RecordEvent(ctx, InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: fmt.Sprintf("recovery-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID}) if err != nil { t.Fatalf("record event: result=%+v err=%v", recorded, err) } event := recorded.Event op, inserted, err := store.CreateOperation(ctx, OperationInput{IdempotencyKey: fmt.Sprintf("recovery-op-%d", stamp), Source: "manual", Action: ActionReplyComment, Platform: PlatformDouyin, AccountID: smallID, TargetUID: comment.AuthorUID, TargetCommentID: comment.ID, TargetWorkID: work.ID, Text: "reply", EventID: event.ID}) if err != nil || !inserted { t.Fatalf("create operation: operation=%+v inserted=%v err=%v", op, inserted, err) } old := time.Now().UTC().Add(-3 * time.Minute) if _, err := store.db.ExecContext(ctx, `UPDATE creator_operation SET source='automatic',state='processing',updated_at=$2 WHERE id=$1`, op.ID, old); err != nil { t.Fatal(err) } if _, err := store.db.ExecContext(ctx, `UPDATE creator_event SET state='processing',processing_started_at=$2 WHERE id=$1`, event.ID, old); err != nil { t.Fatal(err) } count, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()) if err != nil || count != 1 { t.Fatalf("recover stale processing: count=%d err=%v", count, err) } recoveredOperation, err := store.GetOperation(ctx, op.ID) if err != nil || recoveredOperation.State != "uncertain" { t.Fatalf("operation was not made uncertain: operation=%+v err=%v", recoveredOperation, err) } recoveredEvent, err := store.GetEvent(ctx, event.ID) if err != nil || recoveredEvent.State != "uncertain" { t.Fatalf("event was not made uncertain: event=%+v err=%v", recoveredEvent, err) } }