Files
accounted/app/api/settings/booking-templates/__tests__/route.test.ts
T
MattssonandClaude Fable 5 57d4359d1a feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates

Users cannot delete or hide the 26 standard konteringspaket, which clutter
the settings panel and every template picker. Deletion stays off the table
(shared global rows); instead a company can now hide individual system
templates for itself only.

- New booking_template_hidden table (insert=hide, delete=unhide), RLS gated
  on active company + write role; nothing hidden by default
- POST/DELETE /api/settings/booking-templates/[id]/hide (system templates
  only; company/team templates keep their real delete path)
- List route decorates rows with per-company is_hidden; pickers filter them
  out; the settings panel shows hidden ones in a collapsed restore section
  so hiding is never silent
- Classified in full-archive-export exclusions (UI preference, not
  rakenskapsinformation)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL

* fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book

Skeptic + CodeRabbit findings on #2004, one pass:

- hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no
  UPDATE policy on purpose, so the DO UPDATE conflict arm turned a
  concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape
- bth_insert policy additionally requires the referenced template to be an
  active system template (migration is unmerged, edited in place); negative
  pg test for company templates
- BulkBookDialog excludes templates hidden by the company (was reading the
  table directly and ignoring hides)
- panel shows the failure toast when the hide/unhide fetch itself rejects
- picker category chips built from the hidden-filtered list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:12:47 +02:00

105 lines
3.6 KiB
TypeScript

/**
* Tests for GET /api/settings/booking-templates.
*
* Focused on the is_hidden decoration: every row carries the per-company flag,
* a failed hidden lookup falls back to "nothing hidden" (showing extra
* templates is the safe direction), and hidden rows are still RETURNED so the
* settings panel can offer restore; filtering is the pickers' job.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('11111111-1111-4111-8111-111111111111'),
requireCompanyId: vi.fn().mockResolvedValue('11111111-1111-4111-8111-111111111111'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { GET } from '../route'
/** Chainable builder resolving queued {data,error} per from() in call order. */
function createQueuedSupabase(results: { data?: unknown; error?: unknown }[]) {
let idx = 0
const makeBuilder = () => {
const result = results[idx++] ?? { data: null, error: null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b: any = {}
for (const m of ['select', 'eq', 'or', 'order', 'maybeSingle']) {
b[m] = () => b
}
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return b
}
return { from: () => makeBuilder() }
}
const TEMPLATES = [
{ id: 'tpl-1', name: 'Bankavgift', is_system: true },
{ id: 'tpl-2', name: 'Eget uttag', is_system: true },
]
beforeEach(() => {
vi.clearAllMocks()
})
function auth(supabase: unknown) {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
const req = () => createMockRequest('/api/settings/booking-templates', { method: 'GET' })
describe('GET /api/settings/booking-templates', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: {},
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await GET(req(), { params: Promise.resolve({}) })).status).toBe(401)
})
it('marks hidden templates but still returns them', async () => {
// from() order: companies, then library / usage / hidden.
const supabase = createQueuedSupabase([
{ data: { team_id: null } },
{ data: TEMPLATES },
{ data: [] },
{ data: [{ template_id: 'tpl-2' }] },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{
data: { id: string; is_hidden: boolean }[]
}>(await GET(req(), { params: Promise.resolve({}) }))
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data.find((t) => t.id === 'tpl-1')?.is_hidden).toBe(false)
expect(body.data.find((t) => t.id === 'tpl-2')?.is_hidden).toBe(true)
})
it('falls back to nothing hidden when the hidden lookup fails', async () => {
const supabase = createQueuedSupabase([
{ data: { team_id: null } },
{ data: TEMPLATES },
{ data: [] },
{ error: { message: 'boom' } },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{
data: { is_hidden: boolean }[]
}>(await GET(req(), { params: Promise.resolve({}) }))
expect(status).toBe(200)
expect(body.data.every((t) => t.is_hidden === false)).toBe(true)
})
})