Skip to content

Commit 8014518

Browse files
sametbasbugclaude
andcommitted
Reserve email for announcements worth opening an inbox for
Every announcement could be emailed, including informational ones. Two things pushed the same way: the sending quota is finite, and a system that mails everything becomes a system nobody reads, taking the urgent notice down with it. Warning and critical can be emailed. Informational cannot, and the panel disables the box and says why rather than letting someone tick something the server will refuse. The gate is on the server because the panel is not what we can trust; the panel copy of the list is convenience, and a test fails if the two drift. The privacy text now describes outbound mail: which notices cannot be switched off and why, that informational announcements are never mailed, that Resend carries them from Ireland, that there is no open or click tracking, and that each sent message leaves a record. Those sentences are locked to the code that has to keep them true. The slice 4 staging rehearsal passes again. It was written on 16 July and the publication guardrails landed on 22 July; nothing in CI runs it, so it had been failing for three weeks unnoticed. One agent cannot post five times in an hour or twice within fifteen seconds, so the script now seeds a throwaway agent per publication and waits where an agent legitimately posts twice. Cleanup accepts 404 for a reply whose root was already deleted — that cascade is deliberate — and then proves nothing it created is still publicly readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cbf21ac commit 8014518

9 files changed

Lines changed: 281 additions & 36 deletions

File tree

‎scripts/orbit-email-tests.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import assert from 'node:assert/strict';
2+
import { readFileSync } from 'node:fs';
23
import { describe, test } from 'node:test';
34
import { ResendSender, createEmailSender } from '../src/server/notifications/email';
4-
import { announcementEmail, recordRemovedEmail } from '../src/server/notifications/messages';
5+
import {
6+
ANNOUNCEMENT_EMAIL_SEVERITIES,
7+
announcementEmail,
8+
recordRemovedEmail,
9+
} from '../src/server/notifications/messages';
510

611
function sender(respond: (request: Request) => Response | Promise<Response>) {
712
const seen: Request[] = [];
@@ -80,4 +85,19 @@ describe('giden posta', { concurrency: false }, () => {
8085
assert.match(moderation.bodyText, /kapatılamaz/u);
8186
assert.doesNotMatch(moderation.bodyText, /kapatabilirsin/u);
8287
});
88+
89+
test('panel ile sunucu aynı seviye listesini kullanır', () => {
90+
/* Panel istemci tarafında olduğu için listeyi içe aktaramıyor, kopya
91+
* tutuyor. Kopya ayrışırsa kutu açık kalır, kişi postalanacağını
92+
* sanır ve sunucu 400 döner — yani kullanıcı hatası gibi görünen bir
93+
* bizim hatamız. Kaynak dosyadan okuyup karşılaştırıyoruz. */
94+
const client = readFileSync(new URL('../src/scripts/dashboard.js', import.meta.url), 'utf8');
95+
const match = client.match(/const ANNOUNCEMENT_EMAIL_SEVERITIES = \[([^\]]*)\]/u);
96+
assert.ok(match, 'panelde seviye listesi bulunamadı');
97+
const clientList = match[1].split(',')
98+
.map((part) => part.trim().replace(/^'|'$/gu, ''))
99+
.filter((part) => part !== '');
100+
assert.deepEqual(clientList, [...ANNOUNCEMENT_EMAIL_SEVERITIES]);
101+
assert.ok(!clientList.includes('info'), 'bilgi duyuruları postalanabilir görünüyor');
102+
});
83103
});

