package appuser import ( "context" "encoding/json" "strings" "testing" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" ) type recordingAppUserExportStore struct { job *model.AdminJob } func (s *recordingAppUserExportStore) CreateJob(job *model.AdminJob) error { copy := *job copy.ID = 81 job.ID = copy.ID s.job = © return nil } func TestCreateExportJobPersistsFilterSnapshot(t *testing.T) { store := &recordingAppUserExportStore{} service := &Service{ jobStore: store, jobsConfig: config.JobsConfig{ Enabled: true, MaxAttempts: 5, }, } ctx := appctx.WithContext(context.Background(), "lalu") response, err := service.CreateExportJob(ctx, shared.Actor{UserID: 7, Username: "operator"}, listQuery{ Country: " ph ", Status: "active", Keyword: "1299324", SortBy: "coin", SortDirection: "asc", Page: 9, PageSize: 10, }) if err != nil { t.Fatalf("create export job: %v", err) } if response.JobID != 81 || response.SnapshotAtMs <= 0 || store.job == nil { t.Fatalf("response/job mismatch: response=%+v job=%+v", response, store.job) } if store.job.Type != AppUserExportJobType || store.job.MaxAttempts != 5 || store.job.CreatedBy != 7 { t.Fatalf("job metadata mismatch: %+v", store.job) } var payload appUserExportPayload if err := json.Unmarshal([]byte(store.job.PayloadJSON), &payload); err != nil { t.Fatalf("decode payload: %v", err) } if payload.AppCode != "lalu" || payload.Query.Country != "PH" || payload.Query.Status != "active" || payload.Query.Keyword != "1299324" { t.Fatalf("filter snapshot mismatch: %+v", payload) } if payload.Query.Page != 1 || payload.Query.PageSize != 100 || payload.Query.SnapshotAtMs != response.SnapshotAtMs { t.Fatalf("pagination snapshot mismatch: %+v", payload.Query) } if payload.Query.SortBy != "created_at" || payload.Query.SortDirection != "desc" { t.Fatalf("export must use stable database pagination instead of coin projection sort: %+v", payload.Query) } } func TestStableAppUserExportQueryForcesDescendingKeysetOrder(t *testing.T) { for _, query := range []listQuery{ {SortBy: "created_at", SortDirection: "asc"}, {SortBy: "coin", SortDirection: "asc"}, } { stable := stableAppUserExportQuery(normalizeListQuery(query)) if stable.SortBy != "created_at" || stable.SortDirection != "desc" { t.Fatalf("export order must match descending keyset: input=%+v stable=%+v", query, stable) } } } func TestAppUserExportWhereUsesSnapshotAndKeysetCursor(t *testing.T) { query := normalizeListQuery(listQuery{ SnapshotAtMs: 1_000, ExportCursorCreatedAtMs: 900, ExportCursorUserID: 88, }) whereSQL, args := appUserListWhereSQLAt("lalu", query, query.SnapshotAtMs) for _, fragment := range []string{ "u.created_at_ms <= ?", "u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?)", } { if !strings.Contains(whereSQL, fragment) { t.Fatalf("export where SQL missing %q: %s", fragment, whereSQL) } } wantTail := []any{int64(1_000), int64(900), int64(900), int64(88)} if len(args) < len(wantTail) { t.Fatalf("export args too short: %+v", args) } for index, want := range wantTail { if got := args[len(args)-len(wantTail)+index]; got != want { t.Fatalf("export cursor arg[%d]=%v want=%v all=%+v", index, got, want, args) } } }