const fs = require('fs'); const https = require('https'); const path = require('path'); const express = require('express'); const service = require('../server/RobotConfigService'); const DATA_DIR = path.join(__dirname, '..', 'data', 'robot'); const ROBOT_JSON = path.join(DATA_DIR, 'robot.json'); const TEST_KEY = 'test-api-key-12345'; const SAMPLE_ROBOT = { links: { Arm1: { size: [70, 250, 70] }, Arm2: { size: [70, 250, 70] }, Ellbow: { size: [90, 0, 0] } } }; // ── HTTPS-Test-Server ───────────────────────────────────────────────────────── function makeServer(apiKey) { const app = express(); service.register(app, { apiKey }); const key = fs.readFileSync('https/localhost.key'); const cert = fs.readFileSync('https/localhost.pem'); return https.createServer({ key, cert, passphrase: 'abcd' }, app); } function listen(server) { return new Promise((resolve, reject) => { server.listen(0, () => { const addr = server.address(); addr ? resolve(addr.port) : reject(new Error('no port')); }); server.on('error', reject); }); } function closeServer(server) { return new Promise(resolve => { server.close(resolve); }); } function request(method, url, { body, token } = {}) { return new Promise((resolve, reject) => { const agent = new https.Agent({ rejectUnauthorized: false }); const payload = body ? JSON.stringify(body) : undefined; const headers = { ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}), ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }; const req = https.request(url, { method, agent, headers }, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => resolve({ statusCode: res.statusCode, body: data })); }); req.on('error', reject); if (payload) req.write(payload); req.end(); }); } // ── Setup / Teardown ────────────────────────────────────────────────────────── function cleanupTestFiles() { for (const f of fs.readdirSync(DATA_DIR)) { if (f === '.gitkeep' || f === '.apikey') continue; fs.unlinkSync(path.join(DATA_DIR, f)); } } // robot.json gehört zur Betriebskonfiguration und darf durch Tests NICHT dauerhaft // gelöscht werden. Wir sichern den Inhalt vor der Suite und stellen ihn danach exakt // wieder her — inklusive aller Snapshot-Dateien, die schon vorher da waren. let _savedRobotJson = null; // null = Datei existierte nicht let _savedSnapshotFiles = {}; // filename → content (nur Dateien, die VOR den Tests da waren) beforeAll(() => { fs.mkdirSync(DATA_DIR, { recursive: true }); // robot.json sichern try { _savedRobotJson = fs.readFileSync(ROBOT_JSON, 'utf8'); } catch { _savedRobotJson = null; } // Snapshot-Dateien sichern (robot_YYYYMMDD_HHmmss.json) for (const f of fs.readdirSync(DATA_DIR)) { if (/^robot_\d{8}_\d{6}\.json$/.test(f)) { _savedSnapshotFiles[f] = fs.readFileSync(path.join(DATA_DIR, f), 'utf8'); } } }); afterAll(() => { // Testdateien bereinigen cleanupTestFiles(); // robot.json wiederherstellen if (_savedRobotJson !== null) { fs.writeFileSync(ROBOT_JSON, _savedRobotJson, 'utf8'); } // Vor-Test-Snapshots wiederherstellen for (const [f, content] of Object.entries(_savedSnapshotFiles)) { fs.writeFileSync(path.join(DATA_DIR, f), content, 'utf8'); } }); beforeEach(() => { fs.mkdirSync(DATA_DIR, { recursive: true }); cleanupTestFiles(); }); afterEach(() => { cleanupTestFiles(); }); // ── Einzel-Funktionen ───────────────────────────────────────────────────────── describe('readRobotJson / writeRobotJson', () => { test('writeRobotJson schreibt Datei und legt Snapshot an', async () => { fs.writeFileSync(ROBOT_JSON, JSON.stringify({ version: 1 }), 'utf8'); const { snapshotFile } = await service.writeRobotJson({ version: 2 }); expect(snapshotFile).toMatch(/^robot_\d{8}_\d{6}\.json$/); expect(fs.existsSync(path.join(DATA_DIR, snapshotFile))).toBe(true); const written = JSON.parse(fs.readFileSync(ROBOT_JSON, 'utf8')); expect(written).toEqual({ version: 2 }); const snapshot = JSON.parse(fs.readFileSync(path.join(DATA_DIR, snapshotFile), 'utf8')); expect(snapshot).toEqual({ version: 1 }); }); test('kein Snapshot wenn robot.json noch nicht existiert', async () => { const { snapshotFile } = await service.writeRobotJson({ fresh: true }); expect(fs.existsSync(path.join(DATA_DIR, snapshotFile))).toBe(false); }); test('readRobotJson liest die aktuelle Datei', async () => { fs.writeFileSync(ROBOT_JSON, JSON.stringify(SAMPLE_ROBOT), 'utf8'); const data = await service.readRobotJson(); expect(data).toEqual(SAMPLE_ROBOT); }); test('readRobotJson wirft, wenn robot.json fehlt', async () => { await expect(service.readRobotJson()).rejects.toThrow(); }); }); describe('listHistory / readSnapshot', () => { test('listHistory gibt leere Liste zurück wenn kein Snapshot', async () => { const list = await service.listHistory(); expect(list).toEqual([]); }); test('listHistory listet vorhandene Snapshots, neueste zuerst', async () => { fs.writeFileSync(path.join(DATA_DIR, 'robot_20260101_120000.json'), '{}', 'utf8'); fs.writeFileSync(path.join(DATA_DIR, 'robot_20260102_083000.json'), '{}', 'utf8'); const list = await service.listHistory(); expect(list.map(e => e.filename)).toEqual([ 'robot_20260102_083000.json', 'robot_20260101_120000.json' ]); expect(list[0]).toEqual({ filename: 'robot_20260102_083000.json', day: '20260102', timestamp: '083000' }); }); test('readSnapshot liest einen bestimmten Snapshot', async () => { const ts = '20260101_120000'; fs.writeFileSync(path.join(DATA_DIR, `robot_${ts}.json`), JSON.stringify({ x: 42 }), 'utf8'); const data = await service.readSnapshot(ts); expect(data).toEqual({ x: 42 }); }); test('readSnapshot wirft bei unbekanntem Timestamp', async () => { await expect(service.readSnapshot('99999999_999999')).rejects.toThrow(); }); }); describe('Pruning', () => { test('≤ 100 Snapshots pro Tag bleiben alle erhalten', async () => { for (let i = 1; i <= 5; i++) { fs.writeFileSync(path.join(DATA_DIR, `robot_2026061${i}_120000.json`), '{}', 'utf8'); } fs.writeFileSync(ROBOT_JSON, '{}', 'utf8'); await service.writeRobotJson({ pruned: true }); const list = await service.listHistory(); // 5 bestehende (verschiedene Tage) + 1 neuer Snapshot = 6 expect(list.length).toBe(6); }); test('> 100 Snapshots eines Tages: nur neuester bleibt', async () => { const day = '20260101'; for (let i = 0; i < 102; i++) { const hh = String(Math.floor(i / 3600)).padStart(2, '0'); const mm = String(Math.floor((i % 3600) / 60)).padStart(2, '0'); const ss = String(i % 60).padStart(2, '0'); fs.writeFileSync(path.join(DATA_DIR, `robot_${day}_${hh}${mm}${ss}.json`), `{"i":${i}}`, 'utf8'); } // Neuester (lexikographisch letzter) vorher anlegen const newest = `robot_${day}_235959.json`; fs.writeFileSync(path.join(DATA_DIR, newest), '{"newest":true}', 'utf8'); fs.writeFileSync(ROBOT_JSON, '{}', 'utf8'); await service.writeRobotJson({ trigger: true }); const list = await service.listHistory(); const daySnapshots = list.filter(e => e.day === day); expect(daySnapshots.length).toBe(1); expect(daySnapshots[0].filename).toBe(newest); }); }); // ── HTTP-Routen ─────────────────────────────────────────────────────────────── describe('HTTP GET /api/robot', () => { let server, port; beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); }); afterEach(async () => { await closeServer(server); }); test('404 wenn robot.json nicht existiert', async () => { const { statusCode } = await request('GET', `https://127.0.0.1:${port}/api/robot`); expect(statusCode).toBe(404); }); test('200 mit robot.json-Inhalt', async () => { fs.writeFileSync(ROBOT_JSON, JSON.stringify(SAMPLE_ROBOT), 'utf8'); const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot`); expect(statusCode).toBe(200); expect(JSON.parse(body)).toEqual(SAMPLE_ROBOT); }); }); describe('HTTP PUT /api/robot', () => { let server, port; beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); }); afterEach(async () => { await closeServer(server); }); test('401 ohne Authorization-Header', async () => { const { statusCode } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, { body: SAMPLE_ROBOT }); expect(statusCode).toBe(401); }); test('401 mit falschem Key', async () => { const { statusCode } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, { body: SAMPLE_ROBOT, token: 'wrong-key' }); expect(statusCode).toBe(401); }); test('200 mit korrektem Key — schreibt Datei', async () => { const { statusCode, body } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, { body: SAMPLE_ROBOT, token: TEST_KEY }); expect(statusCode).toBe(200); const result = JSON.parse(body); expect(result.ok).toBe(true); expect(result.snapshotFile).toMatch(/^robot_\d{8}_\d{6}\.json$/); const written = JSON.parse(fs.readFileSync(ROBOT_JSON, 'utf8')); expect(written).toEqual(SAMPLE_ROBOT); }); }); describe('HTTP GET /api/robot/history', () => { let server, port; beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); }); afterEach(async () => { await closeServer(server); }); test('200 mit leerer History', async () => { const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history`); expect(statusCode).toBe(200); expect(JSON.parse(body)).toEqual({ history: [] }); }); test('200 listet vorhandene Snapshots', async () => { fs.writeFileSync(path.join(DATA_DIR, 'robot_20260611_100000.json'), '{}', 'utf8'); const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history`); expect(statusCode).toBe(200); const { history } = JSON.parse(body); expect(history).toHaveLength(1); expect(history[0].filename).toBe('robot_20260611_100000.json'); }); }); describe('HTTP GET /api/robot/history/:ts', () => { let server, port; beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); }); afterEach(async () => { await closeServer(server); }); test('200 für vorhandenen Snapshot', async () => { const ts = '20260611_100000'; fs.writeFileSync(path.join(DATA_DIR, `robot_${ts}.json`), JSON.stringify({ snap: true }), 'utf8'); const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history/${ts}`); expect(statusCode).toBe(200); expect(JSON.parse(body)).toEqual({ snap: true }); }); test('404 für unbekannten Snapshot', async () => { const { statusCode } = await request('GET', `https://127.0.0.1:${port}/api/robot/history/99991231_235959`); expect(statusCode).toBe(404); }); });