diff --git a/apps/backend/src/app/api/deployments/route.test.ts b/apps/backend/src/app/api/deployments/route.test.ts index c816a37..87a0e52 100644 --- a/apps/backend/src/app/api/deployments/route.test.ts +++ b/apps/backend/src/app/api/deployments/route.test.ts @@ -78,6 +78,23 @@ function makeTableMock(results: { data: unknown; error: unknown; count?: number }; } +function get(url: string, headers?: Record) { + return new NextRequest(url, { + method: 'GET', + headers: headers ?? {}, + }); +} + +function postWithVersion(url: string, body: unknown, version?: string) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (version) headers['API-Version'] = version; + return new NextRequest(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + describe('POST /api/deployments', () => { beforeEach(() => { vi.clearAllMocks(); @@ -199,3 +216,176 @@ describe('POST /api/deployments', () => { expect(body.status).toBe('generating'); }); }); + +describe('GET /api/deployments', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetUser.mockResolvedValue({ data: { user: fakeUser }, error: null }); + }); + + it('returns 401 when unauthenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments'), { params: {} as any }); + expect(res.status).toBe(401); + }); + + it('returns user deployments', async () => { + const deploymentRows = [ + { id: 'dep-1', name: 'App 1', status: 'deployed', template_id: 'tpl-1', created_at: '2024-01-01', updated_at: '2024-01-02', deployed_at: '2024-01-02', deployment_url: 'https://app-1.vercel.app' }, + ]; + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: deploymentRows, error: null }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments'), { params: {} as any }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.deployments).toHaveLength(1); + expect(body.deployments[0].id).toBe('dep-1'); + expect(body.deployments[0].templateId).toBe('tpl-1'); + }); + + it('returns 500 when supabase query fails', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: null, error: { message: 'db error' } }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments'), { params: {} as any }); + expect(res.status).toBe(500); + }); +}); + +describe('API versioning on /api/deployments', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetUser.mockResolvedValue({ data: { user: fakeUser }, error: null }); + }); + + it('includes X-API-Version header in response', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments'), { params: {} as any }); + expect(res.headers.get('X-API-Version')).toBe('v1'); + }); + + it('includes X-Latest-Version header in response', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments'), { params: {} as any }); + expect(res.headers.get('X-Latest-Version')).toBe('v1'); + }); + + it('defaults to v1 when API-Version header is absent', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments'), { params: {} as any }); + expect(res.headers.get('X-API-Version')).toBe('v1'); + expect(res.status).toBe(200); + }); + + it('accepts API-Version: v1 explicitly', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments', { 'API-Version': 'v1' }), { params: {} as any }); + expect(res.status).toBe(200); + expect(res.headers.get('X-API-Version')).toBe('v1'); + }); + + it('returns 400 with supportedVersions for unknown version', async () => { + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments', { 'API-Version': 'v99' }), { params: {} as any }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/Unsupported API version/); + expect(body.supportedVersions).toEqual(['v1']); + }); + + it('does not set Deprecation header for current version (v1)', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'deployments') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + return makeTableMock([]); + }); + const { GET } = await import('./route'); + const res = await GET(get('http://localhost/api/deployments', { 'API-Version': 'v1' }), { params: {} as any }); + expect(res.headers.get('Deprecation')).toBeNull(); + }); + + it('returns 400 with supportedVersions for unknown POST version', async () => { + const { POST } = await import('./route'); + const res = await POST(postWithVersion('http://localhost/api/deployments', { templateId: 'tpl-1' }, 'v99'), { params: {} as any }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.supportedVersions).toEqual(['v1']); + }); +}); diff --git a/apps/backend/src/app/api/deployments/route.ts b/apps/backend/src/app/api/deployments/route.ts index c9a65d3..3bdc142 100644 --- a/apps/backend/src/app/api/deployments/route.ts +++ b/apps/backend/src/app/api/deployments/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { withAuth } from '@/lib/api/with-auth'; +import { ApiVersionRouter } from '@/lib/api/versioning'; import { getEntitlements } from '@/lib/stripe/pricing'; import type { SubscriptionTier } from '@craft/types'; import { @@ -28,137 +29,192 @@ function normalizeRequestBody(raw: unknown): RequestBody | null { }; } -export const POST = withAuth(async (req: NextRequest, { supabase, user }) => { - let body: RequestBody; - try { - const raw = await req.json(); - const normalized = normalizeRequestBody(raw); - if (!normalized) { +// ── Versioned Router ───────────────────────────────────────────────────────── + +const deploymentRouter = new ApiVersionRouter({ + supportedVersions: ['v1'], + currentVersion: 'v1', +}); + +// GET /api/deployments — list user's deployments (v1) +deploymentRouter.register('GET', { + supportedVersions: ['v1'], + handler: async (_req: NextRequest, { supabase, user }: any) => { + const { data: deployments, error } = await supabase + .from('deployments') + .select('id, name, status, template_id, created_at, updated_at, deployed_at, deployment_url') + .eq('user_id', user.id) + .order('created_at', { ascending: false }); + + if (error) { return NextResponse.json( - { error: 'Invalid request body' }, - { status: 400 } + { error: 'Failed to fetch deployments' }, + { status: 500 }, ); } - body = normalized; - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - // Verify template exists and is active - const { data: template, error: tplErr } = await supabase - .from('templates') - .select('id, name') - .eq('id', body.templateId) - .eq('is_active', true) - .single(); - - if (tplErr || !template) { - return NextResponse.json({ error: 'Template not found' }, { status: 404 }); - } - - // Enforce deployment count limit based on subscription tier - const { data: profile } = await supabase - .from('profiles') - .select('subscription_tier') - .eq('id', user.id) - .single(); - - const tier = ((profile?.subscription_tier as SubscriptionTier) ?? 'free'); - const { maxDeployments } = getEntitlements(tier); - - if (maxDeployments !== -1) { - const { count } = await supabase - .from('deployments') - .select('id', { count: 'exact', head: true }) - .eq('user_id', user.id) - .eq('is_active', true); - if ((count ?? 0) >= maxDeployments) { + return NextResponse.json({ + deployments: (deployments ?? []).map((d: any) => ({ + id: d.id, + name: d.name, + status: d.status, + templateId: d.template_id, + createdAt: d.created_at, + updatedAt: d.updated_at, + deployedAt: d.deployed_at, + deploymentUrl: d.deployment_url, + })), + }); + }, +}); + +// POST /api/deployments — create deployment (v1) +deploymentRouter.register('POST', { + supportedVersions: ['v1'], + handler: async (req: NextRequest, { supabase, user }: any) => { + let body: RequestBody; + try { + const raw = await req.json(); + const normalized = normalizeRequestBody(raw); + if (!normalized) { + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 }, + ); + } + body = normalized; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Verify template exists and is active + const { data: template, error: tplErr } = await supabase + .from('templates') + .select('id, name') + .eq('id', body.templateId) + .eq('is_active', true) + .single(); + + if (tplErr || !template) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } + + // Enforce deployment count limit based on subscription tier + const { data: profile } = await supabase + .from('profiles') + .select('subscription_tier') + .eq('id', user.id) + .single(); + + const tier = ((profile?.subscription_tier as SubscriptionTier) ?? 'free'); + const { maxDeployments } = getEntitlements(tier); + + if (maxDeployments !== -1) { + const { count } = await supabase + .from('deployments') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .eq('is_active', true); + + if ((count ?? 0) >= maxDeployments) { + return NextResponse.json( + { + error: `Deployment limit reached. Your ${tier} plan allows ${maxDeployments} active deployment${maxDeployments !== 1 ? 's' : ''}.`, + upgradeUrl: '/pricing', + }, + { status: 403 }, + ); + } + } + + const customization = body.customizationConfig ?? {}; + + // Validate customization config shape and business rules + const validation = validateCustomizationConfig(customization); + if (!validation.valid) { return NextResponse.json( - { - error: `Deployment limit reached. Your ${tier} plan allows ${maxDeployments} active deployment${maxDeployments !== 1 ? 's' : ''}.`, - upgradeUrl: '/pricing', - }, - { status: 403 } + { error: 'Invalid customization config', details: validation.errors }, + { status: 422 }, ); } - } - - const customization = body.customizationConfig ?? {}; - - // Validate customization config shape and business rules - const validation = validateCustomizationConfig(customization); - if (!validation.valid) { - return NextResponse.json( - { error: 'Invalid customization config', details: validation.errors }, - { status: 422 } - ); - } - - // Validate stellar endpoints reachability (async). If invalid, return details. - try { - const endpointValidation = await validateStellarEndpoints( - customization as any, - { timeout: 3000 } - ); - if (!endpointValidation.valid) { + + // Validate stellar endpoints reachability (async). If invalid, return details. + try { + const endpointValidation = await validateStellarEndpoints( + customization as any, + { timeout: 3000 }, + ); + if (!endpointValidation.valid) { + return NextResponse.json( + { + error: 'Invalid customization endpoints', + details: endpointValidation.errors, + }, + { status: 422 }, + ); + } + } catch (err: any) { + // Treat connectivity errors as transient server errors return NextResponse.json( + { error: err?.message ?? 'Endpoint validation failed' }, + { status: 500 }, + ); + } + + // Create deployment record + const deploymentId = crypto.randomUUID(); + const name = body.name ?? (template.name as string); + + const { data: inserted, error: insertErr } = await supabase + .from('deployments') + .insert([ { - error: 'Invalid customization endpoints', - details: endpointValidation.errors, + id: deploymentId, + user_id: user.id, + template_id: body.templateId, + name, + customization_config: customization as any, + status: 'pending', }, - { status: 422 } + ]) + .select() + .single(); + + if (insertErr || !inserted) { + return NextResponse.json( + { error: insertErr?.message ?? 'Failed to create deployment' }, + { status: 500 }, ); } - } catch (err: any) { - // Treat connectivity errors as transient server errors - return NextResponse.json( - { error: err?.message ?? 'Endpoint validation failed' }, - { status: 500 } - ); - } - - // Create deployment record - const deploymentId = crypto.randomUUID(); - const name = body.name ?? (template.name as string); - - const { data: inserted, error: insertErr } = await supabase - .from('deployments') - .insert([ - { - id: deploymentId, - user_id: user.id, - template_id: body.templateId, - name, - customization_config: customization as any, - status: 'pending', - }, - ]) - .select() - .single(); - - if (insertErr || !inserted) { - return NextResponse.json( - { error: insertErr?.message ?? 'Failed to create deployment' }, - { status: 500 } - ); - } - - // Mark deployment as generating to indicate the pipeline has started/enqueued. - await supabase - .from('deployments') - .update({ status: 'generating', updated_at: new Date().toISOString() }) - .eq('id', deploymentId); - - const created = { - id: inserted.id, - templateId: inserted.template_id, - userId: inserted.user_id, - name: inserted.name, - customizationConfig: inserted.customization_config, - status: 'generating', - createdAt: inserted.created_at, - }; - return NextResponse.json(created, { status: 201 }); + // Mark deployment as generating to indicate the pipeline has started/enqueued. + await supabase + .from('deployments') + .update({ status: 'generating', updated_at: new Date().toISOString() }) + .eq('id', deploymentId); + + const created = { + id: inserted.id, + templateId: inserted.template_id, + userId: inserted.user_id, + name: inserted.name, + customizationConfig: inserted.customization_config, + status: 'generating', + createdAt: inserted.created_at, + }; + + return NextResponse.json(created, { status: 201 }); + }, }); + +// ── Route Exports ───────────────────────────────────────────────────────────── + +export const GET = withAuth(async (req: NextRequest, ctx: any) => + deploymentRouter.handle(req, 'GET', ctx), +); + +export const POST = withAuth(async (req: NextRequest, ctx: any) => + deploymentRouter.handle(req, 'POST', ctx), +); + +export { deploymentRouter }; diff --git a/apps/backend/src/lib/api/versioning.test.ts b/apps/backend/src/lib/api/versioning.test.ts new file mode 100644 index 0000000..2b5a967 --- /dev/null +++ b/apps/backend/src/lib/api/versioning.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; +import { ApiVersionRouter } from '@/lib/api/versioning'; + +describe('ApiVersionRouter', () => { + it('throws if no supported versions are provided', () => { + expect( + () => new ApiVersionRouter({ supportedVersions: [], currentVersion: 'v1' }), + ).toThrow('At least one supported version is required'); + }); + + it('throws if currentVersion is not in supportedVersions', () => { + expect( + () => new ApiVersionRouter({ supportedVersions: ['v1'], currentVersion: 'v2' }), + ).toThrow('currentVersion must be in supportedVersions'); + }); + + it('returns supported versions from config', () => { + const router = new ApiVersionRouter({ supportedVersions: ['v1'], currentVersion: 'v1' }); + expect(router.getSupportedVersions()).toEqual(['v1']); + }); + + it('returns current version from config', () => { + const router = new ApiVersionRouter({ supportedVersions: ['v1'], currentVersion: 'v1' }); + expect(router.getCurrentVersion()).toBe('v1'); + }); + + describe('version resolution', () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1'], + currentVersion: 'v1', + }); + + router.register('GET', { + supportedVersions: ['v1'], + handler: async () => NextResponse.json({ ok: true }), + }); + + it('defaults to current version when API-Version header is absent', async () => { + const req = new NextRequest('http://localhost/api/test', { method: 'GET' }); + const res = await router.handle(req, 'GET', {}); + expect(res.status).toBe(200); + expect(res.headers.get('X-API-Version')).toBe('v1'); + }); + + it('uses the API-Version header when present and valid', async () => { + const req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v1' }, + }); + const res = await router.handle(req, 'GET', {}); + expect(res.status).toBe(200); + expect(res.headers.get('X-API-Version')).toBe('v1'); + }); + + it('returns 400 with supportedVersions list for unknown version', async () => { + const req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v99' }, + }); + const res = await router.handle(req, 'GET', {}); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/Unsupported API version: v99/); + expect(body.supportedVersions).toEqual(['v1']); + }); + }); + + describe('deprecation headers', () => { + it('does not set Deprecation header when on current version', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1'], + currentVersion: 'v1', + }); + router.register('GET', { + supportedVersions: ['v1'], + handler: async () => NextResponse.json({ ok: true }), + }); + + const req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v1' }, + }); + const res = await router.handle(req, 'GET', {}); + expect(res.headers.get('Deprecation')).toBeNull(); + }); + + it('sets Deprecation header when on non-current version', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1', 'v2'], + currentVersion: 'v2', + }); + router.register('GET', { + supportedVersions: ['v1'], + handler: async () => NextResponse.json({ ok: true }), + }); + + const req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v1' }, + }); + const res = await router.handle(req, 'GET', {}); + expect(res.headers.get('Deprecation')).toBe('true'); + expect(res.headers.get('X-API-Upgrade-Available')).toBe('v2'); + }); + + it('sets Deprecation and Sunset headers for deprecated handlers', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1', 'v2'], + currentVersion: 'v2', + }); + router.register('GET', { + supportedVersions: ['v1'], + deprecated: true, + deprecatedSince: 'v2', + replacedBy: '/api/v2/test', + handler: async () => NextResponse.json({ ok: true }), + }); + + const req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v1' }, + }); + const res = await router.handle(req, 'GET', {}); + expect(res.headers.get('Deprecation')).toBe('true'); + expect(res.headers.get('Sunset')).toBe('v2'); + }); + }); + + describe('handler dispatch', () => { + it('returns 404 when no handler matches the method+version', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1', 'v2'], + currentVersion: 'v2', + }); + router.register('GET', { + supportedVersions: ['v2'], + handler: async () => NextResponse.json({ ok: true }), + }); + + const req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v1' }, + }); + const res = await router.handle(req, 'GET', {}); + expect(res.status).toBe(404); + }); + + it('dispatches to the correct handler based on version', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1', 'v2'], + currentVersion: 'v2', + }); + router.register('GET', { + supportedVersions: ['v1'], + handler: async () => NextResponse.json({ version: 'v1' }), + }); + router.register('GET', { + supportedVersions: ['v2'], + handler: async () => NextResponse.json({ version: 'v2' }), + }); + + const v1Req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v1' }, + }); + const v1Res = await router.handle(v1Req, 'GET', {}); + const v1Body = await v1Res.json(); + expect(v1Body.version).toBe('v1'); + + const v2Req = new NextRequest('http://localhost/api/test', { + method: 'GET', + headers: { 'API-Version': 'v2' }, + }); + const v2Res = await router.handle(v2Req, 'GET', {}); + const v2Body = await v2Res.json(); + expect(v2Body.version).toBe('v2'); + }); + + it('passes context to the handler', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1'], + currentVersion: 'v1', + }); + let receivedCtx: any; + router.register('GET', { + supportedVersions: ['v1'], + handler: async (_req: any, ctx: any) => { + receivedCtx = ctx; + return NextResponse.json({ ok: true }); + }, + }); + + const req = new NextRequest('http://localhost/api/test', { method: 'GET' }); + await router.handle(req, 'GET', { userId: 'test-user' }); + expect(receivedCtx.userId).toBe('test-user'); + }); + }); + + describe('response headers', () => { + it('always sets X-API-Version and X-Latest-Version', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1'], + currentVersion: 'v1', + }); + router.register('GET', { + supportedVersions: ['v1'], + handler: async () => NextResponse.json({ ok: true }), + }); + + const req = new NextRequest('http://localhost/api/test', { method: 'GET' }); + const res = await router.handle(req, 'GET', {}); + expect(res.headers.get('X-API-Version')).toBe('v1'); + expect(res.headers.get('X-Latest-Version')).toBe('v1'); + }); + }); +}); diff --git a/apps/backend/src/lib/api/versioning.ts b/apps/backend/src/lib/api/versioning.ts new file mode 100644 index 0000000..612db5e --- /dev/null +++ b/apps/backend/src/lib/api/versioning.ts @@ -0,0 +1,155 @@ +/** + * API Versioning Router + * + * Provides version negotiation for CRAFT API endpoints via the `API-Version` + * request header. The router validates the requested version against the + * supported set, dispatches to the correct handler, and stamps response + * headers (`X-API-Version`, `X-Latest-Version`, `Deprecation`, + * `X-API-Upgrade-Available`). + * + * Usage: + * const router = new ApiVersionRouter({ + * supportedVersions: ['v1'], + * currentVersion: 'v1', + * }); + * + * router.register('GET', { + * supportedVersions: ['v1'], + * handler: async (req, ctx) => NextResponse.json({ ... }), + * }); + * + * // In the route file: + * export const GET = withAuth(async (req, ctx) => router.handle(req, 'GET', ctx)); + */ + +import { NextRequest, NextResponse } from 'next/server'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type ApiVersion = string; + +export interface VersionedHandler { + /** Versions this handler can serve. */ + supportedVersions: ApiVersion[]; + /** Whether this versioned endpoint is deprecated. */ + deprecated?: boolean; + /** Version in which the endpoint was deprecated. */ + deprecatedSince?: ApiVersion; + /** Replacement endpoint path, included in deprecation warnings. */ + replacedBy?: string; + /** The actual route handler. */ + handler: (req: NextRequest, ctx: any) => Promise; +} + +export interface ApiVersionRouterConfig { + /** All versions the API currently supports (e.g. ['v1']). */ + supportedVersions: ApiVersion[]; + /** The current / latest version — used for deprecation headers. */ + currentVersion: ApiVersion; +} + +// ── Router ─────────────────────────────────────────────────────────────────── + +export class ApiVersionRouter { + private handlers: Map = new Map(); + private config: ApiVersionRouterConfig; + + constructor(config: ApiVersionRouterConfig) { + if (!config.supportedVersions.length) { + throw new Error('At least one supported version is required'); + } + if (!config.supportedVersions.includes(config.currentVersion)) { + throw new Error('currentVersion must be in supportedVersions'); + } + this.config = config; + } + + /** Register a versioned handler for an HTTP method. */ + register(method: string, handler: VersionedHandler): void { + const key = method.toUpperCase(); + if (!this.handlers.has(key)) { + this.handlers.set(key, []); + } + this.handlers.get(key)!.push(handler); + } + + /** + * Resolve, validate, and dispatch an incoming request to the correct + * versioned handler, then stamp versioning response headers. + */ + async handle(req: NextRequest, method: string, ctx: any): Promise { + const { version, valid, requested } = this.resolveVersion(req); + + // Unknown version → 400 with supported-versions list + if (!valid) { + return NextResponse.json( + { + error: `Unsupported API version: ${requested}`, + supportedVersions: this.config.supportedVersions, + }, + { status: 400 }, + ); + } + + // Find matching handler for this method + version + const methodHandlers = this.handlers.get(method.toUpperCase()) ?? []; + const handler = methodHandlers.find((h) => h.supportedVersions.includes(version!)); + + if (!handler) { + return NextResponse.json( + { + error: `${method.toUpperCase()} not available for version ${version}`, + supportedVersions: this.config.supportedVersions, + }, + { status: 404 }, + ); + } + + // Execute the handler + const response = await handler.handler(req, ctx); + + // Stamp version headers + response.headers.set('X-API-Version', version!); + response.headers.set('X-Latest-Version', this.config.currentVersion); + + // Deprecation header when requesting a non-current version + if (version !== this.config.currentVersion) { + response.headers.set('Deprecation', 'true'); + response.headers.set('X-API-Upgrade-Available', this.config.currentVersion); + } + + // Deprecation metadata on the handler itself (e.g. v1 endpoint marked deprecated) + if (handler.deprecated) { + response.headers.set('Deprecation', 'true'); + if (handler.deprecatedSince) { + response.headers.set('Sunset', handler.deprecatedSince); + } + } + + return response; + } + + /** Read and validate the API-Version header. Defaults to currentVersion when absent. */ + private resolveVersion(req: NextRequest): { + version: ApiVersion | null; + valid: boolean; + requested: string | null; + } { + const raw = req.headers.get('API-Version'); + if (!raw) { + return { version: this.config.currentVersion, valid: true, requested: null }; + } + if (this.config.supportedVersions.includes(raw)) { + return { version: raw, valid: true, requested: raw }; + } + return { version: null, valid: false, requested: raw }; + } + + getSupportedVersions(): ApiVersion[] { + return [...this.config.supportedVersions]; + } + + getCurrentVersion(): ApiVersion { + return this.config.currentVersion; + } +} diff --git a/apps/backend/tests/api/versioning.test.ts b/apps/backend/tests/api/versioning.test.ts index 37ffc80..f719395 100644 --- a/apps/backend/tests/api/versioning.test.ts +++ b/apps/backend/tests/api/versioning.test.ts @@ -1,151 +1,88 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; +import { ApiVersionRouter } from '@/lib/api/versioning'; /** * API Versioning Tests (#371) * * Verifies version negotiation, backward compatibility, deprecated endpoint - * warnings, version-specific behaviour, and migration paths. + * warnings, version-specific behaviour, and migration paths using the + * production ApiVersionRouter from @/lib/api/versioning. */ -// ── Types ───────────────────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── -type ApiVersion = 'v1' | 'v2' | 'v3'; - -interface VersionedRequest { - path: string; - method: string; - version: ApiVersion; - body?: Record; - headers?: Record; -} - -interface VersionedResponse { - status: number; - body: Record; - headers: Record; - warnings?: string[]; -} - -interface EndpointDefinition { - path: string; - method: string; - supportedVersions: ApiVersion[]; - deprecated?: boolean; - deprecatedSince?: ApiVersion; - replacedBy?: string; - handler: (req: VersionedRequest) => Record; -} - -// ── ApiVersionRouter ────────────────────────────────────────────────────────── - -class ApiVersionRouter { - private endpoints: EndpointDefinition[] = []; - private readonly supportedVersions: ApiVersion[] = ['v1', 'v2', 'v3']; - private readonly latestVersion: ApiVersion = 'v3'; - - register(endpoint: EndpointDefinition): void { - this.endpoints.push(endpoint); - } - - handle(req: VersionedRequest): VersionedResponse { - if (!this.supportedVersions.includes(req.version)) { - return { - status: 400, - body: { error: `Unsupported API version: ${req.version}` }, - headers: { 'X-API-Version': req.version }, - }; - } - - const endpoint = this.endpoints.find( - (e) => e.path === req.path && e.method === req.method && e.supportedVersions.includes(req.version), - ); - - if (!endpoint) { - return { - status: 404, - body: { error: `${req.method} ${req.path} not found for version ${req.version}` }, - headers: { 'X-API-Version': req.version }, - }; - } - - const warnings: string[] = []; - const headers: Record = { - 'X-API-Version': req.version, - 'X-Latest-Version': this.latestVersion, - }; - - if (endpoint.deprecated) { - const msg = endpoint.replacedBy - ? `Deprecated since ${endpoint.deprecatedSince}. Use ${endpoint.replacedBy} instead.` - : `Deprecated since ${endpoint.deprecatedSince}.`; - warnings.push(msg); - headers['Deprecation'] = 'true'; - headers['Sunset'] = endpoint.deprecatedSince ?? ''; - } - - if (req.version !== this.latestVersion) { - headers['X-API-Upgrade-Available'] = this.latestVersion; - } - - return { - status: 200, - body: endpoint.handler(req), - headers, - warnings: warnings.length ? warnings : undefined, - }; - } - - getSupportedVersions(): ApiVersion[] { - return [...this.supportedVersions]; +function makeRequest(url: string, method: string, version?: string, body?: unknown): NextRequest { + const headers: Record = {}; + if (version) headers['API-Version'] = version; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + return new NextRequest(url, { method, headers, body: JSON.stringify(body) }); } + return new NextRequest(url, { method, headers }); +} - getLatestVersion(): ApiVersion { - return this.latestVersion; - } +async function parseJson(res: NextResponse): Promise> { + return res.json() as Promise>; } -// ── Fixtures ────────────────────────────────────────────────────────────────── +// ── Fixtures ───────────────────────────────────────────────────────────────── -function makeRouter(): ApiVersionRouter { - const router = new ApiVersionRouter(); +/** + * Multi-version deployments router for testing. + * Simulates a future state where v1–v3 are all supported and v3 is current. + */ +function makeDeploymentsRouter(): ApiVersionRouter { + const router = new ApiVersionRouter({ + supportedVersions: ['v1', 'v2', 'v3'], + currentVersion: 'v3', + }); - // v1 endpoint (deprecated, replaced by v2+) - router.register({ - path: '/api/deployments', - method: 'GET', + // v1 GET (deprecated, replaced by v2+) + router.register('GET', { supportedVersions: ['v1'], deprecated: true, deprecatedSince: 'v2', replacedBy: '/api/v2/deployments', - handler: () => ({ deployments: [], version: 'v1' }), + handler: async () => NextResponse.json({ deployments: [], version: 'v1' }), }); - // v2 endpoint (still supported) - router.register({ - path: '/api/deployments', - method: 'GET', + // v2/v3 GET (not individually deprecated) + router.register('GET', { supportedVersions: ['v2', 'v3'], - handler: (req) => ({ - deployments: [], - version: req.version, - pagination: { page: 1, limit: 10 }, - }), + handler: async (req: any) => { + const version = req.headers.get('API-Version') ?? 'v3'; + return NextResponse.json({ + deployments: [], + version, + pagination: { page: 1, limit: 10 }, + }); + }, }); - // v3-only endpoint - router.register({ - path: '/api/deployments/stats', - method: 'GET', - supportedVersions: ['v3'], - handler: () => ({ total: 0, active: 0, failed: 0 }), + // POST available in all versions + router.register('POST', { + supportedVersions: ['v1', 'v2', 'v3'], + handler: async (req: any) => { + let body: Record = {}; + try { body = await req.json(); } catch { /* empty body */ } + return NextResponse.json({ id: 'dep-1', ...body }); + }, }); - // POST available in all versions - router.register({ - path: '/api/deployments', - method: 'POST', + return router; +} + +/** Stats endpoint router — only available in v3. */ +function makeStatsRouter(): ApiVersionRouter { + const router = new ApiVersionRouter({ supportedVersions: ['v1', 'v2', 'v3'], - handler: (req) => ({ id: 'dep-1', ...req.body }), + currentVersion: 'v3', + }); + + router.register('GET', { + supportedVersions: ['v3'], + handler: async () => NextResponse.json({ total: 0, active: 0, failed: 0 }), }); return router; @@ -154,182 +91,297 @@ function makeRouter(): ApiVersionRouter { // ── Tests ───────────────────────────────────────────────────────────────────── describe('Version negotiation', () => { - it('returns 200 for a valid version', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); + it('returns 200 for a valid version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); expect(res.status).toBe(200); }); - it('returns 400 for an unsupported version', () => { - const router = makeRouter(); - // @ts-expect-error intentional invalid version - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v0' }); + it('returns 400 for an unsupported version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v0'), + 'GET', + {}, + ); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/Unsupported API version/); + const body = await parseJson(res); + expect(body.error).toMatch(/Unsupported API version/); + expect(body.supportedVersions).toEqual(['v1', 'v2', 'v3']); + }); + + it('defaults to current version when API-Version header is absent', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET'), + 'GET', + {}, + ); + expect(res.status).toBe(200); + expect(res.headers.get('X-API-Version')).toBe('v3'); }); - it('includes X-API-Version header in every response', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - expect(res.headers['X-API-Version']).toBe('v2'); + it('includes X-API-Version header in every response', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + expect(res.headers.get('X-API-Version')).toBe('v2'); }); - it('includes X-Latest-Version header pointing to latest', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - expect(res.headers['X-Latest-Version']).toBe('v3'); + it('includes X-Latest-Version header pointing to current version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + expect(res.headers.get('X-Latest-Version')).toBe('v3'); }); - it('includes X-API-Upgrade-Available when not on latest version', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - expect(res.headers['X-API-Upgrade-Available']).toBe('v3'); + it('includes X-API-Upgrade-Available when not on current version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + expect(res.headers.get('X-API-Upgrade-Available')).toBe('v3'); }); - it('does not include X-API-Upgrade-Available on latest version', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v3' }); - expect(res.headers['X-API-Upgrade-Available']).toBeUndefined(); + it('does not include X-API-Upgrade-Available on current version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v3'), + 'GET', + {}, + ); + expect(res.headers.get('X-API-Upgrade-Available')).toBeNull(); }); it('getSupportedVersions returns all supported versions', () => { - const router = makeRouter(); + const router = makeDeploymentsRouter(); expect(router.getSupportedVersions()).toEqual(['v1', 'v2', 'v3']); }); }); describe('Backward compatibility', () => { - it('v1 endpoint still responds for v1 requests', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v1' }); + it('v1 endpoint still responds for v1 requests', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v1'), + 'GET', + {}, + ); expect(res.status).toBe(200); - expect(res.body.version).toBe('v1'); + const body = await parseJson(res); + expect(body.version).toBe('v1'); }); - it('v2 endpoint responds for v2 requests', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); + it('v2 endpoint responds for v2 requests', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); expect(res.status).toBe(200); - expect(res.body.version).toBe('v2'); + const body = await parseJson(res); + expect(body.version).toBe('v2'); }); - it('v2 endpoint also responds for v3 requests (multi-version support)', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v3' }); + it('v2 endpoint also responds for v3 requests (multi-version support)', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v3'), + 'GET', + {}, + ); expect(res.status).toBe(200); - expect(res.body.version).toBe('v3'); + const body = await parseJson(res); + expect(body.version).toBe('v3'); }); - it('POST endpoint is available across all versions', () => { - const router = makeRouter(); - for (const version of ['v1', 'v2', 'v3'] as ApiVersion[]) { - const res = router.handle({ path: '/api/deployments', method: 'POST', version, body: { name: 'test' } }); + it('POST endpoint is available across all versions', async () => { + const router = makeDeploymentsRouter(); + for (const version of ['v1', 'v2', 'v3']) { + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'POST', version, { name: 'test' }), + 'POST', + {}, + ); expect(res.status).toBe(200); } }); - it('v2 response includes pagination (version-specific field)', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - expect(res.body).toHaveProperty('pagination'); + it('v2 response includes pagination (version-specific field)', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + const body = await parseJson(res); + expect(body).toHaveProperty('pagination'); }); }); describe('Deprecated endpoint warnings', () => { - it('deprecated endpoint returns Deprecation header', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v1' }); - expect(res.headers['Deprecation']).toBe('true'); - }); - - it('deprecated endpoint includes warning message', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v1' }); - expect(res.warnings).toBeDefined(); - expect(res.warnings![0]).toMatch(/Deprecated/); + it('deprecated handler returns Deprecation header', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v1'), + 'GET', + {}, + ); + expect(res.headers.get('Deprecation')).toBe('true'); }); - it('deprecation warning includes replacement endpoint', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v1' }); - expect(res.warnings![0]).toContain('/api/v2/deployments'); + it('deprecated handler includes Sunset header with deprecatedSince version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v1'), + 'GET', + {}, + ); + expect(res.headers.get('Sunset')).toBe('v2'); }); - it('non-deprecated endpoint has no Deprecation header', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - expect(res.headers['Deprecation']).toBeUndefined(); + it('non-current version returns Deprecation header', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + expect(res.headers.get('Deprecation')).toBe('true'); }); - it('non-deprecated endpoint has no warnings', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - expect(res.warnings).toBeUndefined(); + it('current version handler with no deprecation flag has no Deprecation header', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v3'), + 'GET', + {}, + ); + expect(res.headers.get('Deprecation')).toBeNull(); }); }); describe('Version-specific behaviour', () => { - it('v3-only endpoint returns 404 for v2 requests', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments/stats', method: 'GET', version: 'v2' }); + it('v3-only endpoint returns 404 for v2 requests', async () => { + const router = makeStatsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments/stats', 'GET', 'v2'), + 'GET', + {}, + ); expect(res.status).toBe(404); }); - it('v3-only endpoint returns 200 for v3 requests', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments/stats', method: 'GET', version: 'v3' }); + it('v3-only endpoint returns 200 for v3 requests', async () => { + const router = makeStatsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments/stats', 'GET', 'v3'), + 'GET', + {}, + ); expect(res.status).toBe(200); - expect(res.body).toHaveProperty('total'); + const body = await parseJson(res); + expect(body).toHaveProperty('total'); }); - it('v1 endpoint returns 404 for v2 requests (not in supportedVersions)', () => { - // The v1-only deprecated endpoint should not be found for v2 - const router = new ApiVersionRouter(); - router.register({ - path: '/api/legacy', - method: 'GET', + it('v1-only handler returns 404 for v2 requests (not in supportedVersions)', async () => { + const router = new ApiVersionRouter({ + supportedVersions: ['v1', 'v2', 'v3'], + currentVersion: 'v3', + }); + router.register('GET', { supportedVersions: ['v1'], - handler: () => ({ legacy: true }), + handler: async () => NextResponse.json({ legacy: true }), }); - const res = router.handle({ path: '/api/legacy', method: 'GET', version: 'v2' }); + const res = await router.handle( + makeRequest('http://localhost/api/legacy', 'GET', 'v2'), + 'GET', + {}, + ); expect(res.status).toBe(404); }); - it('response body reflects the requested version', () => { - const router = makeRouter(); - const v2 = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - const v3 = router.handle({ path: '/api/deployments', method: 'GET', version: 'v3' }); - expect(v2.body.version).toBe('v2'); - expect(v3.body.version).toBe('v3'); + it('response body reflects the requested version', async () => { + const router = makeDeploymentsRouter(); + const v2Res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + const v3Res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v3'), + 'GET', + {}, + ); + const v2Body = await parseJson(v2Res); + const v3Body = await parseJson(v3Res); + expect(v2Body.version).toBe('v2'); + expect(v3Body.version).toBe('v3'); }); }); describe('Version migration paths', () => { - it('v1 response shape is a subset of v2 response shape', () => { - const router = makeRouter(); - const v1 = router.handle({ path: '/api/deployments', method: 'GET', version: 'v1' }); - const v2 = router.handle({ path: '/api/deployments', method: 'GET', version: 'v2' }); - // Both have deployments array - expect(v1.body).toHaveProperty('deployments'); - expect(v2.body).toHaveProperty('deployments'); + it('v1 response shape is a subset of v2 response shape', async () => { + const router = makeDeploymentsRouter(); + const v1Res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v1'), + 'GET', + {}, + ); + const v2Res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v2'), + 'GET', + {}, + ); + const v1Body = await parseJson(v1Res); + const v2Body = await parseJson(v2Res); + expect(v1Body).toHaveProperty('deployments'); + expect(v2Body).toHaveProperty('deployments'); }); - it('migrating from v1 to v2 preserves core fields', () => { - const router = makeRouter(); - const v1 = router.handle({ path: '/api/deployments', method: 'POST', version: 'v1', body: { name: 'my-app' } }); - const v2 = router.handle({ path: '/api/deployments', method: 'POST', version: 'v2', body: { name: 'my-app' } }); - expect(v1.body.name).toBe('my-app'); - expect(v2.body.name).toBe('my-app'); + it('migrating from v1 to v2 preserves core fields', async () => { + const router = makeDeploymentsRouter(); + const v1Res = await router.handle( + makeRequest('http://localhost/api/deployments', 'POST', 'v1', { name: 'my-app' }), + 'POST', + {}, + ); + const v2Res = await router.handle( + makeRequest('http://localhost/api/deployments', 'POST', 'v2', { name: 'my-app' }), + 'POST', + {}, + ); + const v1Body = await parseJson(v1Res); + const v2Body = await parseJson(v2Res); + expect(v1Body.name).toBe('my-app'); + expect(v2Body.name).toBe('my-app'); }); - it('getLatestVersion returns v3', () => { - const router = makeRouter(); - expect(router.getLatestVersion()).toBe('v3'); + it('getCurrentVersion returns the current version', () => { + const router = makeDeploymentsRouter(); + expect(router.getCurrentVersion()).toBe('v3'); }); - it('upgrade header guides clients to latest version', () => { - const router = makeRouter(); - const res = router.handle({ path: '/api/deployments', method: 'GET', version: 'v1' }); - expect(res.headers['X-API-Upgrade-Available']).toBe('v3'); + it('upgrade header guides clients to current version', async () => { + const router = makeDeploymentsRouter(); + const res = await router.handle( + makeRequest('http://localhost/api/deployments', 'GET', 'v1'), + 'GET', + {}, + ); + expect(res.headers.get('X-API-Upgrade-Available')).toBe('v3'); }); }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index acef11b..27fe219 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -116,7 +116,10 @@ export class CraftClient { } private headers(): Record { - const h: Record = { 'Content-Type': 'application/json' }; + const h: Record = { + 'Content-Type': 'application/json', + 'API-Version': 'v1', + }; if (this.accessToken) h['Authorization'] = `Bearer ${this.accessToken}`; return h; } diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index 0bb4a70..aa5cbd1 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -146,6 +146,15 @@ describe('CraftClient initialization', () => { expect(fetch.mock.calls[0][1].headers['Authorization']).toBeUndefined(); vi.unstubAllGlobals(); }); + + it('sends API-Version: v1 header on all requests', () => { + const client = new CraftClient({ baseUrl: BASE_URL, accessToken: 'tok' }); + const fetch = mockFetch(TEMPLATE_LIST); + vi.stubGlobal('fetch', fetch); + client.listTemplates(); + expect(fetch.mock.calls[0][1].headers['API-Version']).toBe('v1'); + vi.unstubAllGlobals(); + }); }); describe('Auth methods', () => {