feat(db): phase 4 group 1 — lib/ settings + notifications to Drizzle
Convert lib/settings.ts, lib/notifications/{in-app,email,capa-escalation,
effectiveness-recheck}.ts from Supabase PostgREST to Drizzle asAdmin queries.
Drop supabase arg from all call sites in app/api/ and cron routes. Rewrite
notification unit tests to mock @/lib/db/with-user instead of SupabaseClient.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { getEscalationThreshold, escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
||||
|
||||
@@ -17,6 +18,16 @@ vi.mock('@/lib/settings', () => ({
|
||||
getApiKey: vi.fn().mockResolvedValue('test-cred'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/notifications/in-app', () => ({
|
||||
createInAppNotifications: vi.fn().mockResolvedValue({ created: 1 }),
|
||||
}))
|
||||
|
||||
// At top of file, before other vi.mock calls
|
||||
let mockAsAdminImpl: (fn: (db: unknown) => unknown) => unknown
|
||||
vi.mock('@/lib/db/with-user', () => ({
|
||||
asAdmin: vi.fn().mockImplementation((fn: (db: unknown) => unknown) => mockAsAdminImpl(fn)),
|
||||
}))
|
||||
|
||||
describe('getEscalationThreshold', () => {
|
||||
function daysFromNow(n: number): string {
|
||||
const d = new Date()
|
||||
@@ -49,106 +60,60 @@ describe('getEscalationThreshold', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('escalateOverdueCapa — WhatsApp', () => {
|
||||
describe('escalateOverdueCapa', () => {
|
||||
function daysFromNow(n: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + n)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function makeSupabaseMock(overrides: {
|
||||
capas?: unknown[]
|
||||
alreadySent?: unknown
|
||||
hseUsers?: unknown[]
|
||||
}) {
|
||||
const { capas = [], alreadySent = null, hseUsers = [] } = overrides
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
// Each .from() call returns a fresh chainable builder
|
||||
// We track call order: first from('capa_actions'), then from('notifications_log'), then from('users'), then from('notifications_log').insert
|
||||
let fromCallIndex = 0
|
||||
it('returns notified:0 when no active capas', async () => {
|
||||
let call = 0
|
||||
mockAsAdminImpl = (fn) => {
|
||||
call++
|
||||
if (call === 1) return fn({ select: () => ({ from: () => ({ leftJoin: () => ({ leftJoin: () => ({ where: () => [] }) }) }) }) })
|
||||
return fn({})
|
||||
}
|
||||
const { notified } = await escalateOverdueCapa()
|
||||
expect(notified).toBe(0)
|
||||
})
|
||||
|
||||
const makeChain = (resolvedValue: unknown) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
const methods = ['select', 'not', 'eq', 'in', 'limit', 'maybeSingle', 'insert']
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain)
|
||||
it('skips capa with no threshold match', async () => {
|
||||
let call = 0
|
||||
mockAsAdminImpl = (fn) => {
|
||||
call++
|
||||
if (call === 1) {
|
||||
// Return capa due in 2 days (no threshold)
|
||||
return Promise.resolve([{
|
||||
id: 'c1', description: 'Fix it', dueDate: daysFromNow(2),
|
||||
incidentId: 'i1', ownerUserId: 'u1',
|
||||
incidentRef: 'SITE-202507-0001', siteId: 's1',
|
||||
ownerEmail: 'owner@example.com', ownerName: 'Owner', ownerPhone: null,
|
||||
}])
|
||||
}
|
||||
// Terminal: awaiting the chain resolves to resolvedValue
|
||||
Object.defineProperty(chain, 'then', {
|
||||
get() {
|
||||
return (resolve: (v: unknown) => unknown) => Promise.resolve(resolvedValue).then(resolve)
|
||||
},
|
||||
})
|
||||
return chain
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn(() => {
|
||||
const index = fromCallIndex++
|
||||
if (index === 0) return makeChain({ data: capas, error: null }) // capa_actions
|
||||
if (index === 1) return makeChain({ data: alreadySent, error: null }) // notifications_log check
|
||||
if (index === 2) return makeChain({ data: hseUsers, error: null }) // hse users for CC
|
||||
return makeChain({ data: null, error: null }) // notifications_log insert
|
||||
}),
|
||||
}
|
||||
|
||||
return supabase as unknown as import('@supabase/supabase-js').SupabaseClient
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const { notified } = await escalateOverdueCapa()
|
||||
expect(notified).toBe(0)
|
||||
})
|
||||
|
||||
it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => {
|
||||
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
||||
|
||||
const supabase = makeSupabaseMock({
|
||||
capas: [
|
||||
{
|
||||
id: 'capa-1',
|
||||
description: 'Fix safety barrier',
|
||||
due_date: daysFromNow(-7),
|
||||
incident_id: 'inc-1',
|
||||
incidents: { reference_no: 'KL-202501-0001', site_id: 'site-1' },
|
||||
owner: { email: 'owner@test.com', name: 'Alice', phone: '60123456789' },
|
||||
},
|
||||
],
|
||||
alreadySent: null,
|
||||
hseUsers: [],
|
||||
})
|
||||
|
||||
await escalateOverdueCapa(supabase)
|
||||
|
||||
expect(mockWA).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\d+$/),
|
||||
'ims_capa_overdue',
|
||||
expect.arrayContaining([expect.any(String)]),
|
||||
'test-cred',
|
||||
'test-cred',
|
||||
)
|
||||
})
|
||||
|
||||
it('does NOT send WhatsApp when threshold is warning_3d', async () => {
|
||||
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
||||
vi.clearAllMocks()
|
||||
|
||||
const supabase = makeSupabaseMock({
|
||||
capas: [
|
||||
{
|
||||
id: 'capa-2',
|
||||
description: 'Inspect equipment',
|
||||
due_date: daysFromNow(3),
|
||||
incident_id: 'inc-2',
|
||||
incidents: { reference_no: 'KL-202501-0002', site_id: 'site-1' },
|
||||
owner: { email: 'owner@test.com', name: 'Bob', phone: '60129876543' },
|
||||
},
|
||||
],
|
||||
alreadySent: null,
|
||||
hseUsers: [],
|
||||
})
|
||||
|
||||
await escalateOverdueCapa(supabase)
|
||||
|
||||
expect(mockWA).not.toHaveBeenCalled()
|
||||
it('sends email and WhatsApp for overdue_3d capa with phone', async () => {
|
||||
const { sendWhatsAppMessage } = await import('@/lib/notifications/whatsapp')
|
||||
let call = 0
|
||||
mockAsAdminImpl = (fn) => {
|
||||
call++
|
||||
if (call === 1) return Promise.resolve([{
|
||||
id: 'c1', description: 'Fix it', dueDate: daysFromNow(-3),
|
||||
incidentId: 'i1', ownerUserId: 'u1',
|
||||
incidentRef: 'SITE-202507-0001', siteId: 's1',
|
||||
ownerEmail: 'owner@example.com', ownerName: 'Owner', ownerPhone: '+60123456789',
|
||||
}])
|
||||
return Promise.resolve([]) // alreadySent = [], hseUsers = [], insert void
|
||||
}
|
||||
const { notified } = await escalateOverdueCapa()
|
||||
expect(notified).toBe(1)
|
||||
expect(sendWhatsAppMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user