Files
creator-hub/internal/creator/recovery_integration_test.go
T

203 lines
9.9 KiB
Go

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)
}
}