175 lines
9.6 KiB
JavaScript
175 lines
9.6 KiB
JavaScript
// 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',
|
|
MILESTONE_REMINDER_TIME_ZONE: 'America/New_York',
|
|
});
|
|
const { db } = await import('../dist/db/client.js');
|
|
const { ensureSchema } = await import('../dist/db/schema.js');
|
|
const { getReminderDate } = await import('../dist/reminders/reminderDate.js');
|
|
const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js');
|
|
const { weightReminderQueue, startWeightReminderScheduler, enqueueDailyWeightReminder } = 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');
|
|
|
|
// Simulate the schedule present before upgrading to daily reminders.
|
|
await weightReminderQueue.upsertJobScheduler('weight-reminders', { every: 300_000 }, { name: 'check-overdue-weights', data: {} });
|
|
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, 0);
|
|
await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler');
|
|
const dailyJobs = await Promise.all([enqueueDailyWeightReminder(), enqueueDailyWeightReminder()]);
|
|
assert.equal(dailyJobs[0].id, dailyJobs[1].id);
|
|
await drain();
|
|
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 daily job, legacy schedule removed, grouped SMTP delivery, escaping, flock isolation, no immediate duplicates');
|
|
|
|
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP");
|
|
assert.equal((await runJob()).sent, 0);
|
|
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = ((CURRENT_TIMESTAMP AT TIME ZONE 'America/New_York')::date::timestamp AT TIME ZONE 'America/New_York') - INTERVAL '1 second'");
|
|
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 on the same local date, daily repeat, 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();
|
|
}
|