‎scripts/orbit-site-tests.mjs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,54 @@ if (legalHtml.gizlilik) {
212212
legalHtml.gizlilik.includes('Tanıtım, pazarlama veya bülten gönderilmez'),
213213
'Gizlilik metni tanıtım gönderilmeyeceği sözünü taşımıyor.',
214214
);
215+
/* Giden posta: metin ne söz veriyorsa kod onu yapmalı. En kırılganı
216+
* seviye sınırı — kotayı korumak için konmuş bir kural ve gevşetmek
217+
* bir satırlık iş; metin ise kullanıcıya "bilgi duyurusu postalanmaz"
218+
* diye söz veriyor. */
219+
const emailMessages = fs.readFileSync(
220+
path.join(ROOT, 'src', 'server', 'notifications', 'messages.ts'),
221+
'utf8',
222+
);
223+
check(
224+
/ANNOUNCEMENT_EMAIL_SEVERITIES[^=]*=\s*\n?\s*\['warning', 'critical'\]/u.test(emailMessages),
225+
'Postalanabilir duyuru seviyeleri değişmiş; gizlilik metni yalnız uyarı ve kritik diyor.',
226+
);
227+
check(
228+
legalHtml.gizlilik.includes('<strong>uyarı</strong> ve <strong>kritik</strong>'),
229+
'Gizlilik metni hangi duyuruların postalandığını söylemiyor.',
230+
);
231+
check(
232+
legalHtml.gizlilik.includes('Bilgi seviyesindeki duyurular postalanmaz'),
233+
'Gizlilik metni bilgi duyurularının postalanmadığı sözünü taşımıyor.',
234+
);
235+
/* Kapatılabilirlik iki yönlü bir söz: duyuru kapatılabilir, hesap
236+
* bildirimi kapatılamaz. İkisi de metinde ve ikisi de kodda. */
237+
check(
238+
legalHtml.gizlilik.includes('<strong>kapatılamaz</strong>'),
239+
'Gizlilik metni hangi bildirimlerin kapatılamadığını söylemiyor.',
240+
);
241+
check(
242+
legalHtml.gizlilik.includes('<strong>tek tıkla kapatabilirsin</strong>'),
243+
'Gizlilik metni duyuru postalarının kapatılabildiğini söylemiyor.',
244+
);
245+
check(
246+
fs.readFileSync(path.join(ROOT, 'src', 'server', 'repositories', 'notification-repository.ts'), 'utf8')
247+
.includes('announcement_emails_enabled = 1'),
248+
'Duyuru tercihi süzgeci kalkmış; gizlilik metni kapatınca gelmeyeceğini söylüyor.',
249+
);
250+
/* Resend metinde adıyla ve bölgesiyle sayılı. Sağlayıcı değişirse veya
251+
* bölge kayarsa, KVKK'da yurt dışına aktarım anlatımı yanlış olur. */
252+
check(
253+
fs.readFileSync(path.join(ROOT, 'src', 'server', 'notifications', 'email.ts'), 'utf8')
254+
.includes('api.resend.com'),
255+
'Posta sağlayıcısı değişmiş; gizlilik metni Resend diyor.',
256+
);
257+
check(
258+
legalHtml.gizlilik.includes('<strong>Resend</strong>')
259+
&& legalHtml.gizlilik.includes('<strong>İrlanda</strong>'),
260+
'Gizlilik metni posta sağlayıcısını veya bulunduğu ülkeyi saymıyor.',
261+
);
262+
215263
/* Giriş izi: hangi alanların toplandığı, neyin toplanmadığı ve süresi.
216264
* Üçü de metinde yazılı, üçü de kodda ölçülebilir. */
217265
const signInMigration = fs.readFileSync(

‎scripts/orbit-slice4-staging-e2e.ts‎

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ function execute(sql: string): Array<Record<string, unknown>> {
3030
return parsed.flatMap((item) => item.results ?? []);
3131
}
3232

33+
/* Sunucu aynı ajandan iki KAYIT OLUŞTURMA arasında en az 15 saniye
34+
* istiyor (publication.create.minimum_interval, migration 0017). Kural
35+
* bu betikten sonra girdi ve betik onu hiç görmüyordu; staging gerçek
36+
* saatle çalıştığı için her ardışık yazım 429 alıyordu.
37+
*
38+
* Revision'lar (PATCH) tetikleyiciyi güncellemiyor, o yüzden yalnız yeni
39+
* kayıtlardan önce bekliyoruz. Bekleme kuralın kendisinden bir saniye
40+
* fazla: sınırda beklemek saat kaymasına bağımlı bir test demek. */
41+
const PUBLICATION_BURST_INTERVAL_MS = 16_000;
42+
43+
async function settleBurstWindow(): Promise<void> {
44+
await new Promise((resolve) => setTimeout(resolve, PUBLICATION_BURST_INTERVAL_MS));
45+
}
46+
3347
async function waitReady(): Promise<void> {
3448
for (let attempt = 0; attempt < 60; attempt += 1) {
3549
const response = await fetch(`${ORIGIN}/v1/records`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
@@ -39,6 +53,12 @@ async function waitReady(): Promise<void> {
3953
assert.fail('Slice 4 deployment readiness timeout.');
4054
}
4155

56+
/* Seedlenen her ajan buraya yazılıyor. Kanıt sorgusu ve temizlik eskiden
57+
* ajanları elle sayıyordu; kota kuralları yüzünden ajan eklemek zorunda
58+
* kaldığımızda o listeler sessizce eksik kalırdı — sonuç, temizlenmeyen
59+
* canlı kimlik bilgisi olurdu. */
60+
const seededAgents: Array<{ id: string; token: string; credentialId: string }> = [];
61+
4262
async function seedAgent(
4363
ownerId: string,
4464
handle: string,
@@ -72,7 +92,9 @@ async function seedAgent(
7292
${credential.hashVersion}, 'feed:read records:write', ${quote(ownerId)}, ${now}
7393
)
7494
`);
75-
return { id: agentId, token: credential.token, credentialId: credential.selector };
95+
const seeded = { id: agentId, token: credential.token, credentialId: credential.selector };
96+
seededAgents.push(seeded);
97+
return seeded;
7698
}
7799

78100
async function ownerSession(ownerId: string, sessionPepper: string, csrfPepper: string, now: number) {
@@ -141,7 +163,18 @@ const direct = await seedAgent(ownerId, `slice4-direct-${suffix}`, 'direct_publi
141163
const approval = await seedAgent(ownerId, `slice4-review-${suffix}`, 'approval_required', agentPepper, now + 1);
142164
const readonly = await seedAgent(ownerId, `slice4-readonly-${suffix}`, 'read_only', agentPepper, now + 2);
143165
const concurrentDirect = await seedAgent(ownerId, `slice4-concurrent-${suffix}`, 'direct_publish', agentPepper, now + 3);
144-
const concurrentReview = await seedAgent(ownerId, `slice4-concurrent-review-${suffix}`, 'approval_required', agentPepper, now + 4);
166+
/* Saatlik gönderi kotası ajan başına 2 (migration 0017). Bu betik tek
167+
* ajana beş gönderi yazdırıyordu ve kural girdiğinden beri geçemiyordu.
168+
* Test ettiğimiz şeyler kotayla ilgili değil, o yüzden tek kullanımlık
169+
* ajan seedliyoruz: kotayı gevşetmek yerine kotanın altında kalıyoruz. */
170+
let disposableCount = 0;
171+
const disposableAgent = async (mode: 'direct_publish' | 'approval_required' = 'direct_publish') => {
172+
disposableCount += 1;
173+
return await seedAgent(
174+
ownerId, `slice4-tek-${suffix}-${disposableCount}`, mode, agentPepper, now + 10 + disposableCount,
175+
);
176+
};
177+
145178
const session = await ownerSession(ownerId, sessionPepper, csrfPepper, now + 3);
146179
const createdRecordIds: string[] = [];
147180
const cleanupRecordIds: string[] = [];
@@ -163,6 +196,7 @@ const replay = await agentWrite(direct.token, '/v1/records', {
163196
assert.equal(replay.status, 201);
164197
assert.equal(replay.headers.get('idempotency-replayed'), 'true');
165198

199+
await settleBurstWindow();
166200
const reply = await agentWrite(direct.token, `/v1/records/${directBody.record.id}/replies`, {
167201
bodyMarkdown: 'Staging reply kökünü sunucudan alıyor.',
168202
}, `slice4-${suffix}-reply`);
@@ -224,6 +258,7 @@ const concurrentPost = await pair(() => agentWrite(concurrentDirect.token, '/v1/
224258
const concurrentPostId = concurrentPost.record!.id;
225259
createdRecordIds.push(concurrentPostId);
226260
cleanupRecordIds.push(concurrentPostId);
261+
await settleBurstWindow();
227262
const concurrentReply = await pair(() => agentWrite(
228263
concurrentDirect.token,
229264
`/v1/records/${concurrentPostId}/replies`,
@@ -236,11 +271,16 @@ await pair(() => agentWrite(concurrentDirect.token, `/v1/records/${concurrentPos
236271
bodyMarkdown: `Staging paralel idempotency revision ${suffix}.`,
237272
}, `slice4-${suffix}-concurrent-revision`, 'PATCH'), 200);
238273

274+
/* Slug çakışması global bir sorun, ajana bağlı değil — o yüzden yarışı
275+
* iki ayrı ajana yaptırmak testin ölçtüğü şeyi değiştirmiyor. Tek ajanla
276+
* yapılamaz: aynı anda iki kayıt, 15 saniye kuralının tam olarak
277+
* yasakladığı şey. */
278+
const [slugAgentA, slugAgentB] = [await disposableAgent(), await disposableAgent()];
239279
const slugRace = await Promise.all([
240-
agentWrite(concurrentDirect.token, '/v1/records', {
280+
agentWrite(slugAgentA.token, '/v1/records', {
241281
bodyMarkdown: `Staging aynı anda üretilen slug ${suffix}.`,
242282
}, `slice4-${suffix}-slug-a`),
243-
agentWrite(concurrentDirect.token, '/v1/records', {
283+
agentWrite(slugAgentB.token, '/v1/records', {
244284
bodyMarkdown: `Staging aynı anda üretilen slug ${suffix}.`,
245285
}, `slice4-${suffix}-slug-b`),
246286
]);
@@ -253,12 +293,17 @@ assert.ok(slugBodies.some((item) => item.record.slug.endsWith(item.record.id.rep
253293
createdRecordIds.push(...slugBodies.map((item) => item.record.id));
254294
cleanupRecordIds.push(...slugBodies.map((item) => item.record.id));
255295

296+
/* Her bekleyen kayıt kendi ajanıyla üretiliyor. Üç kayıt tek ajandan
297+
gelirse saatlik gönderi kotasına takılıyor; testin konusu onay akışı,
298+
kota değil. Ajanı geri döndürüyoruz çünkü geri çekme adımı aynı
299+
kimlikle imzalanmak zorunda. */
256300
const createPending = async (bodyMarkdown: string, key: string) => {
257-
const response = await agentWrite(concurrentReview.token, '/v1/records', { bodyMarkdown }, key);
258-
assert.equal(response.status, 202);
301+
const agent = await disposableAgent('approval_required');
302+
const response = await agentWrite(agent.token, '/v1/records', { bodyMarkdown }, key);
303+
assert.equal(response.status, 202, await response.clone().text());
259304
const body = await response.json() as { record: { id: string } };
260305
createdRecordIds.push(body.record.id);
261-
return body.record.id;
306+
return { id: body.record.id, agent };
262307
};
263308
const reviewFor = async (recordId: string) => {
264309
const reviews = await ownerRequest(session, '/v1/approvals').then((response) => response.json()) as {
@@ -268,39 +313,40 @@ const reviewFor = async (recordId: string) => {
268313
assert.ok(match);
269314
return match.id;
270315
};
271-
const approveId = await createPending(`Staging paralel onay ${suffix}.`, `slice4-${suffix}-approve-create`);
316+
const { id: approveId } = await createPending(`Staging paralel onay ${suffix}.`, `slice4-${suffix}-approve-create`);
272317
const approveReview = await reviewFor(approveId);
273318
await pair(() => ownerRequest(session, `/v1/approvals/${approveReview}/approve`, 'POST', {
274319
note: 'parallel staging approval',
275320
}, `slice4-${suffix}-concurrent-approve`), 200);
276321
cleanupRecordIds.push(approveId);
277322

278-
const rejectId = await createPending(`Staging paralel ret ${suffix}.`, `slice4-${suffix}-reject-create`);
323+
const { id: rejectId } = await createPending(`Staging paralel ret ${suffix}.`, `slice4-${suffix}-reject-create`);
279324
const rejectReview = await reviewFor(rejectId);
280325
await pair(() => ownerRequest(session, `/v1/approvals/${rejectReview}/reject`, 'POST', {
281326
note: 'parallel staging rejection',
282327
}, `slice4-${suffix}-concurrent-reject`), 200);
283328

284-
const withdrawId = await createPending(`Staging paralel geri çekme ${suffix}.`, `slice4-${suffix}-withdraw-create`);
329+
const { id: withdrawId, agent: withdrawAgent } = await createPending(`Staging paralel geri çekme ${suffix}.`, `slice4-${suffix}-withdraw-create`);
285330
await pair(() => agentWrite(
286-
concurrentReview.token,
331+
withdrawAgent.token,
287332
`/v1/records/${withdrawId}/withdraw`,
288333
{},
289334
`slice4-${suffix}-concurrent-withdraw`,
290335
), 200);
291336

292-
const agentDeleteRecord = await agentWrite(concurrentDirect.token, '/v1/records', {
337+
const agentDeleteAgent = await disposableAgent();
338+
const agentDeleteRecord = await agentWrite(agentDeleteAgent.token, '/v1/records', {
293339
bodyMarkdown: `Staging paralel ajan silme ${suffix}.`,
294340
}, `slice4-${suffix}-agent-delete-create`).then((response) => response.json()) as { record: { id: string } };
295341
createdRecordIds.push(agentDeleteRecord.record.id);
296342
await pair(() => agentWrite(
297-
concurrentDirect.token,
343+
agentDeleteAgent.token,
298344
`/v1/records/${agentDeleteRecord.record.id}/delete`,
299345
{ reason: 'parallel staging agent delete' },
300346
`slice4-${suffix}-concurrent-agent-delete`,
301347
), 200);
302348

303-
const sponsorDeleteRecord = await agentWrite(concurrentDirect.token, '/v1/records', {
349+
const sponsorDeleteRecord = await agentWrite((await disposableAgent()).token, '/v1/records', {
304350
bodyMarkdown: `Staging paralel sponsor silme ${suffix}.`,
305351
}, `slice4-${suffix}-sponsor-delete-create`).then((response) => response.json()) as { record: { id: string } };
306352
createdRecordIds.push(sponsorDeleteRecord.record.id);
@@ -317,27 +363,40 @@ const evidence = execute(`
317363
(SELECT COUNT(*) FROM records WHERE id IN (${createdRecordIds.map(quote).join(',')})) AS records,
318364
(SELECT COUNT(*) FROM audit_events WHERE subject_type = 'record' AND subject_id IN (${createdRecordIds.map(quote).join(',')})) AS audits,
319365
(SELECT COUNT(*) FROM idempotency_keys WHERE
320-
(principal_type = 'agent' AND principal_id IN (${quote(direct.id)}, ${quote(approval.id)}, ${quote(concurrentDirect.id)}, ${quote(concurrentReview.id)}))
366+
(principal_type = 'agent' AND principal_id IN (${seededAgents.map((agent) => quote(agent.id)).join(',')}))
321367
OR (principal_type = 'account' AND principal_id = ${quote(ownerId)})
322368
) AS idempotency_rows
323369
`)[0] as { records: number; audits: number; idempotency_rows: number };
324370
assert.equal(Number(evidence.records), createdRecordIds.length);
325371
assert.ok(Number(evidence.audits) >= 15);
326372
assert.ok(Number(evidence.idempotency_rows) >= 15);
327373

374+
/* Kök gönderiyi silmek yanıt ağacını da siliyor — bu, ayrı bir testin
375+
* doğruladığı kasıtlı davranış. Dolayısıyla listede önce kök, sonra onun
376+
* yanıtı geldiğinde ikincisi 404 döner ve bu bir arıza değil, istediğimiz
377+
* son durumun ta kendisi. Temizliğin ölçütü "her çağrı 200 döndü mü"
378+
* değil, "geride yayımlanmış kayıt kaldı mı". */
328379
for (const recordId of cleanupRecordIds) {
329380
const ownerDelete = await ownerRequest(session, `/v1/manage/records/${recordId}/delete`, 'POST', {
330381
reason: 'Slice 4 staging cleanup.',
331382
}, `slice4-${suffix}-delete-${recordId}`);
332-
assert.equal(ownerDelete.status, 200);
383+
assert.ok(
384+
ownerDelete.status === 200 || ownerDelete.status === 404,
385+
`Cleanup delete failed for ${recordId}: ${ownerDelete.status}`,
386+
);
387+
}
388+
for (const recordId of cleanupRecordIds) {
389+
assert.equal(
390+
(await fetch(`${ORIGIN}/v1/records/${recordId}`)).status,
391+
404,
392+
`Cleanup left ${recordId} publicly readable.`,
393+
);
333394
}
334395
execute(`
335396
UPDATE agent_credentials SET revoked_at = ${Date.now()}, revoked_reason = 'staging_test_cleanup'
336-
WHERE id IN (${quote(direct.credentialId)}, ${quote(approval.credentialId)}, ${quote(readonly.credentialId)},
337-
${quote(concurrentDirect.credentialId)}, ${quote(concurrentReview.credentialId)});
397+
WHERE id IN (${seededAgents.map((agent) => quote(agent.credentialId)).join(',')});
338398
UPDATE agents SET status = 'retired', updated_at = ${Date.now()}
339-
WHERE id IN (${quote(direct.id)}, ${quote(approval.id)}, ${quote(readonly.id)},
340-
${quote(concurrentDirect.id)}, ${quote(concurrentReview.id)});
399+
WHERE id IN (${seededAgents.map((agent) => quote(agent.id)).join(',')});
341400
UPDATE sessions SET revoked_at = ${Date.now()}, revoked_reason = 'staging_test_cleanup'
342401
WHERE id = ${quote(session.id)}
343402
`);

‎scripts/orbit-slice5-tests.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,54 @@ describe('Orbit V6 Slice 5 dashboard and platform core', { concurrency: false },
10731073
assert.equal(after.length, 0, 'reddedilen istek yine de kuyruğa yazmış');
10741074
});
10751075

1076+
test('an informational announcement is never emailed', async () => {
1077+
/* Gönderim kotası sınırlı ve her duyuruyu postalayan bir sistem
1078+
* okunmaz olur. Kapı sunucuda: panel kutuyu kapatıyor ama panele
1079+
* güvenemeyiz, istek elle de kurulabilir. */
1080+
await fetch(`${baseUrl}/__test/seed-email-world`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
1081+
const created = await ownerRequest('/v1/admin/announcements', 'POST', {
1082+
title: 'Sıradan bilgi', bodyMarkdown: 'Kimsenin kutusunu açmasını gerektirmiyor.',
1083+
severity: 'info', audienceType: 'all_agents', targetAgentId: null,
1084+
startsAt: NOW - 1000, expiresAt: null,
1085+
});
1086+
assert.equal(created.status, 201);
1087+
const id = (await created.json() as { announcement: { id: string } }).announcement.id;
1088+
1089+
const rejected = await ownerRequest(`/v1/admin/announcements/${id}/publish`, 'POST', { sendEmail: true });
1090+
assert.equal(rejected.status, 400, 'bilgi duyurusu postalanabiliyor');
1091+
assert.equal(
1092+
(await rejected.json() as { error: { code: string } }).error.code,
1093+
'announcement_email_severity_invalid',
1094+
);
1095+
const after = (await (await fetch(`${baseUrl}/__test/email-deliveries`)).json() as {
1096+
deliveries: unknown[];
1097+
}).deliveries;
1098+
assert.equal(after.length, 0, 'reddedilen bilgi duyurusu yine de kuyruğa yazmış');
1099+
});
1100+
1101+
test('a warning announcement still reaches the queue', async () => {
1102+
/* Kapıyı koyduktan sonra izin verilen yolun kapandığını fark etmemek
1103+
* kolay: bu test, sınırlamanın postayı tamamen kesmediğini söylüyor.
1104+
*
1105+
* Seviye bilerek 'warning'. Kritik denemiştim ve arkamdan üç test
1106+
* düştü: okunmamış kritik duyuru, ajan yazımını 428 ile durduruyor —
1107+
* kural doğru çalışıyordu, testim ortak durumu kirletiyordu. Kapı
1108+
* ikisini ayırmıyor, o yüzden yan etkisi olmayanı seçiyorum. */
1109+
await fetch(`${baseUrl}/__test/seed-email-world`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
1110+
const created = await ownerRequest('/v1/admin/announcements', 'POST', {
1111+
title: 'Bakım penceresi', bodyMarkdown: 'Yarın gece kısa bir kesinti olacak.',
1112+
severity: 'warning', audienceType: 'all_agents', targetAgentId: null,
1113+
startsAt: NOW - 1000, expiresAt: null,
1114+
});
1115+
const id = (await created.json() as { announcement: { id: string } }).announcement.id;
1116+
const published = await ownerRequest(`/v1/admin/announcements/${id}/publish`, 'POST', { sendEmail: true });
1117+
assert.equal(published.status, 200, await published.clone().text());
1118+
const queued = (await (await fetch(`${baseUrl}/__test/email-deliveries`)).json() as {
1119+
deliveries: unknown[];
1120+
}).deliveries;
1121+
assert.equal(queued.length, 1, 'kritik duyuru postalanamıyor');
1122+
});
1123+
10761124
test('withdrawing an announcement erases it from every surface, owners included', async () => {
10771125
const agent = agents.get('slice5-external')!;
10781126
const created = await ownerRequest('/v1/admin/announcements', 'POST', {

0 commit comments

Comments
 (0)