203 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|