diff --git a/src/__tests__/bank-accounts.test.ts b/src/__tests__/bank-accounts.test.ts new file mode 100644 index 0000000..2045b6a --- /dev/null +++ b/src/__tests__/bank-accounts.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import bankAccountsRoutes from "../routes/admin/bank-accounts"; +import prisma from "../config/database"; +import { config } from "../config/env"; + +// Fixture marker so cleanup never touches real data. +const N = "bank_accounts_test_"; + +let app: ReturnType; +let token: string; +const createdIds: number[] = []; + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(bankAccountsRoutes, { + prefix: "/api/admin/bank-accounts", + }); + + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); + + // Admin role bypasses the settings.banking permission guard. + const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } }); + if (!adminRole) + throw new Error("Test setup: no admin role found — seed the database"); + + const user = await prisma.users.create({ + data: { + username: `${N}user`, + email: `${N}user@test.local`, + password_hash: "x", + first_name: "Bank", + last_name: "Test", + is_active: true, + totp_enabled: false, + role_id: adminRole.id, + }, + }); + token = jwt.sign( + { sub: user.id, username: user.username, role: "admin" }, + config.jwt.secret, + { expiresIn: config.jwt.accessTokenExpiry }, + ); +}); + +afterAll(async () => { + if (createdIds.length) { + await prisma.bank_accounts + .deleteMany({ where: { id: { in: createdIds } } }) + .catch(() => {}); + } + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); + await app.close(); +}); + +const auth = { authorization: () => `Bearer ${token}` }; + +describe("bank accounts — flat-body create/update persists all fields", () => { + it("creates an account from a flat top-level body and persists every field", async () => { + const payload = { + account_name: `${N}Hlavní účet`, + bank_name: "Komerční banka", + account_number: "123456789/0100", + iban: "CZ6508000000192000145399", + bic: "KOMBCZPP", + currency: "CZK", + is_default: true, + }; + + const post = await app.inject({ + method: "POST", + url: "/api/admin/bank-accounts/", + headers: { authorization: auth.authorization() }, + payload, + }); + expect(post.statusCode).toBe(201); + const id = post.json().data.id as number; + expect(typeof id).toBe("number"); + createdIds.push(id); + + // Read back via the list endpoint and confirm NONE of the fields are blank. + const row = await prisma.bank_accounts.findUnique({ where: { id } }); + expect(row?.account_name).toBe(payload.account_name); + expect(row?.bank_name).toBe(payload.bank_name); + expect(row?.account_number).toBe(payload.account_number); + expect(row?.iban).toBe(payload.iban); + expect(row?.bic).toBe(payload.bic); + expect(row?.currency).toBe("CZK"); + expect(row?.is_default).toBe(true); + }); + + it("updates an account from a flat body that also carries the id", async () => { + const created = await prisma.bank_accounts.create({ + data: { account_name: `${N}orig`, bank_name: "Orig", currency: "CZK" }, + }); + createdIds.push(created.id); + + // Mirrors the client payload: spread fields + id (id routes the URL/method, + // is declared in the schema, and is ignored by the route which uses :id). + const put = await app.inject({ + method: "PUT", + url: `/api/admin/bank-accounts/${created.id}`, + headers: { authorization: auth.authorization() }, + payload: { + id: created.id, + account_name: `${N}upraveno`, + bank_name: "Nová banka", + iban: "CZ9999", + currency: "EUR", + is_default: false, + }, + }); + expect(put.statusCode).toBe(200); + + const row = await prisma.bank_accounts.findUnique({ + where: { id: created.id }, + }); + expect(row?.account_name).toBe(`${N}upraveno`); + expect(row?.bank_name).toBe("Nová banka"); + expect(row?.iban).toBe("CZ9999"); + expect(row?.currency).toBe("EUR"); + }); + + it("rejects the old nested {bank:{...}} body shape with 400 (no silent blank save)", async () => { + const post = await app.inject({ + method: "POST", + url: "/api/admin/bank-accounts/", + headers: { authorization: auth.authorization() }, + payload: { bank: { account_name: `${N}nested`, bank_name: "X" } }, + }); + expect(post.statusCode).toBe(400); + + // And nothing was persisted under that name. + const leaked = await prisma.bank_accounts.findFirst({ + where: { account_name: `${N}nested` }, + }); + expect(leaked).toBeNull(); + }); + + it("requires authentication", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/admin/bank-accounts/", + }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/src/admin/pages/CompanySettings.tsx b/src/admin/pages/CompanySettings.tsx index 0433bd6..2757abb 100644 --- a/src/admin/pages/CompanySettings.tsx +++ b/src/admin/pages/CompanySettings.tsx @@ -241,7 +241,7 @@ export default function CompanySettings({ }); const bankMutation = useApiMutation< - { id?: number; bank: BankForm }, + BankForm & { id?: number }, { message?: string } >({ url: (input) => @@ -432,8 +432,8 @@ export default function CompanySettings({ setBankSaving(true); try { const result = await bankMutation.mutateAsync({ + ...bankForm, id: editingBank ?? undefined, - bank: bankForm, }); alert.success(result?.message || "Uloženo"); resetBankForm(); diff --git a/src/schemas/bank-accounts.schema.ts b/src/schemas/bank-accounts.schema.ts index 9942c9d..b44dd7c 100644 --- a/src/schemas/bank-accounts.schema.ts +++ b/src/schemas/bank-accounts.schema.ts @@ -1,7 +1,13 @@ import { z } from "zod"; import { booleanFromForm, numberFromForm } from "./common"; -export const CreateBankAccountSchema = z.object({ +// `id` is declared (optional, ignored by the routes — they use the URL param) +// only so the client may send it in the body for URL/method routing without a +// strictObject rejection. strictObject is deliberate: a malformed/nested body +// (e.g. the old `{ id, bank: {...} }` shape) now 400s loudly instead of +// silently stripping every field and saving a blank record. +export const CreateBankAccountSchema = z.strictObject({ + id: z.number().int().optional(), account_name: z.string().max(255).nullish(), bank_name: z.string().max(255).nullish(), account_number: z.string().max(255).nullish(), @@ -12,7 +18,8 @@ export const CreateBankAccountSchema = z.object({ position: numberFromForm.optional().default(0), }); -export const UpdateBankAccountSchema = z.object({ +export const UpdateBankAccountSchema = z.strictObject({ + id: z.number().int().optional(), account_name: z.string().max(255).nullish(), bank_name: z.string().max(255).nullish(), account_number: z.string().max(255).nullish(),