| import { describe, expect, test } from "bun:test"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| function categorizeTransactions( |
| transactions: Array<{ |
| id: string; |
| attachments: Array<{ id: string; name: string | null }>; |
| }>, |
| syncRecordMap: Map< |
| string, |
| { |
| status: "pending" | "synced" | "failed" | "partial"; |
| providerTransactionId: string | null; |
| syncedAttachmentMapping: Record<string, string | null> | null; |
| } |
| >, |
| ) { |
| const result = { |
| toExport: [] as string[], |
| toSyncAttachments: [] as Array<{ |
| transactionId: string; |
| providerTransactionId: string; |
| newAttachmentIds: string[]; |
| removedAttachments: Array<{ |
| middayId: string; |
| providerId: string | null; |
| }>; |
| }>, |
| alreadyComplete: [] as string[], |
| }; |
|
|
| for (const tx of transactions) { |
| const syncRecord = syncRecordMap.get(tx.id); |
|
|
| |
| if (!syncRecord || syncRecord.status === "failed") { |
| result.toExport.push(tx.id); |
| continue; |
| } |
|
|
| |
| const currentAttachmentIds = new Set( |
| tx.attachments?.filter((a) => a.name !== null).map((a) => a.id) ?? [], |
| ); |
| const syncedMapping = syncRecord.syncedAttachmentMapping ?? {}; |
| const syncedIds = new Set(Object.keys(syncedMapping)); |
|
|
| |
| const newAttachmentIds = [...currentAttachmentIds].filter( |
| (id) => !syncedIds.has(id), |
| ); |
|
|
| |
| const removedAttachments = [...syncedIds] |
| .filter((id) => !currentAttachmentIds.has(id)) |
| .map((middayId) => ({ |
| middayId, |
| providerId: syncedMapping[middayId] ?? null, |
| })); |
|
|
| |
| const needsAttachmentSync = |
| newAttachmentIds.length > 0 || |
| removedAttachments.length > 0 || |
| syncRecord.status === "partial"; |
|
|
| if (needsAttachmentSync) { |
| if (syncRecord.providerTransactionId) { |
| result.toSyncAttachments.push({ |
| transactionId: tx.id, |
| providerTransactionId: syncRecord.providerTransactionId, |
| newAttachmentIds, |
| removedAttachments, |
| }); |
| } else { |
| |
| result.toExport.push(tx.id); |
| } |
| } else { |
| |
| result.alreadyComplete.push(tx.id); |
| } |
| } |
|
|
| return result; |
| } |
|
|
| describe("categorizeTransactions", () => { |
| describe("toExport categorization", () => { |
| test("puts new transactions (no sync record) in toExport", () => { |
| const transactions = [ |
| { id: "tx-new", attachments: [{ id: "att-1", name: "receipt.pdf" }] }, |
| ]; |
| const syncRecordMap = new Map(); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toExport).toContain("tx-new"); |
| expect(result.toSyncAttachments.length).toBe(0); |
| expect(result.alreadyComplete.length).toBe(0); |
| }); |
|
|
| test("puts failed transactions in toExport for retry", () => { |
| const transactions = [ |
| { |
| id: "tx-failed", |
| attachments: [{ id: "att-1", name: "receipt.pdf" }], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-failed", |
| { |
| status: "failed" as const, |
| providerTransactionId: null, |
| syncedAttachmentMapping: null, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toExport).toContain("tx-failed"); |
| }); |
|
|
| test("puts pending transactions with no provider ID in toExport", () => { |
| const transactions = [ |
| { |
| id: "tx-pending", |
| attachments: [{ id: "att-1", name: "receipt.pdf" }], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-pending", |
| { |
| status: "synced" as const, |
| providerTransactionId: null, |
| syncedAttachmentMapping: null, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| |
| expect(result.toExport).toContain("tx-pending"); |
| }); |
| }); |
|
|
| describe("toSyncAttachments categorization", () => { |
| test("puts synced transactions with new attachments in toSyncAttachments", () => { |
| const transactions = [ |
| { |
| id: "tx-synced", |
| attachments: [ |
| { id: "att-existing", name: "existing.pdf" }, |
| { id: "att-new", name: "new.pdf" }, |
| ], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-synced", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: { "att-existing": "provider-att-1" }, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toSyncAttachments.length).toBe(1); |
| expect(result.toSyncAttachments[0]?.transactionId).toBe("tx-synced"); |
| expect(result.toSyncAttachments[0]?.newAttachmentIds).toContain( |
| "att-new", |
| ); |
| }); |
|
|
| test("puts synced transactions with removed attachments in toSyncAttachments", () => { |
| const transactions = [ |
| { |
| id: "tx-removed", |
| attachments: [], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-removed", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: { "att-deleted": "provider-att-1" }, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toSyncAttachments.length).toBe(1); |
| expect(result.toSyncAttachments[0]?.removedAttachments).toContainEqual({ |
| middayId: "att-deleted", |
| providerId: "provider-att-1", |
| }); |
| }); |
|
|
| test("puts partial status transactions in toSyncAttachments for retry", () => { |
| const transactions = [ |
| { |
| id: "tx-partial", |
| attachments: [{ id: "att-1", name: "receipt.pdf" }], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-partial", |
| { |
| status: "partial" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: {}, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toSyncAttachments.length).toBe(1); |
| expect(result.toSyncAttachments[0]?.transactionId).toBe("tx-partial"); |
| }); |
| }); |
|
|
| describe("alreadyComplete categorization", () => { |
| test("puts fully synced transactions in alreadyComplete", () => { |
| const transactions = [ |
| { |
| id: "tx-complete", |
| attachments: [{ id: "att-1", name: "receipt.pdf" }], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-complete", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: { "att-1": "provider-att-1" }, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.alreadyComplete).toContain("tx-complete"); |
| expect(result.toExport.length).toBe(0); |
| expect(result.toSyncAttachments.length).toBe(0); |
| }); |
|
|
| test("puts synced transactions with no attachments in alreadyComplete", () => { |
| const transactions = [{ id: "tx-no-att", attachments: [] }]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-no-att", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: {}, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.alreadyComplete).toContain("tx-no-att"); |
| }); |
| }); |
|
|
| describe("edge cases", () => { |
| test("ignores attachments with null names", () => { |
| const transactions = [ |
| { |
| id: "tx-null-name", |
| attachments: [ |
| { id: "att-1", name: "valid.pdf" }, |
| { id: "att-2", name: null }, |
| ], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-null-name", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: { "att-1": "provider-att-1" }, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| |
| expect(result.alreadyComplete).toContain("tx-null-name"); |
| }); |
|
|
| test("handles empty syncedAttachmentMapping", () => { |
| const transactions = [ |
| { |
| id: "tx-empty-map", |
| attachments: [{ id: "att-new", name: "new.pdf" }], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-empty-map", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: {}, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toSyncAttachments.length).toBe(1); |
| expect(result.toSyncAttachments[0]?.newAttachmentIds).toContain( |
| "att-new", |
| ); |
| }); |
|
|
| test("handles null syncedAttachmentMapping", () => { |
| const transactions = [ |
| { |
| id: "tx-null-map", |
| attachments: [{ id: "att-new", name: "new.pdf" }], |
| }, |
| ]; |
| const syncRecordMap = new Map([ |
| [ |
| "tx-null-map", |
| { |
| status: "synced" as const, |
| providerTransactionId: "provider-123", |
| syncedAttachmentMapping: null, |
| }, |
| ], |
| ]); |
|
|
| const result = categorizeTransactions(transactions, syncRecordMap); |
|
|
| expect(result.toSyncAttachments.length).toBe(1); |
| expect(result.toSyncAttachments[0]?.newAttachmentIds).toContain( |
| "att-new", |
| ); |
| }); |
| }); |
| }); |
|
|
| describe("status update logic", () => { |
| |
| |
|
|
| function determineStatus(_uploadedCount: number, failedCount: number) { |
| if (failedCount > 0) { |
| return "partial"; |
| } |
| return "synced"; |
| } |
|
|
| function determineErrorFields( |
| failedCount: number, |
| errorCodes: (string | null)[], |
| errorMessages: string[], |
| ) { |
| if (failedCount > 0) { |
| return { |
| errorCode: errorCodes[0] ?? null, |
| errorMessage: |
| errorMessages[0] ?? `${failedCount} attachment(s) failed to upload`, |
| }; |
| } |
| |
| return { |
| errorCode: null, |
| errorMessage: null, |
| }; |
| } |
|
|
| test("sets status to partial when failures occur", () => { |
| const status = determineStatus(2, 1); |
| expect(status).toBe("partial"); |
| }); |
|
|
| test("sets status to synced when all succeed", () => { |
| const status = determineStatus(3, 0); |
| expect(status).toBe("synced"); |
| }); |
|
|
| test("clears errorCode on successful retry", () => { |
| const { errorCode } = determineErrorFields(0, [], []); |
| expect(errorCode).toBe(null); |
| }); |
|
|
| test("clears errorMessage on successful retry", () => { |
| const { errorMessage } = determineErrorFields(0, [], []); |
| expect(errorMessage).toBe(null); |
| }); |
|
|
| test("preserves first error code when multiple failures", () => { |
| const { errorCode } = determineErrorFields( |
| 2, |
| ["RATE_LIMIT", "VALIDATION"], |
| ["Rate limit exceeded", "Invalid file type"], |
| ); |
| expect(errorCode).toBe("RATE_LIMIT"); |
| }); |
|
|
| test("preserves first error message when multiple failures", () => { |
| const { errorMessage } = determineErrorFields( |
| 2, |
| ["RATE_LIMIT", "VALIDATION"], |
| ["Rate limit exceeded", "Invalid file type"], |
| ); |
| expect(errorMessage).toBe("Rate limit exceeded"); |
| }); |
|
|
| test("uses fallback message when no specific error message", () => { |
| const { errorMessage } = determineErrorFields( |
| 3, |
| [null], |
| [], |
| ); |
| expect(errorMessage).toBe("3 attachment(s) failed to upload"); |
| }); |
| }); |
|
|