package api import ( "context" "encoding/json" "errors" "os" "sync/atomic" "testing" "time" "git.ipao.vip/rogee/creator-hub/internal/account" "git.ipao.vip/rogee/creator-hub/internal/creator" hub "git.ipao.vip/rogee/creator-hub/internal/environment" ) func privateWorkerFixture(t *testing.T) (*creator.Store, creator.ListenerState) { t.Helper() databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("requires CREATORHUB_POSTGRES_TEST_URL") } store, accounts, ctx := openCreatorIntegrationStoreForAPITest(t, databaseURL) owner := account.Account{ID: "dm-owner", Platform: creator.PlatformDouyin, Name: "本人", PlatformAccountKey: "123", CredentialReference: account.CredentialReference{ID: "dm-credential", Provider: "os_keyring"}, CredentialKey: "creatorhub/dm-owner/cookies"} if err := accounts.CreateAccount(ctx, owner, &testCredentialBridge{values: make(map[string]string)}); err != nil { t.Fatal(err) } if _, err := store.RecordVerifiedLoginResult(ctx, owner.ID, "123"); err != nil { t.Fatal(err) } state, err := store.SetListenerEnabled(ctx, owner.ID, true) if err != nil { t.Fatal(err) } return store, state } func TestPrivateGatewaySendAndSync(t *testing.T) { store, state := privateWorkerFixture(t) ctx := context.Background() var mode atomic.Int32 respond := func(task taskFrame) *TaskResult { var payload map[string]any json.Unmarshal(task.Payload, &payload) if payload["expected_uid"] != "123" { t.Errorf("identity=%v", payload) } if task.Path == "/v1/browsers/browser/douyin/action" { return &TaskResult{Status: 200, Body: []byte(`{"status":"succeeded","success":true,"message":{"server_id":"100"}}`)} } if task.Path != "/v1/browsers/browser/douyin/inbox" { t.Errorf("path=%s", task.Path) } switch mode.Load() { case 2: return &TaskResult{Status: 200, Body: []byte(`{"status":"succeeded","account_uid":"123","messages":[]}`)} case 3: return &TaskResult{Status: 200, Body: []byte(`{"status":"failed","code":"IM_SDK_NOT_READY"}`)} case 4: return &TaskResult{Status: 200, Body: []byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":"改名后昵称","error":""}],"checkpoints":{"456":"101"}}`)} case 5: return &TaskResult{Status: 200, Body: []byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":"","error":"昵称读取失败"}],"checkpoints":{"456":"101"}}`)} default: return &TaskResult{Status: 200, Body: []byte(`{"status":"succeeded","account_uid":"123","peers":[{"peer_uid":"456","peer_name":"真实昵称","error":""}],"messages":[{"peer_uid":"456","server_id":"101","sender_uid":"456","message_type":"7","content":"{\"aweType\":700,\"text\":\"你好\"}","created_at":"1700000000000"}],"checkpoints":{"456":"101"}}`)} } } cleanup := startListenerFakeGatewayFor(t, 140, "private-msg-key-00140", respond) defer cleanup() accounts := listenerAccountsStub{gateway: hub.Gateway{ID: 140, Name: "gateway", AccessKey: "private-msg-key-00140"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser", Gateway: "gateway"}, RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 7}} input := creator.PrivateMessageSendInput{RequestID: "send-one", AccountID: state.AccountID, PeerUID: "456", Text: "你好"} reservation, err := store.BeginPrivateMessage(ctx, input) if err != nil { t.Fatal(err) } result := gatewayPrivateMessageSender(accounts, store)(ctx, input, reservation) if result.State != "succeeded" || result.ServerID != "100" { t.Fatalf("send=%+v", result) } checkpoints, err := syncPrivateInbox(ctx, accounts, store, state, map[string]string{"456": "99"}) if err != nil || checkpoints["456"] != "101" { t.Fatalf("sync=%v %v", checkpoints, err) } page, err := store.ListPrivateMessages(ctx, state.AccountID, "456", 1, 25) if err != nil || page.Total != 2 { t.Fatalf("page=%+v %v", page, err) } for _, m := range []int32{1, 2, 3} { mode.Store(m) if _, err := syncPrivateInbox(ctx, accounts, store, state, checkpoints); err == nil { t.Fatalf("accepted mode %d", m) } } mode.Store(4) refreshed, err := syncPrivateInbox(ctx, accounts, store, state, checkpoints) if err != nil || refreshed["456"] != "101" { t.Fatalf("nickname-only sync=%+v %v", refreshed, err) } conversations, err := store.ListPrivateConversations(ctx, state.AccountID, 1, 25) if err != nil || conversations.Data[0].PeerName != "改名后昵称" || conversations.Data[0].MessageCount != 2 { t.Fatalf("nickname refresh without new messages=%+v %v", conversations, err) } mode.Store(5) if refreshed, err = syncPrivateInbox(ctx, accounts, store, state, checkpoints); err != nil || refreshed["456"] != "101" { t.Fatalf("profile error must not block checkpoint=%+v %v", refreshed, err) } statuses, err := store.ListPrivateSyncStatus(ctx) if err != nil || statuses[0].Error != "昵称读取失败" { t.Fatalf("profile error persistence=%+v %v", statuses, err) } mode.Store(1) result = gatewayPrivateMessageSender(accounts, store)(ctx, input, reservation) if result.State != "unknown" { t.Fatalf("HTTP error=%+v", result) } invalid := accounts invalid.environmentErr = errors.New("missing environment") result = gatewayPrivateMessageSender(invalid, store)(ctx, input, reservation) if result.State != "failed" { t.Fatalf("missing environment=%+v", result) } if _, err := syncPrivateInbox(ctx, invalid, store, state, nil); err == nil { t.Fatal("missing environment accepted") } if _, err := store.SetListenerEnabled(ctx, state.AccountID, false); err != nil { t.Fatal(err) } result = gatewayPrivateMessageSender(accounts, store)(ctx, input, reservation) if result.State != "failed" { t.Fatalf("disabled=%+v", result) } if _, err := privateListenerState(ctx, store, "missing"); !errors.Is(err, creator.ErrNotFound) { t.Fatalf("missing=%v", err) } } func TestPrivateSyncWorkerRecoveryAndErrors(t *testing.T) { for _, failure := range []bool{false, true} { t.Run(map[bool]string{false: "success", true: "failure"}[failure], func(t *testing.T) { store, state := privateWorkerFixture(t) input := creator.PrivateMessageSendInput{RequestID: "interrupted", AccountID: state.AccountID, PeerUID: "456", Text: "你好"} if _, err := store.BeginPrivateMessage(context.Background(), input); err != nil { t.Fatal(err) } respond := func(task taskFrame) *TaskResult { if failure { return &TaskResult{Status: 502, Body: []byte("upstream failure")} } return &TaskResult{Status: 200, Body: []byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[],"checkpoints":{}}`)} } cleanup := startListenerFakeGatewayFor(t, 141, "private-msg-key-00141", respond) defer cleanup() accounts := listenerAccountsStub{gateway: hub.Gateway{ID: 141, Name: "gateway", AccessKey: "private-msg-key-00141"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser", Gateway: "gateway"}, RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 7}} ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan struct{}) go func() { defer close(done); RunPrivateMessageSync(ctx, accounts, store) }() deadline := time.Now().Add(3 * time.Second) matched := false for time.Now().Before(deadline) { statuses, err := store.ListPrivateSyncStatus(context.Background()) if err != nil { t.Fatal(err) } if len(statuses) == 1 && (failure && statuses[0].Error != "" || !failure && statuses[0].SyncedAt != nil) { matched = true break } time.Sleep(10 * time.Millisecond) } if !matched { t.Fatal("sync result not persisted") } cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("worker did not stop") } page, err := store.ListPrivateMessages(context.Background(), state.AccountID, "456", 1, 25) if err != nil || page.Data[0].State != "unknown" { t.Fatalf("interrupted=%+v %v", page, err) } }) } } // startListenerFakeGatewayFor 启动私消息测试用的通道假网关。 func startListenerFakeGatewayFor(t *testing.T, gatewayID int64, accessKey string, respond func(task taskFrame) *TaskResult) func() { t.Helper() store := newMemoryChannelStore(hub.Gateway{ID: gatewayID, Name: "gateway", AccessKey: accessKey}) SetGatewayChannelStore(store) server := (&gatewayChannelTestServer{store: store, URL: startChannelServer(t, store)}) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && sessionByGateway(gatewayID) == nil { time.Sleep(5 * time.Millisecond) } connectFakeGateway(t, server.URL, accessKey, respond) return func() { } }