173 lines
4.9 KiB
TypeScript
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",
|
|
}),
|
|
});
|
|
});
|
|
});
|