server be & fe

This commit is contained in:
azykov@mail.ru
2026-09-29 13:50:01 +03:00
commit 657009bd6c
56 changed files with 6162 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
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<typeof multipart>[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<string, string> = {}) =>
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('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' });
});
});