From 37f2c44db863b53d27c52b7814e463802e195469 Mon Sep 17 00:00:00 2001 From: Raven062 Date: Tue, 1 Sep 2026 10:17:05 +0000 Subject: [PATCH] fix(salary): add dedicated migration + tests for salary models (W2-B-038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2-B-038 — salaryBatch / salaryItem / salarySchedule models missing Root cause ---------- The Prisma schema already defined SalaryBatch, SalaryItem, and SalarySchedule, but the three salary tables were bundled into the large 20260423111042_init migration without a standalone, clearly-named migration of their own. Environments that ran a subset of migrations (or fresh deploys that need an audit trail of when each feature was added) had no dedicated migration to apply, causing prisma migrate deploy to leave the tables absent and TypeScript to report TS2339 on every prisma.salaryBatch / salaryItem / salarySchedule access (13 sites in salaryService.ts). Changes ------- prisma/migrations/20260901000000_add_salary_models/migration.sql - Creates salary_batches, salary_items, salary_schedules with full column definitions matching schema.prisma exactly. - All CREATE TABLE statements use IF NOT EXISTS so the migration is safe to run on environments that already have the tables from the init migration. - Unique and regular indexes use CREATE INDEX IF NOT EXISTS. - Foreign keys are added inside a DO dollar block that checks pg_constraint first, making them idempotent too. tests/salaryService.test.ts (20 test cases, no real DB) - createSalaryBatch: normal path, idempotency hit, total mismatch (400), correct total accepted - processSalaryBatch: all-success (completed), partial failure (partially_completed), all-failed, resume support (skip completed items), rejected transfer writes via dollar-transaction, batch-not-found guard, already-completed guard - getSalaryBatches: pagination params forwarded, includes count.items - createSalarySchedule: normal path + nextRunAt from dateUtils, invalid cron (400), empty cron (400), currency default - triggerSchedule: fires createSalaryBatch, updates lastRunAt/nextRunAt, skips non-active schedules, uses 60s offset for non-daily cron --- .../migration.sql | 162 +++++ tests/salaryService.test.ts | 673 ++++++++++++++++++ 2 files changed, 835 insertions(+) create mode 100644 prisma/migrations/20260901000000_add_salary_models/migration.sql create mode 100644 tests/salaryService.test.ts diff --git a/prisma/migrations/20260901000000_add_salary_models/migration.sql b/prisma/migrations/20260901000000_add_salary_models/migration.sql new file mode 100644 index 0000000..da4f4b6 --- /dev/null +++ b/prisma/migrations/20260901000000_add_salary_models/migration.sql @@ -0,0 +1,162 @@ +-- Migration: 20260901000000_add_salary_models +-- Adds salary_batches, salary_items, salary_schedules tables. +-- +-- Idempotent: all statements use IF NOT EXISTS so this migration is safe to +-- run on environments that already have these tables (e.g. those that ran +-- 20260423111042_init which bundled the salary tables with other changes). +-- +-- References: +-- W2-B-038 — salaryBatch / salaryItem / salarySchedule models missing +-- schema.prisma models: SalaryBatch, SalaryItem, SalarySchedule + +-- ── salary_batches ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "salary_batches" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "user_id" UUID NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "total_amount" DECIMAL(20,8) NOT NULL, + "currency" VARCHAR(10) NOT NULL DEFAULT 'ACBU', + "idempotency_key" VARCHAR(100), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(6), + + CONSTRAINT "salary_batches_pkey" PRIMARY KEY ("id") +); + +-- ── salary_items ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "salary_items" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "batch_id" UUID NOT NULL, + "recipient_id" UUID, + "recipient_address" VARCHAR(56) NOT NULL, + "amount" DECIMAL(20,8) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "transaction_id" UUID, + "error_message" TEXT, + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "salary_items_pkey" PRIMARY KEY ("id") +); + +-- ── salary_schedules ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "salary_schedules" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "organization_id" UUID, + "user_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "cron" VARCHAR(100) NOT NULL, + "amount_config" JSONB NOT NULL, + "currency" VARCHAR(10) NOT NULL DEFAULT 'ACBU', + "status" VARCHAR(20) NOT NULL DEFAULT 'active', + "last_run_at" TIMESTAMP(6), + "next_run_at" TIMESTAMP(6), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "salary_schedules_pkey" PRIMARY KEY ("id") +); + +-- ── Unique constraints ──────────────────────────────────────────────────────── +CREATE UNIQUE INDEX IF NOT EXISTS "salary_batches_idempotency_key_key" + ON "salary_batches"("idempotency_key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "salary_items_transaction_id_key" + ON "salary_items"("transaction_id"); + +-- ── Indexes ─────────────────────────────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS "idx_salary_batch_org_id" + ON "salary_batches"("organization_id"); + +CREATE INDEX IF NOT EXISTS "idx_salary_batch_user_id" + ON "salary_batches"("user_id"); + +CREATE INDEX IF NOT EXISTS "idx_salary_batch_status" + ON "salary_batches"("status"); + +CREATE INDEX IF NOT EXISTS "idx_salary_item_batch_id" + ON "salary_items"("batch_id"); + +CREATE INDEX IF NOT EXISTS "idx_salary_item_status" + ON "salary_items"("status"); + +CREATE INDEX IF NOT EXISTS "idx_salary_schedule_org_id" + ON "salary_schedules"("organization_id"); + +CREATE INDEX IF NOT EXISTS "idx_salary_schedule_status" + ON "salary_schedules"("status"); + +CREATE INDEX IF NOT EXISTS "idx_salary_schedule_next_run" + ON "salary_schedules"("next_run_at"); + +-- ── Foreign keys (DO $$ ... to guard idempotency) ──────────────────────────── +DO $$ +BEGIN + -- salary_batches → organizations + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'salary_batches_organization_id_fkey' + ) THEN + ALTER TABLE "salary_batches" + ADD CONSTRAINT "salary_batches_organization_id_fkey" + FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + -- salary_batches → users + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'salary_batches_user_id_fkey' + ) THEN + ALTER TABLE "salary_batches" + ADD CONSTRAINT "salary_batches_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + -- salary_items → salary_batches + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'salary_items_batch_id_fkey' + ) THEN + ALTER TABLE "salary_items" + ADD CONSTRAINT "salary_items_batch_id_fkey" + FOREIGN KEY ("batch_id") REFERENCES "salary_batches"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + -- salary_items → transactions + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'salary_items_transaction_id_fkey' + ) THEN + ALTER TABLE "salary_items" + ADD CONSTRAINT "salary_items_transaction_id_fkey" + FOREIGN KEY ("transaction_id") REFERENCES "transactions"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + -- salary_schedules → organizations + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'salary_schedules_organization_id_fkey' + ) THEN + ALTER TABLE "salary_schedules" + ADD CONSTRAINT "salary_schedules_organization_id_fkey" + FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + -- salary_schedules → users + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'salary_schedules_user_id_fkey' + ) THEN + ALTER TABLE "salary_schedules" + ADD CONSTRAINT "salary_schedules_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; diff --git a/tests/salaryService.test.ts b/tests/salaryService.test.ts new file mode 100644 index 0000000..0fa0ab9 --- /dev/null +++ b/tests/salaryService.test.ts @@ -0,0 +1,673 @@ +/** + * W2-B-038 — Salary service tests + * + * Acceptance: salary tests pass. Covers: + * - createSalaryBatch: normal path, idempotency hit, total mismatch validation, + * async background processing kick-off + * - processSalaryBatch: all-success, partial-failure, all-failed, resume-support + * (skip already-completed items), rejected transfers write failedItemWrites + * - getSalaryBatches: pagination, org/user filter + * - createSalarySchedule: normal path, invalid cron rejection + * - triggerSchedule: fires createSalaryBatch, updates nextRunAt/lastRunAt, + * skips non-active schedules + */ + +/// + +import { Decimal } from "@prisma/client/runtime/library"; + +// ── In-memory stores ────────────────────────────────────────────────────────── + +type BatchRow = { + id: string; + organizationId: string | null; + userId: string; + status: string; + totalAmount: Decimal; + currency: string; + idempotencyKey: string | null; + createdAt: Date; + updatedAt: Date; + completedAt: Date | null; + items: ItemRow[]; +}; + +type ItemRow = { + id: string; + batchId: string; + recipientId: string | null; + recipientAddress: string; + amount: Decimal; + status: string; + transactionId: string | null; + errorMessage: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type ScheduleRow = { + id: string; + organizationId: string | null; + userId: string; + name: string; + cron: string; + amountConfig: unknown; + currency: string; + status: string; + lastRunAt: Date | null; + nextRunAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +const db: { + batches: Map; + items: Map; + schedules: Map; + transactionBatch: Array<() => Promise>; +} = { + batches: new Map(), + items: new Map(), + schedules: new Map(), + transactionBatch: [], +}; + +let _seq = 0; +function uid(): string { + _seq += 1; + return `id-${_seq}`; +} + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock("../src/config/database", () => ({ + prisma: { + salaryBatch: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + }, + salaryItem: { + update: jest.fn(), + }, + salarySchedule: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + }, + $transaction: jest.fn(), + $disconnect: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock("../src/config/logger", () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + logFinancialEvent: jest.fn(), +})); + +jest.mock("../src/services/transfer/transferService", () => ({ + createTransfer: jest.fn(), +})); + +// Bypass setImmediate deferral in createSalaryBatch so processSalaryBatch runs +// synchronously inside tests. +jest.mock("../src/utils/retry", () => ({ + retryWithBackoff: jest.fn((fn: () => Promise) => fn()), +})); + +// dateUtils: pin deterministic next-run timestamps +jest.mock("../src/utils/dateUtils", () => ({ + getInitialDailyMidnight: jest.fn(() => new Date("2026-09-02T00:00:00.000Z")), + getNextDailyMidnight: jest.fn(() => new Date("2026-09-03T00:00:00.000Z")), +})); + +// ── Import under test (after mocks) ────────────────────────────────────────── +import { + createSalaryBatch, + processSalaryBatch, + getSalaryBatches, + createSalarySchedule, + triggerSchedule, +} from "../src/services/salary/salaryService"; +import { prisma } from "../src/config/database"; +import { createTransfer } from "../src/services/transfer/transferService"; +import { AppError } from "../src/middleware/errorHandler"; + +const mockBatch = prisma.salaryBatch as jest.Mocked; +const mockItem = prisma.salaryItem as jest.Mocked; +const mockSchedule = prisma.salarySchedule as jest.Mocked; +const mockTransaction = prisma.$transaction as jest.Mock; +const mockCreateTransfer = createTransfer as jest.Mock; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeBatch(overrides: Partial = {}): BatchRow { + const id = uid(); + return { + id, + organizationId: "org-1", + userId: "user-1", + status: "pending", + totalAmount: new Decimal("1000.00"), + currency: "ACBU", + idempotencyKey: null, + createdAt: new Date(), + updatedAt: new Date(), + completedAt: null, + items: [], + ...overrides, + }; +} + +function makeItem(batchId: string, overrides: Partial = {}): ItemRow { + return { + id: uid(), + batchId, + recipientId: null, + recipientAddress: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: new Decimal("500.00"), + status: "pending", + transactionId: null, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeSchedule(overrides: Partial = {}): ScheduleRow { + return { + id: uid(), + organizationId: "org-1", + userId: "user-1", + name: "Monthly Payroll", + cron: "0 0 * * *", + amountConfig: [ + { + recipient_id: "rec-1", + recipient_address: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "500.00", + }, + ], + currency: "ACBU", + status: "active", + lastRunAt: null, + nextRunAt: new Date("2026-09-01T00:00:00.000Z"), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// Silence setImmediate in createSalaryBatch background kick-off +const realSetImmediate = global.setImmediate; +beforeAll(() => { + global.setImmediate = ((fn: () => void) => { + fn(); + return {} as NodeJS.Immediate; + }) as typeof setImmediate; +}); +afterAll(() => { + global.setImmediate = realSetImmediate; +}); + +// ── Test suites ─────────────────────────────────────────────────────────────── + +describe("createSalaryBatch", () => { + beforeEach(() => { + jest.clearAllMocks(); + // $transaction called with an array of Prisma promises (array form) + mockTransaction.mockResolvedValue([]); + }); + + it("creates a batch and returns batchId + pending status", async () => { + const batch = makeBatch({ id: "batch-1", status: "pending" }); + mockBatch.create.mockResolvedValue(batch); + + // findUnique is called twice: once for idempotency check, once in processSalaryBatch + mockBatch.findUnique + .mockResolvedValueOnce(null) // idempotency check → no existing batch + .mockResolvedValueOnce({ ...batch, items: [] }); // processSalaryBatch lookup + mockBatch.update.mockResolvedValue({ ...batch, status: "completed" }); + + const result = await createSalaryBatch({ + userId: "user-1", + organizationId: "org-1", + currency: "ACBU", + items: [ + { + recipientAddress: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "500.00", + }, + { + recipientAddress: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123457", + amount: "500.00", + }, + ], + }); + + expect(result.batchId).toBe("batch-1"); + expect(result.status).toBe("pending"); + expect(mockBatch.create).toHaveBeenCalledTimes(1); + + // Verify items are nested in the create call + const createArg = mockBatch.create.mock.calls[0][0]; + expect(createArg.data.items.create).toHaveLength(2); + expect(createArg.data.currency).toBe("ACBU"); + }); + + it("returns idempotency hit without creating a new batch", async () => { + const existing = makeBatch({ id: "existing-batch", status: "completed" }); + mockBatch.findUnique.mockResolvedValue(existing); + + const result = await createSalaryBatch({ + userId: "user-1", + currency: "ACBU", + idempotencyKey: "key-abc", + items: [ + { + recipientAddress: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "500.00", + }, + ], + }); + + expect(result.batchId).toBe("existing-batch"); + expect(result.status).toBe("completed"); + expect(mockBatch.create).not.toHaveBeenCalled(); + }); + + it("throws AppError 400 when provided totalAmount does not match sum of items", async () => { + mockBatch.findUnique.mockResolvedValue(null); + + await expect( + createSalaryBatch({ + userId: "user-1", + currency: "ACBU", + totalAmount: "9999.00", // wrong + items: [ + { + recipientAddress: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "500.00", + }, + ], + }), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(mockBatch.create).not.toHaveBeenCalled(); + }); + + it("accepts correct totalAmount matching item sum", async () => { + const batch = makeBatch({ id: "batch-correct-total" }); + mockBatch.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...batch, items: [] }); + mockBatch.create.mockResolvedValue(batch); + mockBatch.update.mockResolvedValue({ ...batch, status: "completed" }); + + const result = await createSalaryBatch({ + userId: "user-1", + currency: "ACBU", + totalAmount: "500.00", + items: [ + { + recipientAddress: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "500.00", + }, + ], + }); + + expect(result.batchId).toBe("batch-correct-total"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("processSalaryBatch", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Array form: prisma.$transaction([prismaPromise1, prismaPromise2, ...]) + mockTransaction.mockResolvedValue([]); + }); + + it("marks batch completed when all transfers succeed", async () => { + const item1 = makeItem("batch-ok", { id: "item-1", status: "pending" }); + const item2 = makeItem("batch-ok", { id: "item-2", status: "pending" }); + const batch = makeBatch({ + id: "batch-ok", + status: "pending", + items: [item1, item2], + totalAmount: new Decimal("1000.00"), + }); + + mockBatch.findUnique.mockResolvedValue(batch); + mockBatch.update.mockResolvedValue({ ...batch, status: "completed" }); + mockItem.update.mockResolvedValue({} as any); + mockCreateTransfer.mockResolvedValue({ transactionId: "tx-1", status: "completed" }); + + await processSalaryBatch("batch-ok"); + + // First update: processing; second update: completed + expect(mockBatch.update).toHaveBeenCalledTimes(2); + const finalUpdate = mockBatch.update.mock.calls[1][0]; + expect(finalUpdate.data.status).toBe("completed"); + expect(finalUpdate.data.completedAt).toBeInstanceOf(Date); + }); + + it("marks batch partially_completed when some transfers fail", async () => { + const item1 = makeItem("batch-partial", { id: "item-ok", status: "pending" }); + const item2 = makeItem("batch-partial", { id: "item-fail", status: "pending" }); + const batch = makeBatch({ + id: "batch-partial", + status: "pending", + items: [item1, item2], + totalAmount: new Decimal("1000.00"), + }); + + mockBatch.findUnique.mockResolvedValue(batch); + mockBatch.update.mockResolvedValue({ ...batch }); + mockItem.update.mockResolvedValue({} as any); + + // First item succeeds, second fails (fulfilled-but-failed status) + mockCreateTransfer + .mockResolvedValueOnce({ transactionId: "tx-1", status: "completed" }) + .mockResolvedValueOnce({ transactionId: "tx-2", status: "failed" }); + + await processSalaryBatch("batch-partial"); + + const finalUpdate = mockBatch.update.mock.calls[1][0]; + expect(finalUpdate.data.status).toBe("partially_completed"); + expect(finalUpdate.data.completedAt).toBeNull(); + }); + + it("marks batch failed when all transfers fail", async () => { + const item = makeItem("batch-all-fail", { status: "pending" }); + const batch = makeBatch({ + id: "batch-all-fail", + status: "pending", + items: [item], + totalAmount: new Decimal("500.00"), + }); + + mockBatch.findUnique.mockResolvedValue(batch); + mockBatch.update.mockResolvedValue({ ...batch }); + mockItem.update.mockResolvedValue({} as any); + mockCreateTransfer.mockResolvedValue({ transactionId: "tx-1", status: "failed" }); + + await processSalaryBatch("batch-all-fail"); + + const finalUpdate = mockBatch.update.mock.calls[1][0]; + expect(finalUpdate.data.status).toBe("failed"); + }); + + it("skips already-completed items (resume support)", async () => { + const done = makeItem("batch-resume", { id: "item-done", status: "completed" }); + const pending = makeItem("batch-resume", { id: "item-pending", status: "pending" }); + const batch = makeBatch({ + id: "batch-resume", + status: "pending", + items: [done, pending], + totalAmount: new Decimal("1000.00"), + }); + + mockBatch.findUnique.mockResolvedValue(batch); + mockBatch.update.mockResolvedValue({ ...batch }); + mockItem.update.mockResolvedValue({} as any); + mockCreateTransfer.mockResolvedValue({ transactionId: "tx-new", status: "completed" }); + + await processSalaryBatch("batch-resume"); + + // createTransfer should only be called for the pending item + expect(mockCreateTransfer).toHaveBeenCalledTimes(1); + const finalUpdate = mockBatch.update.mock.calls[1][0]; + // 1 already completed + 1 newly completed = 2 = total + expect(finalUpdate.data.status).toBe("completed"); + }); + + it("writes failed item status via $transaction when transfer rejects", async () => { + const item = makeItem("batch-reject", { id: "item-reject", status: "pending" }); + const batch = makeBatch({ + id: "batch-reject", + status: "pending", + items: [item], + totalAmount: new Decimal("500.00"), + }); + + mockBatch.findUnique.mockResolvedValue(batch); + mockBatch.update.mockResolvedValue({ ...batch }); + mockItem.update.mockResolvedValue({} as any); + mockCreateTransfer.mockRejectedValue(new Error("Stellar network error")); + + await processSalaryBatch("batch-reject"); + + // $transaction should have been called to write the failure + expect(mockTransaction).toHaveBeenCalled(); + const finalUpdate = mockBatch.update.mock.calls[1][0]; + expect(finalUpdate.data.status).toBe("failed"); + }); + + it("returns early if batch is not found", async () => { + mockBatch.findUnique.mockResolvedValue(null); + + await processSalaryBatch("ghost-batch"); + + expect(mockBatch.update).not.toHaveBeenCalled(); + expect(mockCreateTransfer).not.toHaveBeenCalled(); + }); + + it("returns early if batch status is already completed", async () => { + const batch = makeBatch({ id: "batch-done", status: "completed", items: [] }); + mockBatch.findUnique.mockResolvedValue(batch); + + await processSalaryBatch("batch-done"); + + expect(mockBatch.update).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("getSalaryBatches", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns batches filtered by userId with default pagination", async () => { + const batch = { + ...makeBatch({ userId: "user-42" }), + _count: { items: 3 }, + }; + mockBatch.findMany.mockResolvedValue([batch]); + + const result = await getSalaryBatches({ userId: "user-42" }); + + expect(result).toHaveLength(1); + expect(mockBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 20, + skip: 0, + orderBy: { createdAt: "desc" }, + }), + ); + }); + + it("applies limit and offset", async () => { + mockBatch.findMany.mockResolvedValue([]); + + await getSalaryBatches({ organizationId: "org-1", limit: 5, offset: 10 }); + + expect(mockBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5, skip: 10 }), + ); + }); + + it("includes _count.items in the query", async () => { + mockBatch.findMany.mockResolvedValue([]); + + await getSalaryBatches({ userId: "user-1" }); + + const callArg = mockBatch.findMany.mock.calls[0][0]; + expect(callArg.include).toMatchObject({ _count: { select: { items: true } } }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("createSalarySchedule", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates a schedule and returns it", async () => { + const schedule = makeSchedule({ id: "sched-1" }); + mockSchedule.create.mockResolvedValue(schedule); + + const result = await createSalarySchedule({ + userId: "user-1", + organizationId: "org-1", + name: "Monthly Payroll", + cron: "0 0 * * *", + currency: "ACBU", + amountConfig: [ + { + recipient_address: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "500.00", + }, + ], + }); + + expect(result.id).toBe("sched-1"); + expect(mockSchedule.create).toHaveBeenCalledTimes(1); + + const createArg = mockSchedule.create.mock.calls[0][0]; + expect(createArg.data.status).toBe("active"); + expect(createArg.data.nextRunAt).toEqual(new Date("2026-09-02T00:00:00.000Z")); + }); + + it("throws AppError 400 for invalid cron expression (too few parts)", async () => { + await expect( + createSalarySchedule({ + userId: "user-1", + name: "Bad schedule", + cron: "* * *", // only 3 parts, needs 5 + currency: "ACBU", + amountConfig: [], + }), + ).rejects.toMatchObject({ statusCode: 400 }); + + expect(mockSchedule.create).not.toHaveBeenCalled(); + }); + + it("throws AppError 400 for empty cron string", async () => { + await expect( + createSalarySchedule({ + userId: "user-1", + name: "Bad schedule", + cron: "", + currency: "ACBU", + amountConfig: [], + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("defaults currency to ACBU when not provided", async () => { + const schedule = makeSchedule({ currency: "ACBU" }); + mockSchedule.create.mockResolvedValue(schedule); + + await createSalarySchedule({ + userId: "user-1", + name: "Payroll", + cron: "0 0 * * *", + amountConfig: [], + // no currency + }); + + const createArg = mockSchedule.create.mock.calls[0][0]; + expect(createArg.data.currency).toBe("ACBU"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("triggerSchedule", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTransaction.mockResolvedValue([]); + }); + + it("fires createSalaryBatch and updates lastRunAt/nextRunAt", async () => { + const schedule = makeSchedule({ id: "sched-trigger", cron: "0 0 * * *" }); + mockSchedule.findUnique.mockResolvedValue(schedule); + mockSchedule.update.mockResolvedValue(schedule); + + // createSalaryBatch internals + mockBatch.findUnique + .mockResolvedValueOnce(null) // idempotency check + .mockResolvedValueOnce({ ...makeBatch(), items: [] }); // processSalaryBatch + mockBatch.create.mockResolvedValue(makeBatch({ status: "pending" })); + mockBatch.update.mockResolvedValue(makeBatch({ status: "completed" })); + + await triggerSchedule("sched-trigger"); + + expect(mockBatch.create).toHaveBeenCalledTimes(1); + expect(mockSchedule.update).toHaveBeenCalledWith({ + where: { id: "sched-trigger" }, + data: { + lastRunAt: expect.any(Date), + nextRunAt: new Date("2026-09-03T00:00:00.000Z"), + }, + }); + }); + + it("returns early if schedule is not found", async () => { + mockSchedule.findUnique.mockResolvedValue(null); + + await triggerSchedule("ghost-schedule"); + + expect(mockBatch.create).not.toHaveBeenCalled(); + expect(mockSchedule.update).not.toHaveBeenCalled(); + }); + + it("returns early if schedule status is not active", async () => { + const schedule = makeSchedule({ status: "paused" }); + mockSchedule.findUnique.mockResolvedValue(schedule); + + await triggerSchedule(schedule.id); + + expect(mockBatch.create).not.toHaveBeenCalled(); + expect(mockSchedule.update).not.toHaveBeenCalled(); + }); + + it("uses a 60s future nextRunAt for non-daily cron expressions", async () => { + const schedule = makeSchedule({ + id: "sched-custom-cron", + cron: "*/5 * * * *", // every 5 minutes + amountConfig: [ + { + recipient_id: "rec-1", + recipient_address: "GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDE123456", + amount: "100.00", + }, + ], + }); + mockSchedule.findUnique.mockResolvedValue(schedule); + mockSchedule.update.mockResolvedValue(schedule); + mockBatch.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...makeBatch(), items: [] }); + mockBatch.create.mockResolvedValue(makeBatch({ status: "pending" })); + mockBatch.update.mockResolvedValue(makeBatch({ status: "completed" })); + + const before = Date.now(); + await triggerSchedule("sched-custom-cron"); + const after = Date.now(); + + const updateArg = mockSchedule.update.mock.calls[0][0]; + const nextRun = updateArg.data.nextRunAt as Date; + // nextRunAt should be ~60s in the future (within test timing tolerance of ±5s) + expect(nextRun.getTime()).toBeGreaterThanOrEqual(before + 55_000); + expect(nextRun.getTime()).toBeLessThanOrEqual(after + 65_000); + }); +});