import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { GET as display } from '@/app/api/display/route'; import { GET as image } from '@/app/api/device/image/[hash]/route'; import { POST as log } from '@/app/api/log/route'; import { GET as setup } from '@/app/api/setup/route'; import { prisma } from '@/lib/db'; import { resetRateLimits } from '@/lib/ratelimit'; import { deviceRequest, hasDatabase, resetDatabase } from './helpers'; const MAC = 'FE:68:44:CE:CA:C3'; describe.skipIf(!hasDatabase)('device API', () => { beforeAll(async () => { await resetDatabase(); }); beforeEach(async () => { await prisma.deviceLog.deleteMany(); await prisma.device.deleteMany(); await prisma.screenImage.deleteMany(); await prisma.scheduleException.deleteMany(); resetRateLimits(); }); async function pair(): Promise<{ token: string; friendlyId: string }> { const response = await setup(deviceRequest('/api/setup', { ID: MAC })); const body = (await response.json()) as { api_key: string; friendly_id: string }; return { token: body.api_key, friendlyId: body.friendly_id }; } describe('GET /api/setup', () => { it('registers an unknown device and hands it a token', async () => { const response = await setup(deviceRequest('/api/setup', { ID: MAC })); const body = (await response.json()) as Record; expect(response.status).toBe(200); expect(body.status).toBe(200); expect(String(body.api_key)).toHaveLength(43); expect(String(body.friendly_id)).toMatch(/^[A-Z2-9]{6}$/); // Only the digest is kept: the plaintext must not be recoverable. const stored = await prisma.device.findUnique({ where: { macAddress: MAC } }); expect(stored?.apiKeyHash).toMatch(/^[0-9a-f]{64}$/); expect(stored?.apiKeyHash).not.toBe(body.api_key); }); it('recognises the same device however the firmware spells its MAC', async () => { const { friendlyId } = await pair(); const again = await setup(deviceRequest('/api/setup', { id: 'fe-68-44-ce-ca-c3' })); const body = (await again.json()) as Record; expect(body.friendly_id).toBe(friendlyId); // The token was issued once and only its digest kept, so it cannot be // handed out a second time. expect(body.api_key).toBe(''); expect(await prisma.device.count()).toBe(1); }); it('refuses a missing or malformed MAC', async () => { const body = (await (await setup(deviceRequest('/api/setup'))).json()) as { status: number }; expect(body.status).toBe(404); expect(await prisma.device.count()).toBe(0); }); }); describe('GET /api/display', () => { it('refuses a request with no token', async () => { await pair(); expect((await display(deviceRequest('/api/display'))).status).toBe(401); }); it('refuses a request with the wrong token', async () => { await pair(); const response = await display( deviceRequest('/api/display', { 'Access-Token': 'not-the-token', ID: MAC }), ); expect(response.status).toBe(401); }); it('refuses a deactivated device', async () => { const { token } = await pair(); await prisma.device.update({ where: { macAddress: MAC }, data: { isActive: false } }); const response = await display(deviceRequest('/api/display', { 'Access-Token': token })); expect(response.status).toBe(401); }); it('answers a paired device with an image and a wake interval', async () => { const { token } = await pair(); const response = await display( deviceRequest('/api/display', { 'Access-Token': token, ID: MAC }), ); const body = (await response.json()) as Record; expect(response.status).toBe(200); expect(body.filename).toMatch(/^[0-9a-f]{16}\.bmp$/); expect(body.image_url).toBe(`https://trmnl.example.test/api/device/image/${body.filename}`); expect(body.refresh_rate).toBeGreaterThan(0); expect(body.update_firmware).toBe(false); expect(body.special_function).toBe('none'); }); it('records the telemetry the firmware sends', async () => { const { token } = await pair(); await display( deviceRequest('/api/display', { 'Access-Token': token, ID: MAC, 'FW-Version': '1.5.2', 'Battery-Voltage': '3.94', 'Percent-Charged': '82', RSSI: '-62', }), ); const device = await prisma.device.findUnique({ where: { macAddress: MAC } }); expect(device?.fwVersion).toBe('1.5.2'); expect(device?.batteryVoltage).toBeCloseTo(3.94); expect(device?.percentCharged).toBe(82); expect(device?.rssi).toBe(-62); expect(device?.lastSeenAt).toBeInstanceOf(Date); }); it('accepts the underscore spelling of the token header', async () => { // The TRMNL docs show ACCESS_TOKEN, the Seeed sources Access-Token. const { token } = await pair(); const response = await display(deviceRequest('/api/display', { ACCESS_TOKEN: token })); expect(response.status).toBe(200); }); it('returns the same filename while nothing changes', async () => { // This is the battery test: an unchanged filename means the firmware // skips the redraw entirely. const { token } = await pair(); const headers = { 'Access-Token': token, ID: MAC }; const first = (await (await display(deviceRequest('/api/display', headers))).json()) as { filename: string; }; const second = (await (await display(deviceRequest('/api/display', headers))).json()) as { filename: string; }; expect(second.filename).toBe(first.filename); // And it stored one image, not two. expect(await prisma.screenImage.count()).toBe(1); }); it('returns a different filename once the hours change', async () => { const { token } = await pair(); const headers = { 'Access-Token': token, ID: MAC }; const before = (await (await display(deviceRequest('/api/display', headers))).json()) as { filename: string; }; const today = new Date(); await prisma.scheduleException.create({ data: { date: new Date( `${today.toISOString().slice(0, 10)}T00:00:00.000Z`, ), isClosed: false, slots: [{ open: '14:00', close: '18:00' }], reason: 'SPECIAL_EVENT', noteFr: 'Ouverture exceptionnelle', source: 'MANUAL', }, }); const after = (await (await display(deviceRequest('/api/display', headers))).json()) as { filename: string; }; expect(after.filename).not.toBe(before.filename); }); }); describe('GET /api/device/image/[hash]', () => { it('serves the image the device was pointed at', async () => { const { token } = await pair(); const { filename } = (await ( await display(deviceRequest('/api/display', { 'Access-Token': token })) ).json()) as { filename: string }; const response = await image(deviceRequest(`/api/device/image/${filename}`), { params: Promise.resolve({ hash: filename }), }); const bytes = Buffer.from(await response.arrayBuffer()); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('image/bmp'); expect(response.headers.get('cache-control')).toContain('immutable'); // A real 1-bit 800x480 bitmap, header and all. expect(bytes.subarray(0, 2).toString('ascii')).toBe('BM'); expect(bytes.readInt32LE(18)).toBe(800); expect(bytes.readInt32LE(22)).toBe(480); expect(bytes.readUInt16LE(28)).toBe(1); }); it('returns 404 for an unknown image', async () => { const response = await image(deviceRequest('/api/device/image/0000000000000000.bmp'), { params: Promise.resolve({ hash: '0000000000000000.bmp' }), }); expect(response.status).toBe(404); }); it('returns 404 for anything that is not a hash', async () => { const response = await image(deviceRequest('/api/device/image/x'), { params: Promise.resolve({ hash: '../../etc/passwd' }), }); expect(response.status).toBe(404); }); }); describe('POST /api/log', () => { it('refuses a request with no token', async () => { const response = await log( deviceRequest('/api/log', {}, { method: 'POST', body: '{"logs":[]}' }), ); expect(response.status).toBe(401); }); it('stores what the firmware reports', async () => { const { token } = await pair(); const response = await log( deviceRequest( '/api/log', { 'Access-Token': token, 'Content-Type': 'application/json' }, { method: 'POST', body: JSON.stringify({ logs: [{ message: 'wifi connected', level: 'warn', created_at: 1790000000 }], }), }, ), ); expect(response.status).toBe(204); const entries = await prisma.deviceLog.findMany(); expect(entries).toHaveLength(1); expect(entries[0]?.message).toBe('wifi connected'); expect(entries[0]?.level).toBe('WARN'); }); it('answers 204 to a malformed body rather than making the device retry', async () => { const { token } = await pair(); const response = await log( deviceRequest('/api/log', { 'Access-Token': token }, { method: 'POST', body: 'not json' }), ); expect(response.status).toBe(204); expect(await prisma.deviceLog.count()).toBe(0); }); it('caps how much a single call can write', async () => { const { token } = await pair(); await log( deviceRequest( '/api/log', { 'Access-Token': token }, { method: 'POST', body: JSON.stringify({ logs: Array.from({ length: 200 }, (_, index) => ({ message: `line ${index}` })), }), }, ), ); expect(await prisma.deviceLog.count()).toBe(50); }); }); });