2026-04-20 11:07:48 +08:00
|
|
|
package app
|
|
|
|
|
|
2026-05-26 18:43:27 +08:00
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
|
|
|
|
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
|
|
|
)
|
2026-04-20 11:07:48 +08:00
|
|
|
|
|
|
|
|
func TestResolveRetryArticleIDFromPayload(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
payload map[string]any
|
|
|
|
|
wantID int64
|
|
|
|
|
wantOK bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "top level article id",
|
|
|
|
|
payload: map[string]any{
|
|
|
|
|
"article_id": 78,
|
|
|
|
|
},
|
|
|
|
|
wantID: 78,
|
|
|
|
|
wantOK: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "nested content ref article id",
|
|
|
|
|
payload: map[string]any{
|
|
|
|
|
"content_ref": map[string]any{
|
|
|
|
|
"article_id": 79,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
wantID: 79,
|
|
|
|
|
wantOK: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "nested content ref generic id",
|
|
|
|
|
payload: map[string]any{
|
|
|
|
|
"content_ref": map[string]any{
|
|
|
|
|
"id": 80,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
wantID: 80,
|
|
|
|
|
wantOK: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "missing article id",
|
|
|
|
|
payload: map[string]any{
|
|
|
|
|
"publish_record_id": 999,
|
|
|
|
|
},
|
|
|
|
|
wantID: 0,
|
|
|
|
|
wantOK: false,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
tc := tc
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
gotID, gotOK := resolveRetryArticleIDFromPayload(tc.payload)
|
|
|
|
|
if gotID != tc.wantID || gotOK != tc.wantOK {
|
|
|
|
|
t.Fatalf("resolveRetryArticleIDFromPayload() = (%d, %v), want (%d, %v)", gotID, gotOK, tc.wantID, tc.wantOK)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-26 18:43:27 +08:00
|
|
|
|
|
|
|
|
func TestParseUniquePublishAccountIDsDedupesInRequestOrder(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
first := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
|
|
|
second := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
|
|
|
|
|
|
|
|
|
got, err := parseUniquePublishAccountIDs([]CreatePublishJobAccountRequest{
|
|
|
|
|
{AccountID: " " + first.String() + " "},
|
|
|
|
|
{AccountID: second.String()},
|
|
|
|
|
{AccountID: first.String()},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("parseUniquePublishAccountIDs() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(got) != 2 || got[0] != first || got[1] != second {
|
|
|
|
|
t.Fatalf("parseUniquePublishAccountIDs() = %v, want [%s %s]", got, first, second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDesktopPublishDedupKeyUsesArticlePlatformAndAccount(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
accountID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
|
|
|
|
got := desktopPublishDedupKey(7, 11, 897, " sohuhao ", accountID)
|
|
|
|
|
want := `["publish","7","11","897","sohuhao","33333333-3333-3333-3333-333333333333"]`
|
|
|
|
|
if got != want {
|
|
|
|
|
t.Fatalf("desktopPublishDedupKey() = %q, want %q", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDesktopPublishDedupKeyEncodesAmbiguousPlatformIDs(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
accountID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
|
|
|
|
got := desktopPublishDedupKey(7, 11, 897, "sohu:hao", accountID)
|
|
|
|
|
want := `["publish","7","11","897","sohu:hao","33333333-3333-3333-3333-333333333333"]`
|
|
|
|
|
if got != want {
|
|
|
|
|
t.Fatalf("desktopPublishDedupKey() = %q, want %q", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDesktopPublishDedupKeysFromTargetsSortsAndDedupes(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
got := desktopPublishDedupKeysFromTargets([]publishTarget{
|
|
|
|
|
{dedupKey: "publish:1:1:2:b:account"},
|
|
|
|
|
{dedupKey: "publish:1:1:2:a:account"},
|
|
|
|
|
{dedupKey: "publish:1:1:2:b:account"},
|
|
|
|
|
{dedupKey: " "},
|
|
|
|
|
})
|
|
|
|
|
want := []string{"publish:1:1:2:a:account", "publish:1:1:2:b:account"}
|
|
|
|
|
if len(got) != len(want) {
|
|
|
|
|
t.Fatalf("desktopPublishDedupKeysFromTargets() length = %d, want %d; got %v", len(got), len(want), got)
|
|
|
|
|
}
|
|
|
|
|
for i := range want {
|
|
|
|
|
if got[i] != want[i] {
|
|
|
|
|
t.Fatalf("desktopPublishDedupKeysFromTargets()[%d] = %q, want %q; got %v", i, got[i], want[i], got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishTargetPlatformsSortsAndDedupes(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
got := publishTargetPlatforms([]publishTarget{
|
|
|
|
|
{accountSeed: &platformAccountSeed{PlatformID: "xiaohongshu"}},
|
|
|
|
|
{accountSeed: &platformAccountSeed{PlatformID: " weixin_channels "}},
|
|
|
|
|
{accountSeed: &platformAccountSeed{PlatformID: "xiaohongshu"}},
|
|
|
|
|
{accountSeed: &platformAccountSeed{PlatformID: " "}},
|
|
|
|
|
{},
|
|
|
|
|
})
|
|
|
|
|
want := []string{"weixin_channels", "xiaohongshu"}
|
|
|
|
|
if len(got) != len(want) {
|
|
|
|
|
t.Fatalf("publishTargetPlatforms() length = %d, want %d; got %v", len(got), len(want), got)
|
|
|
|
|
}
|
|
|
|
|
for i := range want {
|
|
|
|
|
if got[i] != want[i] {
|
|
|
|
|
t.Fatalf("publishTargetPlatforms()[%d] = %q, want %q; got %v", i, got[i], want[i], got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:17:01 +08:00
|
|
|
func TestShouldRequeuePublishTaskInsteadOfCreatingNewOnlyForUnknownPublish(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
if !shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
|
|
|
|
|
Kind: "publish",
|
|
|
|
|
Status: "unknown",
|
2026-06-25 01:52:26 +08:00
|
|
|
Error: []byte(`{"publish_submit_uncertain":true,"message":"network interrupted after submit"}`),
|
2026-05-29 23:17:01 +08:00
|
|
|
}) {
|
2026-06-25 01:52:26 +08:00
|
|
|
t.Fatal("submit-uncertain unknown publish tasks should be retried by requeueing the existing task")
|
|
|
|
|
}
|
|
|
|
|
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
|
|
|
|
|
Kind: "publish",
|
|
|
|
|
Status: "unknown",
|
|
|
|
|
Error: []byte(`{"error_code":403,"error_msg":"CSRF token invalid"}`),
|
|
|
|
|
}) {
|
|
|
|
|
t.Fatal("definitive failed unknown publish tasks should create a new retry record")
|
2026-05-29 23:17:01 +08:00
|
|
|
}
|
|
|
|
|
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
|
|
|
|
|
Kind: "publish",
|
|
|
|
|
Status: "failed",
|
|
|
|
|
}) {
|
|
|
|
|
t.Fatal("failed publish tasks should keep create-new retry semantics")
|
|
|
|
|
}
|
|
|
|
|
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
|
|
|
|
|
Kind: "monitor",
|
|
|
|
|
Status: "unknown",
|
|
|
|
|
}) {
|
|
|
|
|
t.Fatal("monitor retry semantics are owned by desktop task reconcile")
|
|
|
|
|
}
|
|
|
|
|
if shouldRequeuePublishTaskInsteadOfCreatingNew(nil) {
|
|
|
|
|
t.Fatal("nil tasks should not be requeued")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCreatePublishJobResponseForRequeuedTaskReportsRequeuedTaskID(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
taskID := uuid.MustParse("55555555-5555-5555-5555-555555555555")
|
|
|
|
|
got := createPublishJobResponseForRequeuedTask(&repository.DesktopTask{DesktopID: taskID})
|
|
|
|
|
if got.JobID != "" {
|
|
|
|
|
t.Fatalf("JobID = %q, want empty for requeued task", got.JobID)
|
|
|
|
|
}
|
|
|
|
|
if len(got.TaskIDs) != 1 || got.TaskIDs[0] != taskID.String() {
|
|
|
|
|
t.Fatalf("TaskIDs = %v, want [%s]", got.TaskIDs, taskID)
|
|
|
|
|
}
|
|
|
|
|
if len(got.CreatedTaskIDs) != 0 {
|
|
|
|
|
t.Fatalf("CreatedTaskIDs = %v, want empty", got.CreatedTaskIDs)
|
|
|
|
|
}
|
|
|
|
|
if len(got.ExistingTaskIDs) != 0 {
|
|
|
|
|
t.Fatalf("ExistingTaskIDs = %v, want empty", got.ExistingTaskIDs)
|
|
|
|
|
}
|
|
|
|
|
if len(got.RequeuedTaskIDs) != 1 || got.RequeuedTaskIDs[0] != taskID.String() {
|
|
|
|
|
t.Fatalf("RequeuedTaskIDs = %v, want [%s]", got.RequeuedTaskIDs, taskID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 00:22:59 +08:00
|
|
|
func TestPublishUnknownTaskHasDefinitiveFailureReleasesCSRF403(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
task := &repository.DesktopTask{
|
|
|
|
|
Kind: "publish",
|
|
|
|
|
Status: "unknown",
|
|
|
|
|
Error: []byte(`{"error_code":403,"error_msg":"CSRF token invalid"}`),
|
|
|
|
|
}
|
|
|
|
|
if !publishUnknownTaskHasDefinitiveFailure(task) {
|
|
|
|
|
t.Fatal("unknown publish task with CSRF 403 should be treated as definitive failed")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishUnknownTaskHasDefinitiveFailureKeepsSubmitUncertain(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
task := &repository.DesktopTask{
|
|
|
|
|
Kind: "publish",
|
|
|
|
|
Status: "unknown",
|
|
|
|
|
Error: []byte(`{"publish_submit_uncertain":true,"message":"network interrupted after submit"}`),
|
|
|
|
|
}
|
|
|
|
|
if publishUnknownTaskHasDefinitiveFailure(task) {
|
|
|
|
|
t.Fatal("submit-uncertain unknown publish task must stay in dedup set")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 18:43:27 +08:00
|
|
|
func TestLoadExistingPublishRecordTargetsDedupeUnknownTasks(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
tx := &capturePublishDedupLookupTx{rows: emptyPublishDedupRows{}}
|
|
|
|
|
accountID := uuid.MustParse("44444444-4444-4444-4444-444444444444")
|
|
|
|
|
targets := []publishTarget{
|
|
|
|
|
{accountSeed: &platformAccountSeed{ID: 77}, account: &repository.DesktopAccount{DesktopID: accountID}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := loadExistingPublishRecordTargets(context.Background(), tx, 1, 2, 3, targets)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("loadExistingPublishRecordTargets() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
normalizedSQL := normalizeSQLWhitespace(tx.sql)
|
|
|
|
|
if !strings.Contains(normalizedSQL, "dt.status IN ('queued', 'in_progress', 'succeeded', 'unknown')") {
|
|
|
|
|
t.Fatalf("publish dedup lookup should include unknown task status; query:\n%s", tx.sql)
|
|
|
|
|
}
|
2026-06-25 00:22:59 +08:00
|
|
|
if !strings.Contains(normalizedSQL, "OR dt.status = 'unknown'") {
|
|
|
|
|
t.Fatalf("publish dedup lookup should include unknown tasks even when publish record is failed; query:\n%s", tx.sql)
|
|
|
|
|
}
|
2026-05-26 18:43:27 +08:00
|
|
|
if !strings.Contains(normalizedSQL, "AND dt.tenant_id = $1") {
|
|
|
|
|
t.Fatalf("publish dedup lookup should constrain desktop_tasks by tenant for the payload index; query:\n%s", tx.sql)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(normalizedSQL, "desktop_publish_jobs") {
|
|
|
|
|
t.Fatalf("publish dedup lookup should not load unused publish job metadata; query:\n%s", tx.sql)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLockDesktopPublishDedupKeysUsesSingleArrayQuery(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
tx := &capturePublishDedupLookupTx{}
|
|
|
|
|
err := lockDesktopPublishDedupKeys(context.Background(), tx, []string{"a", "b"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("lockDesktopPublishDedupKeys() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if tx.execCount != 1 {
|
|
|
|
|
t.Fatalf("lockDesktopPublishDedupKeys() exec count = %d, want 1", tx.execCount)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(tx.execSQL, "unnest($1::text[])") {
|
|
|
|
|
t.Fatalf("lockDesktopPublishDedupKeys() should use array unnest query; query:\n%s", tx.execSQL)
|
|
|
|
|
}
|
|
|
|
|
gotKeys, ok := tx.execArgs[0].([]string)
|
|
|
|
|
if !ok || len(gotKeys) != 2 || gotKeys[0] != "a" || gotKeys[1] != "b" {
|
|
|
|
|
t.Fatalf("lockDesktopPublishDedupKeys() args = %#v, want [a b]", tx.execArgs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLockDesktopPublishDedupKeysSkipsEmptyList(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
tx := &capturePublishDedupLookupTx{}
|
|
|
|
|
err := lockDesktopPublishDedupKeys(context.Background(), tx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("lockDesktopPublishDedupKeys() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if tx.execCount != 0 {
|
|
|
|
|
t.Fatalf("lockDesktopPublishDedupKeys() exec count = %d, want 0", tx.execCount)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIsPostgresUniqueViolationMatchesConstraint(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
err := fmt.Errorf("wrapped: %w", &pgconn.PgError{
|
|
|
|
|
Code: "23505",
|
|
|
|
|
ConstraintName: desktopPublishDedupActiveConstraint,
|
|
|
|
|
})
|
|
|
|
|
if !isPostgresUniqueViolation(err, desktopPublishDedupActiveConstraint) {
|
|
|
|
|
t.Fatal("isPostgresUniqueViolation() should match the dedup constraint")
|
|
|
|
|
}
|
|
|
|
|
if isPostgresUniqueViolation(&pgconn.PgError{Code: "23505", ConstraintName: "desktop_tasks_desktop_id_key"}, desktopPublishDedupActiveConstraint) {
|
|
|
|
|
t.Fatal("isPostgresUniqueViolation() should ignore other unique constraints")
|
|
|
|
|
}
|
|
|
|
|
if isPostgresUniqueViolation(&pgconn.PgError{Code: "23503", ConstraintName: desktopPublishDedupActiveConstraint}, desktopPublishDedupActiveConstraint) {
|
|
|
|
|
t.Fatal("isPostgresUniqueViolation() should require SQLSTATE 23505")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type capturePublishDedupLookupTx struct {
|
|
|
|
|
sql string
|
|
|
|
|
args []any
|
|
|
|
|
execSQL string
|
|
|
|
|
execArgs []any
|
|
|
|
|
execCount int
|
|
|
|
|
rows pgx.Rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Begin(context.Context) (pgx.Tx, error) { return nil, nil }
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Commit(context.Context) error { return nil }
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Rollback(context.Context) error { return nil }
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) {
|
|
|
|
|
return 0, nil
|
|
|
|
|
}
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) SendBatch(context.Context, *pgx.Batch) pgx.BatchResults {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) LargeObjects() pgx.LargeObjects { return pgx.LargeObjects{} }
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Prepare(context.Context, string, string) (*pgconn.StatementDescription, error) {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Exec(_ context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) {
|
|
|
|
|
tx.execCount++
|
|
|
|
|
tx.execSQL = sql
|
|
|
|
|
tx.execArgs = arguments
|
|
|
|
|
return pgconn.CommandTag{}, nil
|
|
|
|
|
}
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) {
|
|
|
|
|
tx.sql = sql
|
|
|
|
|
tx.args = args
|
|
|
|
|
return tx.rows, nil
|
|
|
|
|
}
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) QueryRow(context.Context, string, ...any) pgx.Row {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
func (tx *capturePublishDedupLookupTx) Conn() *pgx.Conn { return nil }
|
|
|
|
|
|
|
|
|
|
type emptyPublishDedupRows struct{}
|
|
|
|
|
|
|
|
|
|
func (r emptyPublishDedupRows) Close() {}
|
|
|
|
|
func (r emptyPublishDedupRows) Err() error { return nil }
|
|
|
|
|
func (r emptyPublishDedupRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
|
|
|
|
|
func (r emptyPublishDedupRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
|
|
|
|
|
func (r emptyPublishDedupRows) Next() bool { return false }
|
|
|
|
|
func (r emptyPublishDedupRows) Scan(...any) error { return nil }
|
|
|
|
|
func (r emptyPublishDedupRows) Values() ([]any, error) { return nil, nil }
|
|
|
|
|
func (r emptyPublishDedupRows) RawValues() [][]byte { return nil }
|
|
|
|
|
func (r emptyPublishDedupRows) Conn() *pgx.Conn { return nil }
|
|
|
|
|
|
|
|
|
|
func normalizeSQLWhitespace(sql string) string {
|
|
|
|
|
return strings.Join(strings.Fields(sql), " ")
|
|
|
|
|
}
|