Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesExports ImportsExports & Imports — QA Plan

Exports & Imports — QA Plan

Exports — auth & rate

StepExpected
Member (non-admin/owner) calls POST /export/members403 Only owners and admins can export data.
Owner calls with empty filtersJob enqueued; CSV contains every member row.
11 export requests in 60 seconds from one IP11th returns 429.

Exports — content

StepExpected
Filter roles: ['coach']CSV contains only coach rows.
Filter statuses: ['active', 'invited']CSV contains only those statuses.
Filter fields: ['email', 'firstName']CSV has those two columns only, in the order they’re emitted by the select clause.
CSV opened in Excel with Hebrew namesRenders correctly (UTF-8 BOM present).
Job completesEmail lands with a presigned download URL; URL works for ~1h.
Job 3h after completionGET /jobs/:jobId returns the row with a fresh presigned downloadUrl (r2.getPresignedUrl regenerates).

Exports — failure

StepExpected
Force a DB outage mid-processorJob lands in failed with errorMessage.
Force an R2 upload failureSame.
Email send failsJob still marked completed; sendExportEmail warning logged. Download URL still served via the API endpoint.

Analytics tab CSV (synchronous)

StepExpected
GET /analytics/export?tab=revenue-summary as owner200 with Content-Disposition: attachment; filename="revenue-summary-<date>.csv".
Unknown tab400.
Member calls403.

Imports — provider config

StepExpected
Owner posts { apiKey, email, password }Row inserted with credentials_encrypted; existing row deactivated.
GET /provider-configReturns { ..., hasCredentials: true } with no plaintext.
Rotate CREDENTIAL_ENCRYPTION_KEY then attempt to use the saved configDecrypt fails; import job lands failed (FIT-120). Re-enter credentials.

Imports — Arbox (Business API)

StepExpected
Trigger with arboxApiKey only, entities: { plans: true, members: true }Job lands in_progress; Business API path runs. Plans created first, members second.
Concurrent trigger while one is running409 Active import already exists.
Whitelist ["a@x.com", "b@x.com"]Only those two emails create memberships.
Source has 1500 members, one with malformed email1499 succeed; the malformed row lands in results.members.errors[] after 3 retries.
Cancel a pending jobStatus flips to cancelled; queue job removed.

Imports — Arbox (Management API)

StepExpected
Trigger with arboxEmail + arboxPasswordloginForManagement succeeds; active + inactive reports fetched in parallel.
Member is in both reportsActive wins on merge; arboxStatus = 'active'.
Login fails (bad password)Job lands failed with provider 401 message.
Enrichment phase runs after Management importimport-enrich queue receives jobs; populated fields visible in member_profiles.

Imports — CSV

StepExpected
Upload a 4MB CSV200 with preview + suggested mapping.
Upload a 6MB CSV400 MaxFileSizeValidator.
Upload a non-CSV (rename .txt to .csv)400 file type validator (skipMagicNumbersValidation note: validator only checks extension; magic bytes skipped).
Confirm a job before upload404 / 409.
Confirm with valid mapping, runJob lands in_progress, then completed.
Confirm a job whose status is already in_progress409.
Upload then cancel before confirmingStale pending CSV job is dropped on next upload via cancelStalePendingCsvJobs.

Imports — idempotency

StepExpected
Run the same Arbox import twiceMembers upserted; no duplicate users rows.
Member exists with a different email cased differentlyTreated as the same row (case-insensitive match on import path).
Plan exists with the same nameUpdate, not duplicate.

Negative / robustness

  • Empty Arbox response (no members) → job completes immediately with 0 created.
  • BullMQ Redis outage → orchestration fails fast; new imports rejected until Redis recovers.
  • One member-fan-out job hits a DB unique violation → BullMQ retries; eventual success after the race resolves.

Observability

  • Pino logs every phase transition with [job:<id>].
  • import_jobs.results is the source of truth for “what landed and what didn’t”.
  • No PostHog event today; gap.