// node test_core.cjs : Core.gs の振る舞い確認(GAS にそのまま載せる同一ファイルをテスト) const assert = require('assert'); const C = require('./Core.gs'); const residents = [ { id: 'R001', name: 'サンプル A', area: '北地区', device_id: 'D-001', lat: 1, lng: 1 }, { id: 'R002', name: 'サンプル B', area: '北地区', device_id: 'D-002', lat: 1, lng: 2 }, { id: 'R003', name: 'サンプル C', area: '南地区', device_id: 'D-003', lat: 2, lng: 1 }, { id: 'R004', name: 'サンプル D', area: '南地区', device_id: '', lat: 2, lng: 2 } // 端末なし(LINEのみ) ]; const event = { id: 'EV-1' }; const cfg = { env: 'test', maxRetry: 3, apiKey: 'K', callbackKey: 'CB', sendNames: false, endpoints: { test: { send: 'https://test.example/send' }, prod: { send: 'https://prod.example/send' } } }; let pass = 0; const t = (name, fn) => { fn(); pass++; console.log('ok -', name); }; t('対象指定: 北地区の端末のみ・端末なしは除外', () => { const p = C.buildEmergencyPayload(event, residents, '北地区', 'test', new Date('2026-12-01T00:00:00Z')); assert.deepStrictEqual(p.targets.map(x => x.device_id), ['D-001', 'D-002']); assert.strictEqual(p.mode, 'emergency'); }); t('送信: 成功+一部端末が拒否 → 拒否分だけ failed', () => { const s = new C.MemoryStore(residents); const p = C.buildEmergencyPayload(event, residents, null, 'test'); const r = C.dispatchEmergency(cfg, p, () => ({ code: 200, body: JSON.stringify({ accepted: ['D-001', 'D-002'], rejected: [{ device_id: 'D-003', reason: 'offline' }] }) }), s); assert.ok(r.ok); assert.deepStrictEqual(s.dispatches.map(d => d.result), ['sent', 'sent', 'failed']); }); t('送信: 500 が続く → 3回再送して全件 failed', () => { const s = new C.MemoryStore(residents); let calls = 0; const p = C.buildEmergencyPayload(event, residents, null, 'test'); const r = C.dispatchEmergency(cfg, p, () => { calls++; return { code: 500, body: '' }; }, s); assert.strictEqual(calls, 3); assert.ok(!r.ok); assert.ok(s.dispatches.every(d => d.result === 'failed' && d.attempts === 3)); }); t('送信: 例外(タイムアウト)でも落ちずに再送→2回目で成功', () => { const s = new C.MemoryStore(residents); let n = 0; const p = C.buildEmergencyPayload(event, residents, null, 'test'); const r = C.dispatchEmergency(cfg, p, () => { n++; if (n === 1) throw new Error('timeout'); return { code: 200, body: '{}' }; }, s); assert.ok(r.ok); assert.strictEqual(r.attempts, 2); }); t('受信: APIキー不一致は 401', () => { const s = new C.MemoryStore(residents); assert.strictEqual(C.handleDeviceCallback('{}', { key: 'x' }, cfg, s).code, 401); }); t('受信: 不正JSON 400/必須欠落 422/不正status 422/未知端末 404', () => { const s = new C.MemoryStore(residents); const a = { key: 'CB' }; assert.strictEqual(C.handleDeviceCallback('{', a, cfg, s).code, 400); assert.strictEqual(C.handleDeviceCallback('{"event_id":"EV-1"}', a, cfg, s).code, 422); assert.strictEqual(C.handleDeviceCallback('{"event_id":"EV-1","device_id":"D-001","status":"??"}', a, cfg, s).code, 422); assert.strictEqual(C.handleDeviceCallback('{"event_id":"EV-1","device_id":"ZZ","status":"safe"}', a, cfg, s).code, 404); }); t('重複統合: 端末が先着→LINEは後着として追記(採用は端末)', () => { const s = new C.MemoryStore(residents); const r1 = C.handleDeviceCallback({ event_id: 'EV-1', device_id: 'D-001', status: 'safe', message_id: 'm1' }, { key: 'CB' }, cfg, s, new Date('2026-12-01T00:00:10Z')); assert.strictEqual(r1.result, 'adopted'); const r2 = C.recordAnswer(s, { event_id: 'EV-1', resident_id: 'R001', status: 'safe', source: 'LINE', answered_at: '2026-12-01T00:00:05Z', ref: 'line-1' }, new Date('2026-12-01T00:00:20Z')); assert.strictEqual(r2.result, 'appended_late'); // answered_at は端末より早くても「届いた順」で後着 const b = C.computeBoard(event, residents, s.answers, s.dispatches); const row = b.rows.find(x => x.resident_id === 'R001'); assert.strictEqual(row.source, 'DEVICE'); assert.strictEqual(row.late.length, 1); assert.ok(!row.conflict); }); t('冪等: 端末の再送(同じmessage_id)は無視', () => { const s = new C.MemoryStore(residents); const a = { key: 'CB' }; const b = { event_id: 'EV-1', device_id: 'D-002', status: 'safe', message_id: 'm9' }; assert.strictEqual(C.handleDeviceCallback(b, a, cfg, s).result, 'adopted'); assert.strictEqual(C.handleDeviceCallback(b, a, cfg, s).result, 'duplicate_ignored'); assert.strictEqual(s.answers.length, 1); }); t('食い違い: 先着=無事/後着(LINE)=要支援 → conflict+alert', () => { const s = new C.MemoryStore(residents); C.handleDeviceCallback({ event_id: 'EV-1', device_id: 'D-003', status: 'safe', message_id: 'x' }, { key: 'CB' }, cfg, s); const r = C.recordAnswer(s, { event_id: 'EV-1', resident_id: 'R003', status: 'need_help', source: 'LINE', answered_at: 'now', ref: 'l2' }); assert.ok(r.conflict); const row = C.computeBoard(event, residents, s.answers, s.dispatches).rows.find(x => x.resident_id === 'R003'); assert.ok(row.conflict && row.alert); }); t('集計・通知: 未回答/送信失敗の抽出と、プラットフォーム送信は氏名なし', () => { const s = new C.MemoryStore(residents); const p = C.buildEmergencyPayload(event, residents, null, 'test', new Date('2026-12-01T00:00:00Z')); C.dispatchEmergency(cfg, p, () => ({ code: 200, body: JSON.stringify({ rejected: [{ device_id: 'D-003', reason: 'offline' }] }) }), s); C.handleDeviceCallback({ event_id: 'EV-1', device_id: 'D-001', status: 'safe', message_id: 'a' }, { key: 'CB' }, cfg, s); const b = C.computeBoard(event, residents, s.answers, s.dispatches); assert.deepStrictEqual(b.summary, { total: 4, safe: 1, need_help: 0, unknown: 0, no_answer: 2, send_failed: 1, conflict: 0 }); const early = C.findNotifyTargets(b, '2026-12-01T00:00:00Z', 10, new Date('2026-12-01T00:05:00Z')); assert.deepStrictEqual(early.map(x => x.resident_id), ['R003']); // 5分では送信失敗のみ const later = C.findNotifyTargets(b, '2026-12-01T00:00:00Z', 10, new Date('2026-12-01T00:11:00Z')); assert.deepStrictEqual(later.map(x => x.resident_id).sort(), ['R002', 'R003', 'R004']); const pl = C.buildPlatformPayload(b, cfg); assert.ok(pl.residents.every(x => !('name' in x))); }); console.log('\nALL PASSED:', pass, 'tests');