const assert = require('node:assert/strict'); const http = require('node:http'); const test = require('node:test'); const { AgrarmonitorConnector, MemoryCookieStore, ZeitbuchungError } = require('../dist'); const loginPage = ` Anmeldung - AGRARMONITOR
`; // "dd.mm.yy (HH:MM)" -> "YYYY-MM-DD HH:MM" function formularZuVergleich(wert) { const [, tag, monat, jahr, uhrzeit] = /^(\d{2})\.(\d{2})\.(\d{2}) \((\d{2}:\d{2})\)$/.exec(wert); return `20${jahr}-${monat}-${tag} ${uhrzeit}`; } /** * Nachbildung der Arbeitszeit-Endpunkte, so wie sie sich am 14.10.2026 im * echten Agrarmonitor verhalten haben (siehe docs/zeitbuchung-analyse.md). */ function createServer() { const requests = []; const state = { schichten: new Map(), verteilungen: new Map(), pausen: new Map() }; let loggedIn = false; let naechsteId = 100; const neueId = () => (naechsteId += 1); const json = (res, status, data) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(data === undefined ? '' : JSON.stringify(data)); }; const html = (res, body) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(body); }; const schichtAufraeumen = schichtId => { const leer = ![...state.verteilungen.values()].some(v => v.schichtId === schichtId); if (leer) { state.schichten.get(schichtId).deleted = true; } return leer; }; const tagesansicht = (mitarbeiterId, datum) => [...state.schichten.entries()] .filter(([, s]) => !s.deleted && s.mitarbeiterId === mitarbeiterId && s.datum === datum) .map(([schichtId]) => { const zeilen = [ ...[...state.verteilungen.entries()] .filter(([, v]) => v.schichtId === schichtId) .map(([id, v]) => `${v.start.slice(11)} - ${v.ende.slice(11)}`), ...[...state.pausen.entries()] .filter(([, p]) => p.schichtId === schichtId) .map(([id, p]) => `${p.start.slice(11)} - ${p.ende.slice(11)}`), ]; return `${zeilen.join('')}
`; }) .join(''); // Buchbar sind nur aktive Taetigkeiten; 999 ist unbekannt. const taetigkeitAuswahl = ausgewaehlt => ` `; const verteilungFormular = id => { const v = state.verteilungen.get(id); return `
${taetigkeitAuswahl(v.taetigkeitId)}
${v.notizen.map(n => ``).join('')}
14.10.26 (11:15)Connector${n.text.replace(/\n/g, '
\n')}
`; }; const server = http.createServer((req, res) => { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { const url = new URL(req.url, 'http://localhost'); const istJson = (req.headers['content-type'] ?? '').includes('application/json'); const daten = istJson && body ? JSON.parse(body) : body; requests.push({ method: req.method, path: url.pathname, body: daten }); if (req.method === 'POST' && url.pathname === '/login/api/login.php') { loggedIn = true; res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'agrar_session=test; Path=/' }); res.end(JSON.stringify({ success: true })); return; } if (!loggedIn) { html(res, loginPage); return; } const detail = /^\/arbeitszeit\/detail\/(\d+)\/(\d{4}-\d{2}-\d{2})$/.exec(url.pathname); if (detail) { html(res, `${tagesansicht(Number(detail[1]), detail[2])}`); return; } switch (url.pathname) { case '/': html(res, 'Mein AM'); return; case '/module/arbeitszeit/api/nachtragen-form.php': html(res, `
${taetigkeitAuswahl('0')}
`); return; case '/module/arbeitszeit/api/getSchicht.php': { const id = Number(url.searchParams.get('schicht_id')); const s = state.schichten.get(id); json(res, 200, { id, mitarbeiter_id: s.mitarbeiterId, timestamp_start: `${s.start}:00`, timestamp_ende: `${s.ende}:00`, ist_deleted: s.deleted ? 1 : 0, }); return; } case '/module/arbeitszeit/api/nachtragen.php': { const params = new URLSearchParams(body); const datum = params.get('tage[]'); const schichtId = neueId(); const start = `${datum} ${params.get('form[arbeitszeit_start]')}`; const ende = `${datum} ${params.get('form[arbeitszeit_ende]')}`; state.schichten.set(schichtId, { mitarbeiterId: Number(params.get('form[mitarbeiter_id]')), datum, start, ende }); const notiz = params.get('form[notiz]'); state.verteilungen.set(neueId(), { schichtId, start, ende, taetigkeitId: params.get('form[taetigkeit_id]'), maschinenId: params.get('form[maschine_id]'), projektId: '0', notizen: notiz ? [{ id: neueId(), text: notiz }] : [], }); json(res, 200, { action: 'link', target: 'arbeitszeit/plan' }); return; } case '/module/arbeitszeit/api/editVerteilungForm.php': { let id = Number(daten.distributionID); if (daten.shiftID) { // Wie im echten System: Formular fuer "neu" legt sofort einen Platzhalter an. const s = state.schichten.get(daten.shiftID); id = neueId(); state.verteilungen.set(id, { schichtId: daten.shiftID, start: s.start, ende: s.start, taetigkeitId: '0', maschinenId: '0', projektId: '0', notizen: [], }); } html(res, state.verteilungen.has(id) ? verteilungFormular(id) : ''); return; } case '/module/arbeitszeit/api/updateVerteilung.php': { const form = daten.verteilungForm; if (form.ganzertag === undefined) { json(res, 422, { verteilungForm: 'ganzertag must be present' }); return; } if (form.maschine_id === '999') { json(res, 422, { verteilungForm: 'Maschine ungueltig' }); return; } const v = state.verteilungen.get(Number(daten.verteilungId)); Object.assign(v, { start: formularZuVergleich(form.timestamp_start), ende: formularZuVergleich(form.timestamp_ende), taetigkeitId: form.taetigkeit_id, maschinenId: form.maschine_id, projektId: form.projekt_id, }); if (daten.notizForm.notizfeld) { v.notizen.push({ id: neueId(), text: daten.notizForm.notizfeld }); } json(res, 200); return; } case '/module/arbeitszeit/api/deleteVerteilung.php': { const v = state.verteilungen.get(daten.distributionID); state.verteilungen.delete(daten.distributionID); json(res, 200, schichtAufraeumen(v.schichtId) ? { action: 'link', target: 'arbeitszeit' } : null); return; } case '/module/arbeitszeit/api/editPauseForm.php': { const s = state.schichten.get(daten.shiftID); const id = neueId(); state.pausen.set(id, { schichtId: daten.shiftID, start: s.start, ende: s.ende }); html(res, `
`); return; } case '/module/arbeitszeit/api/updatePause.php': { const p = state.pausen.get(Number(daten.pause_id)); p.start = formularZuVergleich(daten.pauseForm.pause_start); p.ende = formularZuVergleich(daten.pauseForm.pause_ende); json(res, 200, 0); return; } case '/module/arbeitszeit/api/deletePause.php': state.pausen.delete(daten.pauseID); json(res, 200, null); return; case '/module/arbeitszeit/api/updateSchicht.php': { const s = state.schichten.get(daten.shiftID); s.start = formularZuVergleich(daten.timestampStart); s.ende = formularZuVergleich(daten.timestampEnd); json(res, 200, true); return; } case '/module/notiz/api/delete.php': for (const v of state.verteilungen.values()) { v.notizen = v.notizen.filter(n => n.id !== daten.noteID); } json(res, 200, true); return; default: res.writeHead(404); res.end('not found'); } }); }); return { server, requests, state, neueId, expireSession() { loggedIn = false; }, }; } async function startConnector(t) { const fake = createServer(); await new Promise(resolve => fake.server.listen(0, '127.0.0.1', resolve)); t.after(() => new Promise(resolve => fake.server.close(resolve))); const connector = new AgrarmonitorConnector({ baseUrl: `http://127.0.0.1:${fake.server.address().port}`, username: 'demo', password: 'secret', cookieStore: new MemoryCookieStore(), }); await connector.init(); return { connector, ...fake }; } /** Legt eine Schicht mit einer Verteilung an, wie nach einem ersten Nachtragen. */ function schichtAnlegen(state, neueId, { start = '2026-10-14 08:00', ende = '2026-10-14 09:00' } = {}) { const schichtId = neueId(); const verteilungId = neueId(); state.schichten.set(schichtId, { mitarbeiterId: 3309, datum: '2026-10-14', start, ende }); state.verteilungen.set(verteilungId, { schichtId, start, ende, taetigkeitId: '870', maschinenId: '0', projektId: '12', notizen: [{ id: neueId(), text: 'Alte Notiz' }], }); return { schichtId, verteilungId }; } const posts = (requests, path) => requests.filter(r => r.method === 'POST' && r.path === path); const schreibendePosts = requests => requests.filter(r => r.method === 'POST' && r.path.startsWith('/module/') && !r.path.endsWith('Form.php')); // 14.10.2026 ist Sommerzeit: 06:00Z = 08:00 Uhr in Berlin. const block = (start, ende, extra = {}) => ({ mitarbeiterId: 3309, start: new Date(start), ende: new Date(ende), taetigkeitId: 870, ...extra, }); test('createZeitbuchung traegt ohne Schicht Schicht und Taetigkeit nach', async t => { const { connector, requests, state } = await startConnector(t); const { ref } = await connector.createZeitbuchung( block('2026-10-14T06:00:00Z', '2026-10-14T07:00:20Z', { maschinenId: 5033, notiz: 'Hydraulik abgedichtet', kostenstelle: '4711', }) ); const [nachtragen] = posts(requests, '/module/arbeitszeit/api/nachtragen.php'); const params = new URLSearchParams(nachtragen.body); assert.equal(params.get('form[mitarbeiter_id]'), '3309'); assert.equal(params.get('form[mitarbeitertyp]'), '1'); assert.equal(params.get('form[taetigkeit_id]'), '870'); assert.equal(params.get('form[maschine_id]'), '5033'); assert.equal(params.get('form[arbeitszeit_start]'), '08:00'); assert.equal(params.get('form[arbeitszeit_ende]'), '09:00', 'Sekunden werden auf die Minute gerundet'); assert.equal(params.get('form[pause_start]'), ''); assert.equal(params.get('form[uebernehmen]'), '1'); assert.equal(params.get('form[notiz]'), 'Hydraulik abgedichtet\nKostenstelle: 4711'); assert.deepEqual(params.getAll('tage[]'), ['2026-10-14']); const [[verteilungId, verteilung]] = [...state.verteilungen.entries()]; assert.equal(ref, `verteilung:3309:2026-10-14:${verteilungId}`); assert.equal(verteilung.start, '2026-10-14 08:00'); assert.equal(verteilung.ende, '2026-10-14 09:00'); }); test('createZeitbuchung bucht in die vorhandene Schicht und verlaengert sie', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { schichtId } = schichtAnlegen(state, neueId); const { ref } = await connector.createZeitbuchung( block('2026-10-14T07:00:00Z', '2026-10-14T08:00:00Z', { taetigkeitId: 869, maschinenId: 5033, notiz: 'Zweiter Block' }) ); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/editVerteilungForm.php')[0].body, { employeeID: 3309, date: '2026-10-14', shiftID: schichtId, }); const verteilungId = Number(ref.split(':').at(-1)); assert.equal(ref, `verteilung:3309:2026-10-14:${verteilungId}`); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/updateVerteilung.php')[0].body, { mitarbeiterId: 3309, verteilungId: String(verteilungId), verteilungForm: { timestamp_start: '14.10.26 (09:00)', timestamp_ende: '14.10.26 (10:00)', alte_taetigkeit_id: '0', taetigkeit_id: '869', ganzertag: '1', maschine_id: '5033', projekt_id: '0', }, notizForm: { notizfeld: 'Zweiter Block', verfasser_id: '6' }, }); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/updateSchicht.php').map(r => r.body), [ { shiftID: schichtId, timestampStart: '14.10.26 (08:00)', timestampEnd: '14.10.26 (10:00)' }, ]); assert.equal(posts(requests, '/module/arbeitszeit/api/nachtragen.php').length, 0); }); test('createZeitbuchung bucht eine Pause in die Schicht', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { schichtId } = schichtAnlegen(state, neueId); const { ref } = await connector.createZeitbuchung({ art: 'pause', mitarbeiterId: 3309, start: new Date('2026-10-14T07:00:00Z'), ende: new Date('2026-10-14T07:30:00Z'), }); const [[pauseId, pause]] = [...state.pausen.entries()]; assert.equal(ref, `pause:3309:2026-10-14:${pauseId}`); assert.deepEqual(pause, { schichtId, start: '2026-10-14 09:00', ende: '2026-10-14 09:30' }); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/updatePause.php')[0].body, { mitarbeiterId: 3309, pause_id: String(pauseId), pauseForm: { pause_start: '14.10.26 (09:00)', pause_ende: '14.10.26 (09:30)' }, neu: 1, }); }); test('createZeitbuchung lehnt eine Pause ohne Schicht ab', async t => { const { connector, requests } = await startConnector(t); await assert.rejects( () => connector.createZeitbuchung({ art: 'pause', mitarbeiterId: 3309, start: new Date('2026-10-14T07:00:00Z'), ende: new Date('2026-10-14T07:30:00Z'), }), error => error instanceof ZeitbuchungError && /keine Schicht vorhanden/.test(error.message) ); assert.equal(schreibendePosts(requests).length, 0); }); test('createZeitbuchung meldet den Grund einer 422 und raeumt Platzhalter und Schicht auf', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { schichtId, verteilungId } = schichtAnlegen(state, neueId); await assert.rejects( () => connector.createZeitbuchung(block('2026-10-14T07:00:00Z', '2026-10-14T08:00:00Z', { maschinenId: 999 })), error => error instanceof ZeitbuchungError && error.status === 422 && error.grund === 'Maschine ungueltig' && /Buchung speichern fehlgeschlagen \(HTTP 422\): Maschine ungueltig/.test(error.message) ); assert.deepEqual([...state.verteilungen.keys()], [verteilungId], 'Platzhalter wurde wieder geloescht'); assert.equal(posts(requests, '/module/arbeitszeit/api/deleteVerteilung.php').length, 1); const schicht = state.schichten.get(schichtId); assert.deepEqual([schicht.start, schicht.ende], ['2026-10-14 08:00', '2026-10-14 09:00'], 'Schicht zurueckgesetzt'); }); test('createZeitbuchung ueber Mitternacht korrigiert Verteilung und Schicht nach dem Nachtragen', async t => { const { connector, requests, state } = await startConnector(t); await connector.createZeitbuchung( block('2026-10-14T21:30:00Z', '2026-10-14T22:30:00Z', { datum: '2026-10-14' }) ); const params = new URLSearchParams(posts(requests, '/module/arbeitszeit/api/nachtragen.php')[0].body); assert.equal(params.get('form[arbeitszeit_start]'), '23:30'); assert.equal(params.get('form[arbeitszeit_ende]'), '23:59'); const update = posts(requests, '/module/arbeitszeit/api/updateVerteilung.php')[0].body; assert.equal(update.verteilungForm.timestamp_start, '14.10.26 (23:30)'); assert.equal(update.verteilungForm.timestamp_ende, '15.10.26 (00:30)'); assert.equal(update.notizForm.notizfeld, '', 'Notiz nicht doppelt anlegen'); const [schicht] = state.schichten.values(); assert.deepEqual([schicht.start, schicht.ende], ['2026-10-14 23:30', '2026-10-15 00:30']); }); // Im echten System gespeichert statt abgelehnt (Echttest 14.10.2026) - der Connector muss pruefen. test('createZeitbuchung lehnt eine nicht buchbare Taetigkeit ab', async t => { const { connector, requests, state, neueId } = await startConnector(t); await assert.rejects( () => connector.createZeitbuchung(block('2026-10-14T06:00:00Z', '2026-10-14T07:00:00Z', { taetigkeitId: 999 })), error => error instanceof ZeitbuchungError && error.grund === 'taetigkeit nicht buchbar' ); assert.equal(posts(requests, '/module/arbeitszeit/api/nachtragen.php').length, 0, 'ohne Schicht nichts nachtragen'); const { verteilungId } = schichtAnlegen(state, neueId); await assert.rejects( () => connector.createZeitbuchung(block('2026-10-14T07:00:00Z', '2026-10-14T08:00:00Z', { taetigkeitId: 999 })), error => error instanceof ZeitbuchungError && error.grund === 'taetigkeit nicht buchbar' ); assert.equal(posts(requests, '/module/arbeitszeit/api/updateVerteilung.php').length, 0); assert.deepEqual([...state.verteilungen.keys()], [verteilungId], 'Platzhalter wurde wieder geloescht'); await assert.rejects( () => connector.updateZeitbuchung( `verteilung:3309:2026-10-14:${verteilungId}`, block('2026-10-14T06:00:00Z', '2026-10-14T07:00:00Z', { taetigkeitId: 999 }) ), error => error instanceof ZeitbuchungError && error.grund === 'taetigkeit nicht buchbar' ); assert.equal(posts(requests, '/module/arbeitszeit/api/updateVerteilung.php').length, 0); }); test('updateZeitbuchung aendert die Verteilung und ersetzt die Notiz', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { verteilungId } = schichtAnlegen(state, neueId); const alteNotizId = state.verteilungen.get(verteilungId).notizen[0].id; await connector.updateZeitbuchung( `verteilung:3309:2026-10-14:${verteilungId}`, block('2026-10-14T06:00:00Z', '2026-10-14T06:45:00Z', { taetigkeitId: 869, maschinenId: 5033, notiz: 'Neue Notiz' }) ); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/updateVerteilung.php')[0].body, { mitarbeiterId: 3309, verteilungId: String(verteilungId), verteilungForm: { timestamp_start: '14.10.26 (08:00)', timestamp_ende: '14.10.26 (08:45)', alte_taetigkeit_id: '870', taetigkeit_id: '869', ganzertag: '1', maschine_id: '5033', projekt_id: '12', }, notizForm: { notizfeld: 'Neue Notiz', verfasser_id: '6' }, }); assert.deepEqual(posts(requests, '/module/notiz/api/delete.php').map(r => r.body), [{ noteID: alteNotizId }]); assert.deepEqual(state.verteilungen.get(verteilungId).notizen.map(n => n.text), ['Neue Notiz']); }); test('updateZeitbuchung laesst eine unveraenderte Notiz stehen', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { verteilungId } = schichtAnlegen(state, neueId); await connector.updateZeitbuchung( `verteilung:3309:2026-10-14:${verteilungId}`, block('2026-10-14T06:00:00Z', '2026-10-14T06:45:00Z', { notiz: 'Alte Notiz' }) ); assert.equal(posts(requests, '/module/arbeitszeit/api/updateVerteilung.php')[0].body.notizForm.notizfeld, ''); assert.equal(posts(requests, '/module/notiz/api/delete.php').length, 0); }); test('updateZeitbuchung aendert eine Pause', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { schichtId } = schichtAnlegen(state, neueId); const pauseId = neueId(); state.pausen.set(pauseId, { schichtId, start: '2026-10-14 08:30', ende: '2026-10-14 08:45' }); await connector.updateZeitbuchung(`pause:3309:2026-10-14:${pauseId}`, { art: 'pause', mitarbeiterId: 3309, start: new Date('2026-10-14T06:35:00Z'), ende: new Date('2026-10-14T06:50:00Z'), }); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/updatePause.php')[0].body, { mitarbeiterId: 3309, pause_id: String(pauseId), pauseForm: { pause_start: '14.10.26 (08:35)', pause_ende: '14.10.26 (08:50)' }, neu: 0, }); }); test('updateZeitbuchung lehnt ungueltige Referenzen und Wechsel von Art oder Tag ab', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { verteilungId } = schichtAnlegen(state, neueId); const daten = block('2026-10-14T06:00:00Z', '2026-10-14T06:45:00Z'); await assert.rejects(() => connector.updateZeitbuchung('1180803', daten), ZeitbuchungError); await assert.rejects( () => connector.updateZeitbuchung(`pause:3309:2026-10-14:${verteilungId}`, daten), /Art der Buchung/ ); await assert.rejects( () => connector.updateZeitbuchung(`verteilung:3309:2026-10-13:${verteilungId}`, daten), /Arbeitstag/ ); await assert.rejects( () => connector.updateZeitbuchung(`verteilung:3309:2026-10-14:999999`, daten), /nicht gefunden/ ); assert.equal(schreibendePosts(requests).length, 0); }); test('deleteZeitbuchung loescht Verteilung und Pause', async t => { const { connector, requests, state, neueId } = await startConnector(t); const { schichtId, verteilungId } = schichtAnlegen(state, neueId); const pauseId = neueId(); state.pausen.set(pauseId, { schichtId, start: '2026-10-14 08:30', ende: '2026-10-14 08:45' }); await connector.deleteZeitbuchung(`pause:3309:2026-10-14:${pauseId}`); await connector.deleteZeitbuchung(`verteilung:3309:2026-10-14:${verteilungId}`); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/deletePause.php')[0].body, { pauseID: pauseId, employeeID: 3309, date: '2026-10-14', }); assert.deepEqual(posts(requests, '/module/arbeitszeit/api/deleteVerteilung.php')[0].body, { distributionID: verteilungId, employeeID: 3309, date: '2026-10-14', }); assert.equal(state.verteilungen.size, 0); assert.equal(state.pausen.size, 0); }); test('createZeitbuchung prueft die Eingaben vor dem ersten Request', async t => { const { connector, requests } = await startConnector(t); const anzahlVorher = requests.length; await assert.rejects( () => connector.createZeitbuchung(block('2026-10-14T06:00:00Z', '2026-10-14T06:00:20Z')), /mindestens eine Minute/ ); await assert.rejects( () => connector.createZeitbuchung(block('2026-10-14T06:00:00Z', '2026-10-14T07:00:00Z', { taetigkeitId: 0 })), /Taetigkeits-ID/ ); assert.equal(requests.length, anzahlVorher); }); test('createZeitbuchung meldet sich bei abgelaufener Session neu an', async t => { const { connector, requests, state, expireSession } = await startConnector(t); const logins = () => posts(requests, '/login/api/login.php').length; assert.equal(logins(), 1); expireSession(); const { ref } = await connector.createZeitbuchung(block('2026-10-14T06:00:00Z', '2026-10-14T07:00:00Z')); assert.equal(logins(), 2, 'genau ein erneuter Login'); assert.match(ref, /^verteilung:3309:2026-10-14:\d+$/); assert.equal(state.verteilungen.size, 1); });