You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
123 lines
7.1 KiB
TypeScript
123 lines
7.1 KiB
TypeScript
// apps/hq/test/dashboard.test.ts
|
|
import { describe, it, expect, afterAll, beforeAll } from 'vitest'
|
|
import express from 'express'
|
|
import { openDb } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { createStaff } from '../src/auth'
|
|
import { apiRouter } from '../src/api'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { assignModule, createModule, setPrice, updateClientModule } from '../src/repos-modules'
|
|
import { createDraft, issueDocument } from '../src/repos-documents'
|
|
import { createInteraction } from '../src/repos-interactions'
|
|
import { dashboardView } from '../src/repos-dashboard'
|
|
import { runDailyScan, type ScanDeps } from '../src/scheduler'
|
|
|
|
const scanDeps: ScanDeps = {
|
|
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
|
|
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z',
|
|
}
|
|
|
|
async function seeded() {
|
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
|
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
|
|
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
|
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
|
|
await db.run(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`, inv.id)
|
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
|
await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
|
|
await createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
|
|
return { db, c, inv }
|
|
}
|
|
|
|
describe('dashboardView', () => {
|
|
it('aggregates overdue, renewals, follow-ups and the reminder queue', async () => {
|
|
const { db, inv } = await seeded()
|
|
await runDailyScan(db, scanDeps, '2026-07-10') // populates the queue
|
|
const view = await dashboardView(db, '2026-07-10')
|
|
expect(view.overdue.map((o) => o.docId)).toContain(inv.id)
|
|
expect(view.overdue[0]!.outstandingPaise).toBe(11_800_00)
|
|
expect(view.renewalsThisMonth).toHaveLength(1)
|
|
expect(view.followUpsToday).toHaveLength(1)
|
|
expect(view.queue.length).toBeGreaterThanOrEqual(3) // overdue + renewal + follow_up
|
|
expect(view.totals.overduePaise).toBe(11_800_00)
|
|
})
|
|
|
|
it('anchors overdue on the stamped due date, not the issue date (D18 WS-A)', async () => {
|
|
const { db, inv } = await seeded()
|
|
// Issued 2026-06-01 but not due until 2026-07-20 → NOT overdue on 2026-07-10.
|
|
await db.run(`UPDATE document SET due_date='2026-07-20' WHERE id=?`, inv.id)
|
|
expect((await dashboardView(db, '2026-07-10')).overdue).toHaveLength(0)
|
|
// Due date passed → overdue, aged from the due date (5 days, not 39).
|
|
await db.run(`UPDATE document SET due_date='2026-07-05' WHERE id=?`, inv.id)
|
|
const view = await dashboardView(db, '2026-07-10')
|
|
expect(view.overdue.map((o) => o.docId)).toContain(inv.id)
|
|
expect(view.overdue[0]!.daysOverdue).toBe(5)
|
|
})
|
|
})
|
|
|
|
describe('GET /api/dashboard + reminder queue routes', () => {
|
|
async function routeCtx() {
|
|
const { db } = await seeded()
|
|
await createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' })
|
|
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
|
|
const server = app.listen(0)
|
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
|
return { db, server, base }
|
|
}
|
|
let ctx: Awaited<ReturnType<typeof routeCtx>>
|
|
beforeAll(async () => { ctx = await routeCtx() })
|
|
let token = ''
|
|
const call = async (method: string, path: string, body?: unknown) => {
|
|
const res = await fetch(ctx.base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
|
|
return { status: res.status, json: await res.json() as any }
|
|
}
|
|
afterAll(() => ctx.server.close())
|
|
it('serves the dashboard, lists the queue and dismisses a reminder', async () => {
|
|
token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token
|
|
await runDailyScan(ctx.db, scanDeps, '2026-07-10')
|
|
const dash = (await call('GET', '/dashboard')).json
|
|
expect(dash.ok).toBe(true)
|
|
expect(dash.view.overdue.length).toBeGreaterThanOrEqual(1)
|
|
const queue = (await call('GET', '/reminders')).json.reminders
|
|
expect(queue.length).toBeGreaterThanOrEqual(1)
|
|
const dismissed = (await call('POST', `/reminders/${queue[0].id}/dismiss`)).json
|
|
expect(dismissed.reminder.status).toBe('dismissed')
|
|
})
|
|
})
|
|
|
|
describe('My Day scoping (D18 WS-E)', () => {
|
|
it('staff are forced to their own book; managerial mine=true narrows; default managerial view is everyone', async () => {
|
|
const { openDb } = await import('../src/db')
|
|
const { seedIfEmpty } = await import('../src/seed')
|
|
const { createStaff } = await import('../src/auth')
|
|
const { createClient } = await import('../src/repos-clients')
|
|
const { setClientOwner } = await import('../src/repos-clients')
|
|
const { createModule, setPrice, assignModule, updateClientModule } = await import('../src/repos-modules')
|
|
const { dashboardView } = await import('../src/repos-dashboard')
|
|
|
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
|
const { id: staffId } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'password-9' })
|
|
const { id: mgrId } = await createStaff(db, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password-9' })
|
|
const mine = await createClient(db, 'u1', { name: 'Mine Bank', stateCode: '32' })
|
|
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
|
await setClientOwner(db, mgrId, mine.id, staffId)
|
|
const m = await createModule(db, 'u1', { code: 'POS2', name: 'POS2' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_000_00, effectiveFrom: '2026-01-01' })
|
|
const today = '2026-07-17'
|
|
for (const c of [mine, other]) {
|
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
|
await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-25' })
|
|
}
|
|
// Staff: only the client they own shows in renewals.
|
|
const staffView = await dashboardView(db, today, { id: staffId, role: 'staff' })
|
|
expect(staffView.renewalsThisMonth.map((r) => r.clientName)).toEqual(['Mine Bank'])
|
|
// Managerial default: everyone.
|
|
const mgrAll = await dashboardView(db, today, { id: mgrId, role: 'manager' })
|
|
expect(mgrAll.renewalsThisMonth).toHaveLength(2)
|
|
// Managerial My Day: narrowed to their own book (they own nothing → empty).
|
|
const mgrMine = await dashboardView(db, today, { id: mgrId, role: 'manager' }, { mine: true })
|
|
expect(mgrMine.renewalsThisMonth).toHaveLength(0)
|
|
})
|
|
})
|