News post → town crier + Discord announcement pipeline
Replace the fire-and-forget Discord-only announce on publish with a
retry-safe, two-leg pipeline. When a post transitions into published-news
(false→true publish while in news, or category→news while published), an
announce_jobs row is enqueued with two INDEPENDENT delivery legs:
• town crier — sidecar POST /towncrier via uoLinkClient (stable id
`post-<id>` so a retry replaces rather than duplicates)
• discord — bot POST /internal/announce via botInternalClient
(single source of truth for the #news channel stays in the bot)
An in-process poller (utils/announceWorker) sweeps the table every
ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential
backoff (30s→2h, 6 attempts). A leg is retried on transient failures
(503/504/network) and failed fast on data/config errors (400 over-cap,
401/409). Publishing never blocks on the sidecar or Discord — enqueue is
local DB only. Parent `status` is a done/partial/failed rollup of the two
legs; posts.announced_at is stamped once both deliver.
Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry
(POST .../announce/retry) surfaced in the PostEditor for news posts.
Pure decisions (text build/caps, classification, backoff, rollup) live in
announceJobs.logic and are unit-tested (server/test/announceJobs.test.js,
10 tests). The old manual /admin/uo-link/towncrier form is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
86
server/test/announceJobs.test.js
Normal file
86
server/test/announceJobs.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const logic = require('../src/model/announceJobs/announceJobs.logic')
|
||||
|
||||
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
assert.deepEqual(lines, ['Server Update', 'Big things afoot.', 'https://uom.example/site/news'])
|
||||
})
|
||||
|
||||
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
assert.equal(lines[1], 'Hello world')
|
||||
})
|
||||
|
||||
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||
const longTitle = 'x'.repeat(500)
|
||||
const lines = logic.buildTownCrierText(
|
||||
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
for (const line of lines) assert.ok(line.length <= logic.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
assert.ok(lines[0].endsWith('…'))
|
||||
assert.ok(lines.length <= logic.MAX_LINES)
|
||||
})
|
||||
|
||||
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
assert.deepEqual(lines, ['Only a title', 'https://uom.example/site/news'])
|
||||
})
|
||||
|
||||
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||
test('classifyTownCrier: 2xx is done', () => {
|
||||
assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, 'done')
|
||||
})
|
||||
|
||||
test('classifyTownCrier: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
for (const status of [400, 401, 409]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyTownCrier: shard-transient and network errors retry', () => {
|
||||
for (const status of [503, 504, 500, 0]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── classifyDiscord ──────────────────────────────────────────────────────────
|
||||
test('classifyDiscord: ok is done, every failure retries', () => {
|
||||
assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done')
|
||||
for (const status of [400, 503, 0]) {
|
||||
assert.equal(logic.classifyDiscord({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── scheduleAfter (backoff) ──────────────────────────────────────────────────
|
||||
test('scheduleAfter returns increasing delays then null at the attempt cap', () => {
|
||||
const d1 = logic.scheduleAfter(1)
|
||||
const d2 = logic.scheduleAfter(2)
|
||||
assert.ok(d1 > 0 && d2 > d1, 'delays should grow')
|
||||
// Exhausted once attempts reach MAX_ATTEMPTS.
|
||||
assert.equal(logic.scheduleAfter(logic.MAX_ATTEMPTS), null)
|
||||
assert.equal(logic.scheduleAfter(logic.MAX_ATTEMPTS + 3), null)
|
||||
})
|
||||
|
||||
// ── rollupStatus ─────────────────────────────────────────────────────────────
|
||||
test('rollupStatus derives the parent status from the two legs', () => {
|
||||
assert.equal(logic.rollupStatus('done', 'done'), 'done')
|
||||
assert.equal(logic.rollupStatus('failed', 'failed'), 'failed')
|
||||
assert.equal(logic.rollupStatus('pending', 'pending'), 'pending')
|
||||
// One terminal, the other not matching → partial.
|
||||
assert.equal(logic.rollupStatus('done', 'pending'), 'partial')
|
||||
assert.equal(logic.rollupStatus('pending', 'failed'), 'partial')
|
||||
assert.equal(logic.rollupStatus('done', 'failed'), 'partial')
|
||||
})
|
||||
Reference in New Issue
Block a user