Added missing weight emails
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { WeightReminder } from './reminders/weightReminders.js';
|
||||
import crypto from 'crypto';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
@@ -2270,6 +2271,19 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
|
||||
};
|
||||
};
|
||||
|
||||
export const sendWeightReminderNotification = async (reminders: WeightReminder[]): Promise<boolean> => {
|
||||
if (!mailTransport || !reminders.length) return false;
|
||||
const first = reminders[0];
|
||||
const result = await mailTransport.sendMail({
|
||||
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
||||
to: first.recipient,
|
||||
subject: `Weight reminders for ${first.workspace_name}`,
|
||||
text: `These birds in ${first.workspace_name} haven't had a new weight entry in at least 48 hours:\n\n${reminders.map((bird) => `- ${bird.bird_name}`).join('\n')}\n\nOpen FlockPal to record their weights: ${frontendBaseUrl}`,
|
||||
html: `<p>These birds in ${escapeHtml(first.workspace_name)} haven't had a new weight entry in at least 48 hours:</p><ul>${reminders.map((bird) => `<li>${escapeHtml(bird.bird_name)}</li>`).join('')}</ul><p><a href="${escapeHtml(frontendBaseUrl)}">Open FlockPal to record their weights</a></p>`,
|
||||
});
|
||||
return result.accepted.length > 0;
|
||||
};
|
||||
|
||||
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
|
||||
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
||||
let sent = 0;
|
||||
|
||||
@@ -480,6 +480,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
UNIQUE (bird_id, recorded_on)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weight_reminder_deliveries (
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
activity_at TIMESTAMPTZ NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
claim_token UUID NOT NULL,
|
||||
claimed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (bird_id, workspace_id, activity_at, recipient)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vet_visits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { redisConnection } from './redisConnection.js';
|
||||
|
||||
export const weightReminderQueueName = 'weight-reminders';
|
||||
export const weightReminderQueue = new Queue(weightReminderQueueName, {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 60_000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
export const startWeightReminderScheduler = async () => {
|
||||
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') {
|
||||
await weightReminderQueue.removeJobScheduler('weight-reminders');
|
||||
return;
|
||||
}
|
||||
await weightReminderQueue.setGlobalConcurrency(1);
|
||||
await weightReminderQueue.upsertJobScheduler(
|
||||
'weight-reminders',
|
||||
{ every: 5 * 60_000 },
|
||||
{ name: 'check-overdue-weights', data: {} },
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { runWeightReminders, type WeightReminder } from './weightReminders.js';
|
||||
|
||||
const bird = (id: string, workspace = 1, recipient = 'owner@example.com'): WeightReminder => ({
|
||||
bird_id: id, workspace_id: workspace, recipient,
|
||||
bird_name: id, workspace_name: `Flock ${workspace}`, activity_at: '2026-09-01T12:00:00Z',
|
||||
});
|
||||
|
||||
test('groups overdue birds into one email per flock and recipient', async () => {
|
||||
const sent: WeightReminder[][] = [];
|
||||
const finished: boolean[] = [];
|
||||
const result = await runWeightReminders(async (birds) => { sent.push(birds); return true; }, {
|
||||
list: async () => [bird('a'), bird('b'), bird('c', 2), bird('a', 1, 'caregiver@example.com')],
|
||||
claim: async () => true,
|
||||
finish: async (_token, delivered) => { finished.push(delivered); },
|
||||
});
|
||||
assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['a', 'b'], ['c'], ['a']]);
|
||||
assert.deepEqual(finished, [true, true, true, true]);
|
||||
assert.equal(result.sent, 3);
|
||||
});
|
||||
|
||||
test('omits birds whose claim was lost or whose weight changed', async () => {
|
||||
const sent: WeightReminder[][] = [];
|
||||
await runWeightReminders(async (birds) => { sent.push(birds); return true; }, {
|
||||
list: async () => [bird('a'), bird('b')],
|
||||
claim: async (reminder) => reminder.bird_id === 'b', finish: async () => {},
|
||||
});
|
||||
assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['b']]);
|
||||
});
|
||||
|
||||
test('does not email when all claims are unavailable', async () => {
|
||||
await runWeightReminders(async () => { assert.fail('Unexpected email'); }, {
|
||||
list: async () => [bird('a')], claim: async () => false, finish: async () => {},
|
||||
});
|
||||
});
|
||||
|
||||
test('missing SMTP releases all claims without recording delivery', async () => {
|
||||
const finished: boolean[] = [];
|
||||
const result = await runWeightReminders(async () => false, {
|
||||
list: async () => [bird('a'), bird('b')], claim: async () => true,
|
||||
finish: async (_token, delivered) => { finished.push(delivered); },
|
||||
});
|
||||
assert.deepEqual(finished, [false, false]);
|
||||
assert.equal(result.sent, 0);
|
||||
});
|
||||
|
||||
test('SMTP failure releases claims and still processes other flocks', async () => {
|
||||
const finished: boolean[] = [];
|
||||
const result = await runWeightReminders(async (birds) => {
|
||||
if (birds[0].workspace_id === 1) throw new Error('SMTP unavailable');
|
||||
return true;
|
||||
}, {
|
||||
list: async () => [bird('a'), bird('b'), bird('c', 2)], claim: async () => true,
|
||||
finish: async (_token, delivered) => { finished.push(delivered); },
|
||||
});
|
||||
assert.deepEqual(finished, [false, false, true]);
|
||||
assert.equal(result.sent, 1);
|
||||
assert.equal(result.failed, 1);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export type WeightReminder = {
|
||||
bird_id: string;
|
||||
workspace_id: number;
|
||||
bird_name: string;
|
||||
workspace_name: string;
|
||||
activity_at: string;
|
||||
recipient: string;
|
||||
};
|
||||
|
||||
// Use entry creation time: recorded_on is a date and cannot measure elapsed hours.
|
||||
// Editing an existing entry does not reset the reminder clock.
|
||||
export const listDueWeightReminders = async () => {
|
||||
const result = await db.query<WeightReminder>(`
|
||||
WITH activity AS (
|
||||
SELECT birds.id AS bird_id, birds.workspace_id, birds.name AS bird_name,
|
||||
workspaces.name AS workspace_name,
|
||||
GREATEST(birds.created_at, COALESCE(MAX(weight_records.created_at), birds.created_at)) AS activity_at
|
||||
FROM birds
|
||||
JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||
LEFT JOIN weight_records ON weight_records.bird_id = birds.id
|
||||
WHERE birds.memorialized_at IS NULL
|
||||
GROUP BY birds.id, workspaces.name
|
||||
), recipients AS (
|
||||
SELECT DISTINCT workspace_id, LOWER(TRIM(users.email)) AS recipient
|
||||
FROM workspace_members
|
||||
JOIN users ON users.id = workspace_members.user_id
|
||||
WHERE accepted_at IS NOT NULL
|
||||
AND role IN ('owner', 'assistant', 'caregiver')
|
||||
)
|
||||
SELECT activity.bird_id, activity.workspace_id, activity.bird_name, activity.workspace_name,
|
||||
activity.activity_at::text, recipients.recipient
|
||||
FROM activity
|
||||
JOIN recipients USING (workspace_id)
|
||||
WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours'
|
||||
AND recipients.recipient <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM weight_reminder_deliveries d
|
||||
WHERE d.bird_id = activity.bird_id AND d.workspace_id = activity.workspace_id
|
||||
AND d.activity_at = activity.activity_at AND d.recipient = recipients.recipient
|
||||
AND (d.delivered_at > CURRENT_TIMESTAMP - INTERVAL '48 hours'
|
||||
OR (d.delivered_at IS NULL AND d.claimed_at > CURRENT_TIMESTAMP - INTERVAL '15 minutes'))
|
||||
)
|
||||
ORDER BY activity.workspace_id, activity.bird_id, recipients.recipient
|
||||
`);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const claimWeightReminder = async (reminder: WeightReminder, token: string) => {
|
||||
const result = await db.query(`
|
||||
INSERT INTO weight_reminder_deliveries (bird_id, workspace_id, activity_at, recipient, claim_token)
|
||||
SELECT $1, $2, $3, $4, $5
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM birds
|
||||
WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL
|
||||
AND GREATEST(created_at, COALESCE((SELECT MAX(created_at) FROM weight_records WHERE bird_id = $1), created_at)) = $3::timestamptz
|
||||
)
|
||||
ON CONFLICT (bird_id, workspace_id, activity_at, recipient) DO UPDATE
|
||||
SET claim_token = EXCLUDED.claim_token, claimed_at = CURRENT_TIMESTAMP, delivered_at = NULL
|
||||
WHERE weight_reminder_deliveries.delivered_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours'
|
||||
OR (weight_reminder_deliveries.delivered_at IS NULL
|
||||
AND weight_reminder_deliveries.claimed_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes')
|
||||
RETURNING bird_id
|
||||
`, [reminder.bird_id, reminder.workspace_id, reminder.activity_at, reminder.recipient, token]);
|
||||
return Boolean(result.rowCount);
|
||||
};
|
||||
|
||||
export const finishWeightReminder = async (token: string, delivered: boolean) => {
|
||||
if (delivered) {
|
||||
await db.query('UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP WHERE claim_token = $1', [token]);
|
||||
} else {
|
||||
await db.query('DELETE FROM weight_reminder_deliveries WHERE claim_token = $1 AND delivered_at IS NULL', [token]);
|
||||
}
|
||||
};
|
||||
|
||||
type Dependencies = {
|
||||
list: typeof listDueWeightReminders;
|
||||
claim: typeof claimWeightReminder;
|
||||
finish: typeof finishWeightReminder;
|
||||
};
|
||||
|
||||
export const runWeightReminders = async (
|
||||
send: (reminders: WeightReminder[]) => Promise<boolean>,
|
||||
dependencies: Dependencies = { list: listDueWeightReminders, claim: claimWeightReminder, finish: finishWeightReminder },
|
||||
) => {
|
||||
const reminders = await dependencies.list();
|
||||
const result = { checked: reminders.length, sent: 0, skipped: 0, failed: 0 };
|
||||
const groups = new Map<string, WeightReminder[]>();
|
||||
for (const reminder of reminders) {
|
||||
const key = JSON.stringify([reminder.workspace_id, reminder.recipient]);
|
||||
groups.set(key, [...(groups.get(key) ?? []), reminder]);
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
const claimed: { reminder: WeightReminder; token: string }[] = [];
|
||||
let delivered = false;
|
||||
try {
|
||||
for (const reminder of group) {
|
||||
const token = randomUUID();
|
||||
if (await dependencies.claim(reminder, token)) claimed.push({ reminder, token });
|
||||
else result.skipped += 1;
|
||||
}
|
||||
if (!claimed.length) continue;
|
||||
delivered = await send(claimed.map(({ reminder }) => reminder));
|
||||
for (const { token } of claimed) await dependencies.finish(token, delivered);
|
||||
if (delivered) result.sent += 1;
|
||||
else result.skipped += claimed.length;
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
// Keep leases if SMTP accepted the email but saving delivery failed.
|
||||
if (!delivered) {
|
||||
for (const { token } of claimed) {
|
||||
await dependencies.finish(token, false).catch((cleanupError) => console.error('Weight reminder claim cleanup failed', cleanupError));
|
||||
}
|
||||
}
|
||||
console.error(`Weight reminder failed for flock ${group[0].workspace_id}`, error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import { runWeightReminders } from './reminders/weightReminders.js';
|
||||
import { weightReminderQueue, weightReminderQueueName, startWeightReminderScheduler } from './queues/weightReminderQueue.js';
|
||||
import { Worker } from 'bullmq';
|
||||
|
||||
import { ensureSchema } from './db/schema.js';
|
||||
import { db } from './db/client.js';
|
||||
import {
|
||||
sendWeightReminderNotification,
|
||||
runBirdMilestoneReminders,
|
||||
runMedicationReminders,
|
||||
startBirdMilestoneReminderScheduler,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
import { redisConnection } from './queues/redisConnection.js';
|
||||
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||
|
||||
let weightReminderWorker: Worker | null = null;
|
||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||
@@ -93,6 +97,16 @@ const startWorker = async () => {
|
||||
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
|
||||
});
|
||||
|
||||
weightReminderWorker = new Worker(weightReminderQueueName, async () => {
|
||||
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return;
|
||||
const result = await runWeightReminders(sendWeightReminderNotification);
|
||||
console.log('Weight reminder job completed', result);
|
||||
return result;
|
||||
}, { connection: redisConnection, concurrency: 1 });
|
||||
weightReminderWorker.on('failed', (job, error) => {
|
||||
console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||
});
|
||||
await startWeightReminderScheduler();
|
||||
startBirdMilestoneReminderScheduler();
|
||||
startMedicationReminderScheduler();
|
||||
console.log('FlockPal worker started.');
|
||||
@@ -100,6 +114,8 @@ const startWorker = async () => {
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||
await weightReminderWorker?.close();
|
||||
await weightReminderQueue.close();
|
||||
await birdMilestoneWorker?.close();
|
||||
await medicationReminderWorker?.close();
|
||||
await adoptionReportWorker?.close();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: flockpal_test
|
||||
POSTGRES_PASSWORD: isolated_test_password
|
||||
POSTGRES_DB: flockpal_weight_test
|
||||
ports:
|
||||
- '127.0.0.1:25432:5432'
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U flockpal_test -d flockpal_weight_test']
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ['redis-server', '--save', '', '--appendonly', 'no']
|
||||
ports:
|
||||
- '127.0.0.1:26379:6379'
|
||||
mail:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- '127.0.0.1:21025:1025'
|
||||
- '127.0.0.1:28025:8025'
|
||||
@@ -0,0 +1,166 @@
|
||||
// Run only against disposable local Postgres, Redis, and Mailpit services.
|
||||
// See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup.
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
Object.assign(process.env, {
|
||||
POSTGRES_HOST: '127.0.0.1', POSTGRES_PORT: '25432', POSTGRES_DB: 'flockpal_weight_test',
|
||||
POSTGRES_USER: 'flockpal_test', POSTGRES_PASSWORD: 'isolated_test_password',
|
||||
REDIS_URL: 'redis://127.0.0.1:26379',
|
||||
SMTP_HOST: '127.0.0.1', SMTP_PORT: '21025', SMTP_SECURE: 'false',
|
||||
SMTP_USER: '', SMTP_PASS: '', SMTP_FROM_EMAIL: 'reminders@flockpal.test', SMTP_FROM_NAME: 'FlockPal Test',
|
||||
FRONTEND_URL: 'http://flockpal.test', MILESTONE_REMINDERS_ENABLED: 'false',
|
||||
MEDICATION_REMINDERS_ENABLED: 'false', WEIGHT_REMINDERS_ENABLED: 'true',
|
||||
});
|
||||
const { db } = await import('../dist/db/client.js');
|
||||
const { ensureSchema } = await import('../dist/db/schema.js');
|
||||
const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js');
|
||||
const { weightReminderQueue, startWeightReminderScheduler } = await import('../dist/queues/weightReminderQueue.js');
|
||||
const workers = [];
|
||||
let logs = '';
|
||||
const poll = async (check, label) => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out: ${label}\n${logs}`);
|
||||
};
|
||||
const messages = async () => (await (await fetch('http://127.0.0.1:28025/api/v1/messages')).json()).messages;
|
||||
const startWorker = () => {
|
||||
const child = spawn(process.execPath, [fileURLToPath(new URL('../dist/worker.js', import.meta.url))], {
|
||||
cwd: '/tmp', env: process.env, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', data => { logs += data; });
|
||||
child.stderr.on('data', data => { logs += data; });
|
||||
workers.push(child);
|
||||
};
|
||||
const stopWorkers = async () => {
|
||||
const exits = workers.map(child => once(child, 'exit'));
|
||||
for (const child of workers) child.kill('SIGTERM');
|
||||
await Promise.all(exits);
|
||||
workers.length = 0;
|
||||
};
|
||||
const drain = async () => {
|
||||
await poll(async () => {
|
||||
const counts = await weightReminderQueue.getJobCounts('active', 'waiting');
|
||||
return !counts.active && !counts.waiting;
|
||||
}, 'queue drain');
|
||||
};
|
||||
const runJob = async () => {
|
||||
const job = await weightReminderQueue.add('integration-check', {});
|
||||
await poll(async () => ['completed', 'failed'].includes(await job.getState()), 'manual job');
|
||||
assert.equal(await job.getState(), 'completed');
|
||||
return (await weightReminderQueue.getJob(job.id)).returnvalue;
|
||||
};
|
||||
try {
|
||||
await ensureSchema();
|
||||
await ensureSchema();
|
||||
assert.equal((await db.query('SELECT COUNT(*)::int AS count FROM birds')).rows[0].count, 0, 'Use a fresh disposable database');
|
||||
assert.equal((await messages()).length, 0, 'Use a fresh email catcher');
|
||||
await db.query("INSERT INTO workspaces (id, name) VALUES (101, 'Test Flock'), (102, 'Other Flock')");
|
||||
const ids = {};
|
||||
for (const [role, accepted] of [['owner', true], ['assistant', true], ['caregiver', true], ['viewer', true], ['pending', false]]) {
|
||||
const id = randomUUID(); ids[role] = id;
|
||||
await db.query('INSERT INTO users (id, email, name) VALUES ($1, $2, $3)', [id, `${role}@example.test`, role]);
|
||||
await db.query(`INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at)
|
||||
VALUES (101, $1, $2, $3, $4, ${accepted ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, `${role}@example.test`, role, role === 'pending' ? 'caregiver' : role]);
|
||||
}
|
||||
await db.query("INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) VALUES (102, $1, 'owner@example.test', 'owner', 'owner', CURRENT_TIMESTAMP)", [ids.owner]);
|
||||
const birds = {};
|
||||
for (const [name, age, memorial, workspace] of [
|
||||
['Kiwi & <Peep>', 72, false, 101], ['Boundary', 48, false, 101],
|
||||
['No weights', null, false, 101], ['Recent', 47, false, 101],
|
||||
['Memorial', 72, true, 101], ['Other bird', 72, false, 102],
|
||||
]) {
|
||||
const id = randomUUID(); birds[name] = id;
|
||||
await db.query(`INSERT INTO birds (id, workspace_id, name, species, created_at, memorialized_at)
|
||||
VALUES ($1, $2, $3, 'Cockatiel', CURRENT_TIMESTAMP - INTERVAL '100 hours', ${memorial ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, workspace, name]);
|
||||
if (age !== null) await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on, created_at) VALUES ($1, 90, CURRENT_DATE - 3, CURRENT_TIMESTAMP - $2 * INTERVAL '1 hour')", [id, age]);
|
||||
}
|
||||
const due = await listDueWeightReminders();
|
||||
assert.equal(due.length, 10);
|
||||
assert.deepEqual(new Set(due.map(b => b.recipient)), new Set(['owner@example.test', 'assistant@example.test', 'caregiver@example.test']));
|
||||
const candidate = due[0];
|
||||
const tokens = [randomUUID(), randomUUID()];
|
||||
const claims = await Promise.all(tokens.map(token => claimWeightReminder(candidate, token)));
|
||||
assert.equal(claims.filter(Boolean).length, 1, 'Only one concurrent claim wins');
|
||||
await finishWeightReminder(tokens[claims.indexOf(true)], false);
|
||||
console.log('PASS: schema initialization is repeatable; eligibility, roles, and concurrent claims');
|
||||
|
||||
startWorker(); startWorker();
|
||||
await poll(async () => (await messages()).length === 4, 'initial grouped emails');
|
||||
await drain();
|
||||
const initial = await messages();
|
||||
const bodies = await Promise.all(initial.map(async message => (await fetch(`http://127.0.0.1:28025/api/v1/message/${message.ID}`)).json()));
|
||||
for (const body of bodies) {
|
||||
if (body.Subject === 'Weight reminders for Test Flock') {
|
||||
for (const name of ['Kiwi & <Peep>', 'Boundary', 'No weights']) assert.ok(body.Text.includes(name));
|
||||
assert.ok(body.HTML.includes('Kiwi & <Peep>'));
|
||||
assert.ok(!body.Text.includes('Memorial'));
|
||||
assert.ok(!body.Text.includes('Recent'));
|
||||
assert.ok(!body.Text.includes('Other bird'));
|
||||
} else {
|
||||
assert.equal(body.Subject, 'Weight reminders for Other Flock');
|
||||
assert.ok(body.Text.includes('Other bird'));
|
||||
assert.ok(!body.Text.includes('Boundary'));
|
||||
}
|
||||
assert.equal(body.To.length, 1);
|
||||
}
|
||||
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 1);
|
||||
assert.equal(await weightReminderQueue.getGlobalConcurrency(), 1);
|
||||
assert.equal((await runJob()).sent, 0);
|
||||
assert.equal((await messages()).length, 4);
|
||||
console.log('PASS: two real workers, one scheduler, grouped SMTP delivery, escaping, flock isolation, no immediate duplicates');
|
||||
|
||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '47 hours'");
|
||||
assert.equal((await runJob()).sent, 0);
|
||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '48 hours'");
|
||||
assert.equal((await runJob()).sent, 4);
|
||||
assert.equal((await messages()).length, 8);
|
||||
await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on) VALUES ($1, 91, CURRENT_DATE)", [birds.Boundary]);
|
||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
|
||||
assert.ok((await listDueWeightReminders()).every(b => b.bird_id !== birds.Boundary));
|
||||
assert.equal((await runJob()).sent, 4);
|
||||
console.log('PASS: no repeat before 48 hours, repeat after 48 hours, fresh weight resets eligibility');
|
||||
|
||||
await stopWorkers();
|
||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
|
||||
const beforeFailures = (await messages()).length;
|
||||
process.env.SMTP_HOST = '';
|
||||
startWorker();
|
||||
assert.equal((await runJob()).sent, 0);
|
||||
assert.equal((await listDueWeightReminders()).length, 7);
|
||||
await stopWorkers();
|
||||
|
||||
process.env.SMTP_HOST = '127.0.0.1';
|
||||
process.env.SMTP_PORT = '21026'; // No SMTP listener: exercise a real refused connection.
|
||||
startWorker();
|
||||
const failed = await runJob();
|
||||
assert.equal(failed.failed, 4);
|
||||
assert.equal(failed.sent, 0);
|
||||
assert.equal((await listDueWeightReminders()).length, 7);
|
||||
assert.equal((await messages()).length, beforeFailures);
|
||||
await stopWorkers();
|
||||
|
||||
process.env.SMTP_PORT = '21025';
|
||||
startWorker();
|
||||
await runJob();
|
||||
await poll(async () => (await messages()).length === beforeFailures + 4, 'delivery after SMTP recovery');
|
||||
await drain();
|
||||
await stopWorkers();
|
||||
console.log('PASS: missing SMTP, real SMTP connection failure, released claims, successful retry');
|
||||
|
||||
process.env.WEIGHT_REMINDERS_ENABLED = 'false';
|
||||
await startWeightReminderScheduler();
|
||||
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
|
||||
console.log('PASS: graceful worker shutdown and disabled scheduler removal');
|
||||
console.log('All Postgres / Redis / SMTP integration checks passed.');
|
||||
} finally {
|
||||
for (const child of workers) child.kill('SIGKILL');
|
||||
await weightReminderQueue.close();
|
||||
await db.close();
|
||||
}
|
||||
Reference in New Issue
Block a user