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", }), }); }); });