ledgerone_backend/test/plaid.webhook.spec.ts

173 lines
4.9 KiB
TypeScript

import { PlaidService } from "../src/plaid/plaid.service";
const createService = () => {
const prisma = {
plaidWebhookEvent: {
create: jest.fn().mockResolvedValue({ id: "evt_1" }),
update: jest.fn(),
},
account: {
findFirst: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
},
transactionRaw: {
upsert: jest.fn(),
},
};
const service = Object.create(PlaidService.prototype) as PlaidService;
Object.assign(service as any, {
prisma,
client: {
linkTokenCreate: jest.fn(),
transactionsGet: jest.fn(),
},
encryption: {
decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")),
},
rawPayloads: {
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
},
logger: {
error: jest.fn(),
},
webhookKeys: new Map(),
});
return { service, prisma, client: (service as any).client };
};
describe("PlaidService webhooks", () => {
beforeEach(() => {
process.env.NODE_ENV = "test";
process.env.PLAID_WEBHOOK_LOOKBACK_DAYS = "7";
});
it("records and processes transaction update webhooks", async () => {
const { service, prisma, client } = createService();
prisma.account.findMany
.mockResolvedValueOnce([{ userId: "user_1" }])
.mockResolvedValueOnce([
{
id: "acct_1",
userId: "user_1",
plaidAccessToken: "enc_token",
plaidAccountId: "plaid_acct_1",
},
]);
client.transactionsGet.mockResolvedValue({
data: {
transactions: [
{
transaction_id: "tx_1",
account_id: "plaid_acct_1",
date: "2026-07-15",
amount: 12.34,
name: "Coffee",
},
],
},
});
const result = await service.handleWebhook({
webhook_type: "TRANSACTIONS",
webhook_code: "SYNC_UPDATES_AVAILABLE",
item_id: "item_1",
});
expect(result).toMatchObject({
received: true,
processed: true,
webhookType: "TRANSACTIONS",
webhookCode: "SYNC_UPDATES_AVAILABLE",
usersSynced: 1,
created: 1,
});
expect(prisma.plaidWebhookEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
itemId: "item_1",
webhookType: "TRANSACTIONS",
webhookCode: "SYNC_UPDATES_AVAILABLE",
}),
});
expect(client.transactionsGet).toHaveBeenCalledWith(expect.objectContaining({
access_token: "raw_token",
}));
expect(prisma.transactionRaw.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { bankTransactionId: "tx_1" },
}));
});
it("marks Plaid item errors as needing reauth", async () => {
const { service, prisma } = createService();
prisma.account.findMany.mockResolvedValue([{ userId: "user_1" }]);
const result = await service.handleWebhook({
webhook_type: "ITEM",
webhook_code: "ERROR",
item_id: "item_1",
error: {
error_code: "ITEM_LOGIN_REQUIRED",
error_message: "User credentials need repair",
},
});
expect(result).toMatchObject({
received: true,
processed: true,
status: "needs_reauth",
usersMarked: 1,
});
expect(prisma.account.updateMany).toHaveBeenCalledWith({
where: { plaidItemId: "item_1" },
data: expect.objectContaining({
syncStatus: "needs_reauth",
lastSyncError: "User credentials need repair",
}),
});
});
it("creates update-mode link tokens for existing Plaid items", async () => {
const { service, prisma, client } = createService();
prisma.account.findFirst.mockResolvedValue({ plaidAccessToken: "enc_token" });
client.linkTokenCreate.mockResolvedValue({
data: {
link_token: "link-update-token",
expiration: "2026-07-15T18:00:00Z",
},
});
const result = await service.createUpdateModeLinkToken("user_1", "acct_1");
expect(result).toEqual({
linkToken: "link-update-token",
expiration: "2026-07-15T18:00:00Z",
});
expect(client.linkTokenCreate).toHaveBeenCalledWith(expect.objectContaining({
access_token: "raw_token",
client_name: "LedgerOne",
}));
});
it("marks update-mode repair completion across the Plaid item", async () => {
const { service, prisma } = createService();
prisma.account.findFirst.mockResolvedValue({ plaidItemId: "item_1" });
prisma.account.updateMany.mockResolvedValue({ count: 2 });
const result = await service.markItemRepairComplete("user_1", "acct_1");
expect(result).toEqual({ updated: 2 });
expect(prisma.account.updateMany).toHaveBeenCalledWith({
where: {
userId: "user_1",
plaidItemId: "item_1",
},
data: expect.objectContaining({
syncStatus: "idle",
lastSyncError: null,
syncConsecutiveFailures: 0,
plaidWebhookCode: "UPDATE_MODE_COMPLETED",
}),
});
});
});