// apps/hq/test/import-routes.test.ts — go-live cluster WS-B: APEX import over HTTP (D18) import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createStaff } from '../src/auth' import { apiRouter } from '../src/api' // Optional WS-F support columns ride the same CSV (anydesk/os/district/sector). const CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status,anydesk,os,district,sector\n' + 'APX001,Acme Bank,,32,Kochi,,r@acme.in,active,123456789,Windows 10,Ernakulam,Co-op\n' const BAD_CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status\n,No Code Bank,,32,,,,active\n' // Amounts are RUPEES in the APEX export; staging converts via fromRupees. // Current-FY doc date so the INVOICE series seeds past the legacy number. const INVOICES_CSV = 'client_code,doc_no,doc_date,taxable,tax,total,paid\nAPX001,INV/26-27-0042,2026-07-01,1000,180,1180,1\n' async function appWith() { const db = openDb(':memory:'); await seedIfEmpty(db) await createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' }) const app = express(); app.use(express.json({ limit: '5mb' })); app.locals['db'] = db app.use('/api', apiRouter(db)) const server = app.listen(0) const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, server, baseUrl } } const login = async (baseUrl: string, email: string, password: string) => ((await (await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json()) as { token: string }).token const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` }) describe('APEX import routes (owner-only)', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) it('staff get 403 on every import route', async () => { const ctx = await appWith(); servers.push(ctx.server) const t = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') for (const [method, path] of [['POST', '/import/stage'], ['GET', '/import/status'], ['POST', '/import/commit']] as const) { const res = await fetch(`${ctx.baseUrl}${path}`, { method, headers: H(t), ...(method === 'POST' ? { body: '{}' } : {}) }) expect(res.status).toBe(403) } }) it('stage → status → commit round-trip; problems lock the commit until re-staged clean', async () => { const ctx = await appWith(); servers.push(ctx.server) const t = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') // Stage a BAD clients file → problems reported, commit refused. const bad = await fetch(`${ctx.baseUrl}/import/stage`, { method: 'POST', headers: H(t), body: JSON.stringify({ clientsCsv: BAD_CLIENTS_CSV }), }) const badJson = await bad.json() as { report: { clients: { problems: number } } } expect(bad.status).toBe(200) expect(badJson.report.clients.problems).toBeGreaterThan(0) const refused = await fetch(`${ctx.baseUrl}/import/commit`, { method: 'POST', headers: H(t), body: '{}' }) expect(refused.status).toBe(400) // Re-stage clean + invoices → status shows zero problems → commit succeeds. const ok = await fetch(`${ctx.baseUrl}/import/stage`, { method: 'POST', headers: H(t), body: JSON.stringify({ clientsCsv: CLIENTS_CSV, invoicesCsv: INVOICES_CSV }), }) expect(ok.status).toBe(200) const status = await (await fetch(`${ctx.baseUrl}/import/status`, { headers: H(t) })).json() as { report: { clients: { problems: number }; invoices: { problems: number } } problemClients: unknown[] } expect(status.report.clients.problems).toBe(0) expect(status.report.invoices.problems).toBe(0) const commit = await fetch(`${ctx.baseUrl}/import/commit`, { method: 'POST', headers: H(t), body: '{}' }) const cj = await commit.json() as { result: { clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null } } expect(commit.status).toBe(200) expect(cj.result.clients).toBe(1) expect(cj.result.invoices).toBe(1) expect(cj.result.seeded).not.toBeNull() // INVOICE series seeded past the legacy number // The imported rows are real now — including the WS-F support-access fields. const imported = await ctx.db.get(`SELECT source, status FROM document WHERE doc_no='INV/26-27-0042'`) expect(imported).toMatchObject({ source: 'apex' }) const client = await ctx.db.get(`SELECT anydesk, os, district, sector FROM client WHERE code='APX001'`) expect(client).toEqual({ anydesk: '123456789', os: 'Windows 10', district: 'Ernakulam', sector: 'Co-op' }) }) })