import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { createBriefOutbox, briefWeekKey } from '../server/brief-outbox.mjs'; import { createBriefSender } from '../server/brief-delivery.mjs'; import { backupDataVolume, restoreDataVolume } from '../server/backup-data-volume.mjs'; function fixture(t) { const root=mkdtempSync(join(tmpdir(),'brief-outbox-')),directory=join(root,'data'); t.after(()=>rmSync(root,{recursive:true,force:true})); return {root,directory,outbox:createBriefOutbox(directory)}; } function sender(outbox, overrides={}) { return createBriefSender({outbox,clock:()=>new Date('2026-09-07T05:00:00Z'), store:{listUsers:()=>[{email:'test@example.test'}],getData:()=>null}, buildBrief:()=>({bos:false,hafta:'07 Eylül 2026'}),text:()=> 'Test',html:()=> '

Test

', telegramConfigured:()=>false,sendTelegram:async()=>true,emailConfigured:()=>true, sendEmail:async()=>({id:'synthetic-receipt'}),warn:()=>{},...overrides}); } test('invalid weekly manifest is rejected before it reaches the queue', t=>{ const {outbox}=fixture(t); assert.throws(()=>outbox.prepare('2026-09-07','bad@example.test',{channel:'email'}),/manifesti bozuk/); assert.throws(()=>outbox.prepare('2026-09-07','bad@example.test',[{to:{email:'bad'}}]),/manifesti bozuk/); }); test('accepted delivery survives reopening and does not send again; weeks and owners are isolated',async t=>{ const {outbox,directory}=fixture(t);let calls=0; const transport={sendEmail:async()=>{calls++;return {id:'receipt'};}}; assert.equal((await sender(outbox,transport)(async()=>[])).gonderilen,1); const again=await sender(createBriefOutbox(directory),transport)(async()=>[]); assert.equal(calls,1);assert.equal(again.atlanan,1); const next=sender(outbox,{...transport,clock:()=>new Date('2026-09-14T05:00:00Z')}); await next(async()=>[]);assert.equal(calls,2); const other=outbox.prepare('2026-09-07','other@example.test',[{text:'Test'}]); assert.equal(outbox.claim(other.id,0).state,'sending'); }); test('concurrent senders cannot call the provider for the same message twice',async t=>{ const {outbox,directory}=fixture(t);let release; const gate=new Promise(resolve=>{release=resolve;});let entered; const ready=new Promise(resolve=>{entered=resolve;});let calls=0; const transport={sendEmail:async()=>{calls++;entered();await gate;return {id:'receipt'};}}; const first=sender(outbox,transport)(async()=>[]); await ready; const second=await sender(createBriefOutbox(directory),transport)(async()=>[]); assert.equal(second.bekleyen,1);assert.equal(calls,1); release();await first; assert.equal(outbox.inspect()[0].state,'accepted'); }); test('ambiguous provider failure and changed payload remain held without automatic replay',async t=>{ const {outbox}=fixture(t);let calls=0; const transport={sendEmail:async()=>{calls++;throw new Error('Connection lost after send');}}; assert.equal((await sender(outbox,transport)(async()=>[])).hata,1); const retry=await sender(outbox,transport)(async()=>[]); assert.equal(retry.bekleyen,1);assert.equal(calls,1); const changed=await sender(outbox,{text:()=> 'Changed'})(async()=>[]); assert.equal(changed.hata,1);assert.equal(changed.bekleyen,1); assert.equal(outbox.inspect()[0].state,'uncertain'); }); test('feed failure creates no completion record and a subsequent fetch can proceed',async t=>{ const {outbox}=fixture(t),send=sender(outbox); await assert.rejects(send(async()=>{throw new Error('Feed down');}),/Feed down/); assert.deepEqual(outbox.inspect(),[]); assert.equal((await send(async()=>[])).gonderilen,1); }); test('SIGKILL after claiming leaves an uncertain message after lease expiry, never a fresh send',async t=>{ const {directory,outbox}=fixture(t); const moduleUrl=new URL('../server/brief-outbox.mjs',import.meta.url).href; const code=`import {createBriefOutbox} from ${JSON.stringify(moduleUrl)}; const box=createBriefOutbox(process.argv[1]); const batch=box.prepare('2026-09-07','test@example.test',[{text:'Test'}]); box.claim(batch.id,0,1000);process.stdout.write(batch.id+'\\n');setInterval(()=>{},1000);`; const child=spawn(process.execPath,['--input-type=module','-e',code,directory],{stdio:['ignore','pipe','pipe']}); t.after(()=>{if(child.exitCode===null)child.kill('SIGKILL');}); const output=await Promise.race([once(child.stdout,'data'),once(child,'exit').then(()=>{throw new Error('Child exited before claim');})]); const batch=output[0].toString().trim(); const stopped=once(child,'exit');child.kill('SIGKILL');await stopped; assert.equal(outbox.claim(batch,0,2000).state,'busy'); assert.equal(outbox.claim(batch,0,121001).state,'uncertain'); assert.equal(outbox.claim(batch,0,999999).state,'uncertain'); }); test('full-volume backup restores accepted message records and prevents replay',async t=>{ const {root,directory,outbox}=fixture(t); await sender(outbox)(async()=>[]); const backup=join(root,'backup'),restored=join(root,'restored'); const manifest=await backupDataVolume(directory,backup,{offline:true}); assert.ok(manifest.files.some(file=>file.name==='brief.sqlite'&&file.sqlite)); await restoreDataVolume(backup,restored); const send=sender(createBriefOutbox(restored),{sendEmail:async()=>{throw new Error('Must not replay');}}); const result=await send(async()=>[]);assert.equal(result.atlanan,1);assert.equal(result.hata,0); }); test('weekly id uses Monday in Istanbul even while UTC is still Sunday',()=>{ assert.equal(briefWeekKey(new Date('2026-09-06T20:59:59Z')),'2026-08-31'); assert.equal(briefWeekKey(new Date('2026-09-06T21:00:00Z')),'2026-09-07'); }); test('review needs provider evidence, persists actor and closes only the selected message',t=>{ const {outbox,directory}=fixture(t); const batch=outbox.prepare('2026-09-07','review@example.test',[{channel:'email',to:'review@example.test'},{channel:'telegram',chatId:'test'}]); const first=outbox.claim(batch.id,0,1000),second=outbox.claim(batch.id,1,1000); outbox.hold(first,'Unknown response');outbox.hold(second,'Unknown response'); const id=`${batch.id}:0`,input={action:'confirmed_sent',note:'Sağlayıcı kayıtları incelendi ve eşleşti.',providerId:'provider-test-id',confirmed:true}; assert.throws(()=>outbox.resolve(id,'admin@example.test',{...input,confirmed:false}),/onayı/); assert.throws(()=>outbox.resolve(id,'admin@example.test',{...input,providerId:''}),/kimliği/); assert.throws(()=>outbox.resolve(id,'admin@example.test',{...input,note:'short'}),/açıklaması/); assert.equal(outbox.resolve(id,'admin@example.test',input).state,'sent_manual'); assert.throws(()=>outbox.resolve(id,'other-admin@example.test',input),{status:409}); const rows=createBriefOutbox(directory).queue(10000),done=rows.find(row=>row.id===id); assert.equal(done.review.actor,'admin@example.test');assert.equal(done.review.note,input.note); assert.equal(done.providerId,'provider-test-id');assert.equal(done.state,'sent_manual'); assert.equal(rows.find(row=>row.id!==id).state,'uncertain'); assert.equal(outbox.claim(batch.id,0).state,'accepted'); }); test('skip never claims delivery and a fully reviewed changed plan cannot start new messages',t=>{ const {outbox}=fixture(t); const batch=outbox.prepare('2026-09-07','review@example.test',[{channel:'email'}]); outbox.hold(outbox.claim(batch.id,0),'Uncertain'); outbox.resolve(`${batch.id}:0`,'admin@example.test',{action:'skip',note:'Sonuç doğrulanamadı, tekrar gönderilmeden kapatıldı.'}); assert.equal(outbox.claim(batch.id,0).state,'skipped'); const row=outbox.queue()[0];assert.equal(row.providerId,null);assert.equal(row.state,'skipped'); assert.equal(outbox.prepare('2026-09-07','review@example.test',[{channel:'email',text:'Changed'},{channel:'telegram'}]).closed,true); }); test('review queue exposes expired interrupted sends but does not allow reviewing a live claim',t=>{ const {outbox}=fixture(t); const batch=outbox.prepare('2026-09-07','review@example.test',[{channel:'email'}]); outbox.claim(batch.id,0,1000); assert.equal(outbox.queue(2000)[0].state,'sending'); assert.throws(()=>outbox.resolve(`${batch.id}:0`,'admin@example.test',{action:'skip',note:'Bu gönderim halen etkin durumdadır.'}),{status:409}); assert.equal(outbox.queue(121001)[0].state,'uncertain'); }); test('Telegram provider receipt is persisted for each message and missing receipt stays uncertain',async t=>{ const {outbox}=fixture(t); const telegram={store:{listUsers:()=>[{email:'telegram@example.test'}],getData:(email,key)=>key==='telegram'?{chatId:'123'}:null}, telegramConfigured:()=>true,emailConfigured:()=>false,sendTelegram:async()=>({id:'123:42',chatId:'123',messageId:42})}; const sent=await sender(outbox,telegram)(async()=>[]); assert.equal(sent.gonderilen,1);assert.equal(outbox.inspect()[0].receipt,'123:42'); const missing=await sender(outbox,{...telegram,clock:()=>new Date('2026-09-14T05:00:00Z'),sendTelegram:async()=>true})(async()=>[]); assert.equal(missing.hata,1);assert.equal(missing.gonderilen,0); const uncertain=outbox.inspect().find(row=>row.week==='2026-09-14'); assert.equal(uncertain.state,'uncertain');assert.equal(uncertain.receipt,null); });