* 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>
105 lines
3.6 KiB
TypeScript
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)
|
|
})
|
|
})
|