fix: persist weekly brief delivery claims and accepted messages
This commit is contained in:
@@ -1070,3 +1070,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- Tercih her parça öncesi kontrol ediliyor. Bir parça hata verirse kalan Telegram parçaları gönderilmiyor; e-posta kanalı ayrıca deneniyor. Telegram `denenen/kabul/hata` sayaçları artık mesaj parçası sayısıdır. `gonderilen` en az bir mesaj kabulü bulunan kullanıcı sayısıdır, tüm parçaların tamamlandığı anlamına gelmez; hatalar birlikte değerlendirilmelidir.
|
- Tercih her parça öncesi kontrol ediliyor. Bir parça hata verirse kalan Telegram parçaları gönderilmiyor; e-posta kanalı ayrıca deneniyor. Telegram `denenen/kabul/hata` sayaçları artık mesaj parçası sayısıdır. `gonderilen` en az bir mesaj kabulü bulunan kullanıcı sayısıdır, tüm parçaların tamamlandığı anlamına gelmez; hatalar birlikte değerlendirilmelidir.
|
||||||
- Doğrulama: 248 testlik tüm süit ve ek 1 Telegram taşıma testi geçti; build başarılı (mevcut büyük paket uyarısı sürüyor). 205 takip kaydı, 15 üzeri sonuç, tarih sınırları, uzun Türkçe/emoji içeriğin birebir birleşmesi, parça hatası ve gerçek HTTP çağrısı yerine sahte taşıyıcı ile düz metin gövdesi doğrulandı. Gerçek mesaj gönderilmedi.
|
- Doğrulama: 248 testlik tüm süit ve ek 1 Telegram taşıma testi geçti; build başarılı (mevcut büyük paket uyarısı sürüyor). 205 takip kaydı, 15 üzeri sonuç, tarih sınırları, uzun Türkçe/emoji içeriğin birebir birleşmesi, parça hatası ve gerçek HTTP çağrısı yerine sahte taşıyıcı ile düz metin gövdesi doğrulandı. Gerçek mesaj gönderilmedi.
|
||||||
- Açık işler: kalıcı parça/kanal teslimat kaydı, zamanlayıcı/yeniden deneme, sağlayıcı oran limitleri ve izinli test alıcısında kabul. Bu aşama çökme sonrası tam-bir-kez teslimat garantisi vermez. Kaynakta sözleşme tarihi bulunmadığında mevcut ihale tarihi yedeği kullanılıyor; geçmiş sonuç tarihi kaynak doğruluğu ayrıca doğrulanmalı.
|
- Açık işler: kalıcı parça/kanal teslimat kaydı, zamanlayıcı/yeniden deneme, sağlayıcı oran limitleri ve izinli test alıcısında kabul. Bu aşama çökme sonrası tam-bir-kez teslimat garantisi vermez. Kaynakta sözleşme tarihi bulunmadığında mevcut ihale tarihi yedeği kullanılıyor; geçmiş sonuç tarihi kaynak doğruluğu ayrıca doğrulanmalı.
|
||||||
|
|
||||||
|
### 2026-09-06 — Haftalık özet kalıcı teslimat ve kesinti deneyi
|
||||||
|
|
||||||
|
- Üretim haftalık gönderim işlevi özel veri volume'undaki `brief.sqlite` deposuna bağlandı. Hafta (İstanbul pazartesisi), kullanıcı ve mesaj/parça konumu bazında kalıcı kayıt var. İlk mesaj planı transaction içinde saklanıyor; aynı haftada farklı içerik veya alıcı planı otomatik yeniden gönderilmiyor. Bekleyen içerik değişimleri inceleme durumuna alınıyor.
|
||||||
|
- SQLite transaction ile tek işçi 120 saniyeliğine mesajı sahipleniyor. Kabul edilen mesaj sonraki çağrıda atlanıyor. Süresi dolmuş yarım çağrı veya sağlayıcı/yerel kabul kaydı hatası belirsiz tutuluyor; otomatik tekrar yok. Telegram kaydı mevcut taşıyıcının boolean kabulüne dayanır, Telegram message_id henüz saklanmıyor. `atlanan` kabul kaydı nedeniyle atlanan mesajları, `bekleyen` aktif/belirsiz/değişen plan engellerini raporluyor.
|
||||||
|
- Zamanlayıcı gönderimden önce yeni hafta-tamamlandı dosyası yazmıyor. Kaynak yükleme hatası gönderim kaydı oluşturmuyor; sonraki Pazartesi saatlik kontrolde tekrar denenebilir. Eski brief-son.json geçiş haftasında çift gönderimi önlemek için hâlâ okunuyor; eski kayıt gerçek teslimatı kanıtlamadığından geçiş haftası kontrolü gerekiyor.
|
||||||
|
- Doğrulama: 256 test geçti; build başarılı. Yeni testler kalıcı kabul sonrası tekrar, aynı anda iki gönderici, kullanıcı/hafta ayrımı, belirsiz hata, değişen içerik, kaynak hatası sonrası devam, gerçek alt süreci SIGKILL ile durdurma ve tam volume yedek/geri yüklemeden sonra tekrar engelini kapsıyor. Gerçek mesaj gönderilmedi.
|
||||||
|
- Açık kalanlar: belirsiz kayıtları sağlayıcı kanıtı ve denetim iziyle karara bağlayan yönetici akışı, Telegram sağlayıcı mesaj kimliği, gerçek sağlayıcı kabulü, Pazartesi tamamen kapalı kalmış sunucunun telafisi ve geçiş haftası operasyonu. Bu aşama bütün haftalık bildirim kabul maddelerini kapatmaz; plan aktif.
|
||||||
|
|||||||
@@ -85,3 +85,7 @@ Rakip izleme (`rakip_izleme`) da içerik sürümü zorunlu anahtarlara dahil edi
|
|||||||
Bildirim tercihleri (`reminder`) sürümlü yazım sözleşmesine dahil edildi. Saat alanları HH:mm, pushEnabled/quietEnabled alanları boolean olmalıdır. Frontend/backend birlikte güncellenmelidir. Eski e-postaEnabled kaydı e-posta aboneliği iptali anlamına gelmez; gönderimler abonelik yönetiminden denetlenir.
|
Bildirim tercihleri (`reminder`) sürümlü yazım sözleşmesine dahil edildi. Saat alanları HH:mm, pushEnabled/quietEnabled alanları boolean olmalıdır. Frontend/backend birlikte güncellenmelidir. Eski e-postaEnabled kaydı e-posta aboneliği iptali anlamına gelmez; gönderimler abonelik yönetiminden denetlenir.
|
||||||
|
|
||||||
Haftalık özet `brief` anahtarı user-store tarafından artık kabul edilir. `/api/me/brief` GET tercih ve revision döndürür; metin/brief önizlemesi için `?preview=1` gerekir. POST boolean kapali ve okunan revision ister. Frontend/backend birlikte güncellenmelidir; eski sürüme dönmek brief kaydını tekrar reddeder ve opt-out tercihini okuyamayabilir.
|
Haftalık özet `brief` anahtarı user-store tarafından artık kabul edilir. `/api/me/brief` GET tercih ve revision döndürür; metin/brief önizlemesi için `?preview=1` gerekir. POST boolean kapali ve okunan revision ister. Frontend/backend birlikte güncellenmelidir; eski sürüme dönmek brief kaydını tekrar reddeder ve opt-out tercihini okuyamayabilir.
|
||||||
|
|
||||||
|
Haftalık özet teslimat deposu `IHALEIST_USER_DATA_DIR/brief.sqlite` içindedir (ayar yoksa `server/data`). Alıcı, haftalık mesaj planı, mesaj içeriği, parça konumu, işçi sahipliği ve kabul/belirsizlik kayıtlarını içerir; özel veridir. Tam volume yedeği bu SQLite dosyasını kapsar, yalnız kullanıcı/belge yedeği kapsamaz. Dosya 0600, yeni dizin 0700 oluşturulur. Kod geri dönüşü bu kayıtları yok sayıp aynı haftanın özetini tekrar gönderebilir; eski kodun otomatik gönderimini açmadan geçiş haftası sağlayıcı kayıtları değerlendirilmelidir.
|
||||||
|
|
||||||
|
Eski `server/data/brief-son.json` silinmez: eski sürümün aynı hafta için işaretlediği otomatik gönderim geçişte tekrar yapılmaz. Eski sürüm bu işareti göndermeden önce yazdığı için bu kayıt teslimatı kanıtlamaz. Geçiş haftası manuel kontrol gerektirir. Yeni zamanlayıcı bu dosyaya tamamlandı kaydı yazmaz; kaynak yüklenemezse Pazartesi sonraki saatlik kontrolde tekrar yükleyebilir. Sağlayıcı çağrısına girilmiş fakat kabulü doğrulanamamış mesajlar otomatik yeniden gönderilmez. 120 saniyelik sahipliği sona eren yarım kayıt da belirsiz olur. `brief-outbox.mjs` içindeki salt-okuma `inspect()` sonuçları kayıt incelemesine yardım eder; yönetici karar ekranı/karar geçmişi henüz tamamlanmadı. Kayıtları silmek veya doğrudan pending yapmak yeniden mesaj gönderme riski taşır.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { briefWeekKey } from './brief-outbox.mjs';
|
||||||
import { splitBriefText } from './brief-content.mjs';
|
import { splitBriefText } from './brief-content.mjs';
|
||||||
|
|
||||||
/** Delivery accounting shared by scheduled and administrator-triggered briefs. */
|
/** Delivery accounting shared by scheduled and administrator-triggered briefs. */
|
||||||
export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) {
|
export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, outbox, clock = () => new Date(), warn = console.warn }) {
|
||||||
return async function sendBrief(tenderGetir) {
|
return async function sendBrief(tenderGetir) {
|
||||||
const tenders = await tenderGetir();
|
const tenders = await tenderGetir();
|
||||||
const r = { kullanici:0, gonderilen:0, bos:0, hata:0, kapali:0, kanalsiz:0,
|
const r = { kullanici:0, gonderilen:0, bos:0, hata:0, kapali:0, kanalsiz:0, atlanan:0, bekleyen:0,
|
||||||
kanallar:{ telegram:{denenen:0,kabul:0,hata:0}, email:{denenen:0,kabul:0,hata:0} } };
|
kanallar:{ telegram:{denenen:0,kabul:0,hata:0}, email:{denenen:0,kabul:0,hata:0} } };
|
||||||
for (const user of store.listUsers()) {
|
for (const user of store.listUsers()) {
|
||||||
r.kullanici++;
|
r.kullanici++;
|
||||||
@@ -18,28 +19,41 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
|
|||||||
const telegram = store.getData(user.email, 'telegram');
|
const telegram = store.getData(user.email, 'telegram');
|
||||||
if (telegramConfigured() && telegram?.chatId) {
|
if (telegramConfigured() && telegram?.chatId) {
|
||||||
for (const part of splitBriefText(text(brief))) channels.push({
|
for (const part of splitBriefText(text(brief))) channels.push({
|
||||||
|
payload:{channel:'telegram',chatId:telegram.chatId,text:part},
|
||||||
name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }),
|
name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }),
|
||||||
valid:receipt => receipt === true,
|
valid:receipt => receipt === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (emailConfigured()) channels.push({
|
if (emailConfigured()) channels.push({
|
||||||
|
payload:{channel:'email',to:user.email,subject:`İhaleist — Haftalık Brief (${brief.hafta})`,html:html(brief),text:text(brief)},
|
||||||
name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }),
|
name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }),
|
||||||
valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()),
|
valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()),
|
||||||
});
|
});
|
||||||
if (!channels.length) r.kanalsiz++;
|
if (!channels.length) { r.kanalsiz++; continue; }
|
||||||
|
const batch = outbox?.prepare(briefWeekKey(clock()),user.email,channels.map(channel=>channel.payload));
|
||||||
|
if (batch?.changed) { r.bekleyen++; r.hata++; continue; }
|
||||||
const failedChannels = new Set();
|
const failedChannels = new Set();
|
||||||
for (const channel of channels) {
|
for (const [position,channel] of channels.entries()) {
|
||||||
if (failedChannels.has(channel.name)) continue;
|
if (failedChannels.has(channel.name)) continue;
|
||||||
// An earlier channel can await the network while the user disables briefs.
|
// An earlier channel can await the network while the user disables briefs.
|
||||||
if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; }
|
if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; }
|
||||||
|
const claim = outbox?.claim(batch.id,position,clock().getTime());
|
||||||
|
if (claim?.state === 'accepted') { r.atlanan++; continue; }
|
||||||
|
if (claim && claim.state !== 'sending') {
|
||||||
|
r.bekleyen++; failedChannels.add(channel.name);
|
||||||
|
if (claim.state === 'uncertain') failed=true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const counts = r.kanallar[channel.name];
|
const counts = r.kanallar[channel.name];
|
||||||
counts.denenen++;
|
counts.denenen++;
|
||||||
try {
|
try {
|
||||||
const receipt = await channel.send();
|
const receipt = await channel.send();
|
||||||
if (!channel.valid(receipt)) throw new Error('Sağlayıcı gönderimi doğrulamadı.');
|
if (!channel.valid(receipt)) throw new Error('Sağlayıcı gönderimi doğrulamadı.');
|
||||||
|
if (claim) outbox.accept(claim,receipt === true ? 'telegram-accepted' : receipt.id);
|
||||||
counts.kabul++;
|
counts.kabul++;
|
||||||
accepted = true;
|
accepted = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (claim) outbox.hold(claim,error);
|
||||||
counts.hata++;
|
counts.hata++;
|
||||||
failedChannels.add(channel.name);
|
failedChannels.add(channel.name);
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { mkdirSync, chmodSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
||||||
|
export function briefWeekKey(now = new Date()) {
|
||||||
|
const date = new Date(now.getTime() + 3 * 3600000);
|
||||||
|
date.setUTCDate(date.getUTCDate() - (date.getUTCDay()+6)%7);
|
||||||
|
return date.toISOString().slice(0,10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A claimed message is never blindly replayed after an interrupted provider call. */
|
||||||
|
export function createBriefOutbox(directory) {
|
||||||
|
function transaction(work) {
|
||||||
|
mkdirSync(directory,{recursive:true,mode:0o700});
|
||||||
|
const file=path.join(directory,'brief.sqlite');
|
||||||
|
const db = new DatabaseSync(file);
|
||||||
|
try {
|
||||||
|
chmodSync(file,0o600);
|
||||||
|
db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS brief_batches(id TEXT PRIMARY KEY,week TEXT NOT NULL,email TEXT NOT NULL,manifest TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS brief_messages(batch TEXT NOT NULL,position INTEGER NOT NULL,state TEXT NOT NULL,lease TEXT,lease_until INTEGER,receipt TEXT,error TEXT,PRIMARY KEY(batch,position));`);
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
const result=work(db);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch(error) { try { db.exec('ROLLBACK'); } catch {} throw error; }
|
||||||
|
finally { db.close(); }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prepare(week,email,messages) {
|
||||||
|
const id=hash(JSON.stringify([week,email])),manifest=JSON.stringify(messages);
|
||||||
|
return transaction(db=>{
|
||||||
|
const row=db.prepare('SELECT manifest FROM brief_batches WHERE id=?').get(id);
|
||||||
|
if(row) {
|
||||||
|
const changed=row.manifest!==manifest;
|
||||||
|
if(changed) db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND state='pending'")
|
||||||
|
.run('Haftalık özet içeriği veya alıcı kanalı değişti; yeniden gönderim öncesi inceleme gerekli.',id);
|
||||||
|
return {id,changed};
|
||||||
|
}
|
||||||
|
db.prepare('INSERT INTO brief_batches VALUES (?,?,?,?)').run(id,week,email,manifest);
|
||||||
|
const insert=db.prepare("INSERT INTO brief_messages(batch,position,state) VALUES (?,?,'pending')");
|
||||||
|
messages.forEach((_,i)=>insert.run(id,i));
|
||||||
|
return {id,changed:false};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
claim(batch,position,clock=Date.now()) {
|
||||||
|
return transaction(db=>{
|
||||||
|
const row=db.prepare('SELECT * FROM brief_messages WHERE batch=? AND position=?').get(batch,position);
|
||||||
|
if(!row)throw new Error('Özet teslimat kaydı bulunamadı.');
|
||||||
|
if(row.state==='accepted')return {state:'accepted'};
|
||||||
|
if(row.state==='uncertain')return {state:'uncertain'};
|
||||||
|
if(row.state==='sending') {
|
||||||
|
if(row.lease_until>clock)return {state:'busy'};
|
||||||
|
db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND position=?").run('Kesintiye uğrayan gönderim; sağlayıcı kaydı incelenmeli.',batch,position);
|
||||||
|
return {state:'uncertain'};
|
||||||
|
}
|
||||||
|
const lease=randomUUID();
|
||||||
|
db.prepare("UPDATE brief_messages SET state='sending',lease=?,lease_until=? WHERE batch=? AND position=?").run(lease,clock+120000,batch,position);
|
||||||
|
return {state:'sending',batch,position,lease};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
accept(claim,receipt) {
|
||||||
|
if(typeof receipt!=='string'||!receipt.trim())throw new Error('Gönderim kabul kaydı gerekli.');
|
||||||
|
return transaction(db=>{
|
||||||
|
const result=db.prepare("UPDATE brief_messages SET state='accepted',receipt=?,error=NULL WHERE batch=? AND position=? AND lease=? AND state='sending'")
|
||||||
|
.run(receipt,claim.batch,claim.position,claim.lease);
|
||||||
|
if(result.changes!==1)throw new Error('Özet gönderim kaydı değişti.');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hold(claim,error) {
|
||||||
|
return transaction(db=>db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND position=? AND lease=? AND state='sending'")
|
||||||
|
.run(String(error?.message || error),claim.batch,claim.position,claim.lease).changes===1);
|
||||||
|
},
|
||||||
|
inspect() {
|
||||||
|
return transaction(db=>db.prepare('SELECT b.week,b.email,m.* FROM brief_messages m JOIN brief_batches b ON b.id=m.batch ORDER BY b.week DESC,b.email,m.position').all());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-6
@@ -1,8 +1,9 @@
|
|||||||
// Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar,
|
// Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar,
|
||||||
// önceki yedi takvim günündeki takipli sonuç kayıtları.
|
// önceki yedi takvim günündeki takipli sonuç kayıtları.
|
||||||
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
|
// Telegram (bağlıysa) + e-posta; kalıcı mesaj kabul kayıtları brief.sqlite.
|
||||||
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
|
// Eski brief-son.json yalnız geçiş haftasında tekrar göndermemek için okunur.
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { createBriefOutbox } from './brief-outbox.mjs';
|
||||||
import { createBriefBuilder } from './brief-content.mjs';
|
import { createBriefBuilder } from './brief-content.mjs';
|
||||||
import { createBriefSender } from './brief-delivery.mjs';
|
import { createBriefSender } from './brief-delivery.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -19,9 +20,6 @@ const DURUM_DOSYASI = path.join(__dirname, 'data', 'brief-son.json');
|
|||||||
function sonGonderim() {
|
function sonGonderim() {
|
||||||
try { return JSON.parse(fs.readFileSync(DURUM_DOSYASI, 'utf8')); } catch { return {}; }
|
try { return JSON.parse(fs.readFileSync(DURUM_DOSYASI, 'utf8')); } catch { return {}; }
|
||||||
}
|
}
|
||||||
function sonGonderimYaz(d) {
|
|
||||||
try { fs.mkdirSync(path.dirname(DURUM_DOSYASI), { recursive: true }); fs.writeFileSync(DURUM_DOSYASI, JSON.stringify(d)); } catch { /* yok */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`);
|
const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`);
|
||||||
|
|
||||||
@@ -62,6 +60,7 @@ export function briefHtml(b) {
|
|||||||
export const haftalikBriefGonder = createBriefSender({
|
export const haftalikBriefGonder = createBriefSender({
|
||||||
store, buildBrief:briefOlustur, text:briefMetni, html:briefHtml,
|
store, buildBrief:briefOlustur, text:briefMetni, html:briefHtml,
|
||||||
telegramConfigured, sendTelegram, emailConfigured, sendEmail,
|
telegramConfigured, sendTelegram, emailConfigured, sendEmail,
|
||||||
|
outbox:createBriefOutbox(process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname,'data')),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function briefScheduleKey(now = new Date()) {
|
export function briefScheduleKey(now = new Date()) {
|
||||||
@@ -78,7 +77,6 @@ export function briefZamanlayici(tenderGetir) {
|
|||||||
if (!anahtar) return;
|
if (!anahtar) return;
|
||||||
const d = sonGonderim();
|
const d = sonGonderim();
|
||||||
if (d.son === anahtar) return;
|
if (d.son === anahtar) return;
|
||||||
sonGonderimYaz({ son: anahtar });
|
|
||||||
const r = await haftalikBriefGonder(tenderGetir).catch((e) => ({ hata: e.message }));
|
const r = await haftalikBriefGonder(tenderGetir).catch((e) => ({ hata: e.message }));
|
||||||
console.log('[brief] haftalık brief:', JSON.stringify(r));
|
console.log('[brief] haftalık brief:', JSON.stringify(r));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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('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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user