// 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 & ', 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 & ', '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(); }