Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/main/src/backend/export/exportTransactions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { type EventPublisher } from '@/backend/eventEmitters/EventEmitter';
import { beforeEach, describe, expect, test, vi } from 'vitest';

// Mock the outputVendors module so we can inject controlled exporters.
vi.mock('@/backend/export/outputVendors', () => ({
default: [],
}));

import outputVendors from '@/backend/export/outputVendors';
import { createTransactionsInExternalVendors } from './exportTransactions';

const noopEventPublisher: EventPublisher = {
emit: vi.fn().mockResolvedValue(undefined),
};

function makeExporter(name: string, behavior: 'success' | 'fail', exported = 1) {
return {
name,
init: vi.fn().mockResolvedValue(undefined),
exportTransactions: vi.fn().mockImplementation(async () => {
if (behavior === 'fail') {
throw new Error(`${name} blew up`);
}
return { exportedTransactionsNum: exported };
}),
};
}

function setExporters(list: unknown[]) {
// Mutate the mocked array in place so the module-level import in
// exportTransactions.ts sees the new values.
(outputVendors as unknown as unknown[]).length = 0;
(outputVendors as unknown as unknown[]).push(...list);
}

describe('createTransactionsInExternalVendors', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('one exporter failing does not prevent other exporters from running', async () => {
const failing = makeExporter('csv', 'fail');
const succeeding = makeExporter('ynab', 'success', 5);
setExporters([failing, succeeding]);

const config = {
csv: { active: true },
ynab: { active: true },
} as never;

const result = await createTransactionsInExternalVendors(
config,
{ companyA: [] as never[] },
new Date('2025-01-01'),
noopEventPublisher,
);

// The successful exporter should have run to completion even though csv threw.
expect(succeeding.exportTransactions).toHaveBeenCalledTimes(1);
expect(failing.exportTransactions).toHaveBeenCalledTimes(1);
expect(result).toHaveProperty('ynab');
expect(result).not.toHaveProperty('csv');
});

test('does not reject when an exporter throws (promise resolves with partial result)', async () => {
const failing = makeExporter('csv', 'fail');
const succeeding = makeExporter('ynab', 'success', 3);
setExporters([failing, succeeding]);

const config = {
csv: { active: true },
ynab: { active: true },
} as never;

await expect(
createTransactionsInExternalVendors(
config,
{ companyA: [] as never[] },
new Date('2025-01-01'),
noopEventPublisher,
),
).resolves.toBeDefined();
});
});
9 changes: 7 additions & 2 deletions packages/main/src/backend/export/exportTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,16 @@ export async function createTransactionsInExternalVendors(
...baseEvent,
}),
);
throw e;
// Intentionally do not re-throw: each exporter's outcome is already
// recorded via counters + events. Re-throwing would reject this promise
// and, combined with Promise.all's fail-fast behavior, orphan other
// still-running exporters and skip the summary/EXPORT_PROCESS_END emit.
}
});

await Promise.all(exportPromises);
// Use allSettled so a thrown error from an exporter (e.g. something outside
// the try/catch above) cannot abort the other exporters or skip the summary.
await Promise.allSettled(exportPromises);

const result = failedCount === 0 ? 'success' : successCount === 0 ? 'failed' : 'partial';
log.summary(result, {
Expand Down
Loading