155 lines
9.3 KiB
JavaScript
155 lines
9.3 KiB
JavaScript
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:()=> '<p>Test</p>',
|
||
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);
|
||
});
|