import type { FastifyInstance } from 'fastify'; import { describe, expect, it } from 'vitest'; import type { AccessKeyView, Card, Group } from '@cardy/shared'; import { SUPERADMIN, multipart, testApp } from './helpers.js'; const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' }; async function setup() { const { app } = await testApp(); const key = ( await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: adminHeaders, payload: {} }) ).json() as AccessKeyView; return { app, key: key.key, keyId: key.id }; } function client(app: FastifyInstance, key: string) { return (method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, parts?: Parameters[0]) => { const body = parts ? multipart(parts) : undefined; return app.inject({ method, url, headers: { authorization: `Bearer ${key}`, ...body?.headers }, ...(body ? { payload: body.payload } : {}), }); }; } const cardParts = (overrides: Record = {}) => Object.entries({ name: 'Shop', color: '#123456', code: '4600000000000', codeType: 'EAN13', ...overrides }).map( ([name, value]) => ({ name, value }), ); describe('client API', () => { it('authenticates by bearer key', async () => { const { app, key } = await setup(); const ok = await client(app, key)('GET', '/auth'); expect(ok.statusCode).toBe(200); expect(ok.json()).toMatchObject({ email: SUPERADMIN, group: { name: 'default' } }); expect((await client(app, 'wrong')('GET', '/auth')).statusCode).toBe(401); expect((await app.inject({ url: '/cards' })).statusCode).toBe(401); }); it('stops accepting a key right after it is deleted', async () => { const { app, key, keyId } = await setup(); await app.inject({ method: 'DELETE', url: `/admin/users/${SUPERADMIN}/keys/${keyId}`, headers: adminHeaders }); expect((await client(app, key)('GET', '/auth')).statusCode).toBe(401); }); it('answers CORS preflight without a key', async () => { const { app } = await setup(); const res = await app.inject({ method: 'OPTIONS', url: '/cards', headers: { origin: 'https://localhost', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' }, }); expect(res.statusCode).toBeLessThan(300); expect(res.headers['access-control-allow-origin']).toBe('https://localhost'); }); it('creates, edits and deletes cards with logo and images', async () => { const { app, key } = await setup(); const api = client(app, key); const createdRes = await api('POST', '/cards', [ ...cardParts(), { name: 'logo', filename: 'logo.webp', mime: 'image/webp', data: Buffer.from('logo') }, ]); expect(createdRes.statusCode).toBe(201); const created = createdRes.json() as Card; expect(created.id).toBeTruthy(); expect(created.logo).not.toBeNull(); expect(created.imagesVersion).toBe(1); expect((await api('POST', '/cards', cardParts({ codeType: 'BAD' }))).statusCode).toBe(400); const edited = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Shop 2' }])).json() as Card; expect(edited).toMatchObject({ name: 'Shop 2', code: '4600000000000', imagesVersion: 1 }); const noLogo = (await api('PUT', `/cards/${created.id}`, [{ name: 'removeLogo', value: 'true' }])).json() as Card; expect(noLogo.logo).toBeNull(); expect(noLogo.imagesVersion).toBe(2); const withImages = ( await api('POST', `/cards/${created.id}/images`, [ { name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') }, { name: 'file', filename: 'b.webp', mime: 'image/webp', data: Buffer.from('b') }, ]) ).json() as Card; expect(withImages.images).toHaveLength(2); expect(withImages.imagesVersion).toBe(3); const image = await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`); expect(image.body).toBe('b'); const afterRemove = (await api('DELETE', `/cards/${created.id}/images/${withImages.images[0]!.id}`)).json() as Card; expect(afterRemove.images.map((i) => i.id)).toEqual([withImages.images[1]!.id]); expect((await api('GET', '/cards')).json()).toHaveLength(1); expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(204); // Deleted cards are invisible to the client API but kept for admins. expect((await api('GET', '/cards')).json()).toEqual([]); expect((await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`)).statusCode).toBe(404); expect((await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'x' }])).statusCode).toBe(404); expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(404); const adminCards = (await app.inject({ url: '/admin/cards', headers: adminHeaders })).json() as Card[]; expect(adminCards[0]).toMatchObject({ id: created.id, deleted: true }); }); it('scopes cards to the key owner group', async () => { const { app, key } = await setup(); const other = ( await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } }) ).json() as Group; const otherHeaders = { ...adminHeaders, cookie: `cardy_group=${other.id}` }; await app.inject({ method: 'POST', url: '/admin/users', headers: otherHeaders, payload: { email: 'o@example.com' } }); const otherKey = ( await app.inject({ method: 'POST', url: '/admin/users/o@example.com/keys', headers: otherHeaders, payload: {} }) ).json() as AccessKeyView; const mine = (await client(app, key)('POST', '/cards', cardParts())).json() as Card; const theirs = client(app, otherKey.key); expect((await theirs('GET', '/cards')).json()).toEqual([]); expect((await theirs('DELETE', `/cards/${mine.id}`)).statusCode).toBe(404); }); it('stores a multiline comment and keeps it on partial updates', async () => { const { app, key } = await setup(); const api = client(app, key); const comment = 'Line one\nLine two\n\n indented'; const created = (await api('POST', '/cards', [...cardParts(), { name: 'comment', value: ' Line one\r\nLine two\r\n\r\n indented\n' }])).json() as Card; expect(created.comment).toBe(comment); const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card; expect(renamed.comment).toBe(comment); const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'comment', value: '' }])).json() as Card; expect(cleared.comment).toBe(''); expect((await api('PUT', `/cards/${created.id}`, [{ name: 'comment', value: 'x'.repeat(2001) }])).statusCode).toBe(400); const withoutComment = (await api('POST', '/cards', cardParts())).json() as Card; expect(withoutComment.comment).toBe(''); const patched = await app.inject({ method: 'PATCH', url: `/admin/cards/${created.id}`, headers: adminHeaders, payload: { comment: 'From admin\nsecond line' }, }); expect((patched.json() as Card).comment).toBe('From admin\nsecond line'); }); it('parses comma-separated tags and keeps them on partial updates', async () => { const { app, key } = await setup(); const api = client(app, key); const created = (await api('POST', '/cards', [...cardParts(), { name: 'tags', value: 'один,,, два' }])).json() as Card; expect(created.tags).toEqual(['один', 'два']); const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card; expect(renamed.tags).toEqual(['один', 'два']); const retagged = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: ' food , ,pharmacy ' }])).json() as Card; expect(retagged.tags).toEqual(['food', 'pharmacy']); const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: '' }])).json() as Card; expect(cleared.tags).toEqual([]); expect((await api('POST', '/cards', cardParts())).json()).toMatchObject({ tags: [] }); const patched = await app.inject({ method: 'PATCH', url: `/admin/cards/${created.id}`, headers: adminHeaders, payload: { tags: [' a ', '', 'b, c'] }, }); expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']); }); it('returns JSON 404 for unknown API routes', async () => { const { app, key } = await setup(); const res = await client(app, key)('GET', '/nope'); expect(res.statusCode).toBe(404); expect(res.json()).toEqual({ error: 'Not found' }); }); });