sendi/tests/unit/pendingMessageStore.test.ts
jmcqueen 28451daca1 Harden packaging, add error taxonomy, persist pending messages
Packaging
- Drop stale `main: dist/index.js` from package.json (no build workflow)
- Move `prisma` to dependencies and add `postinstall: prisma generate`
  so a fresh `npm ci` on the server produces a runnable client
- Add `prisma:deploy` script for `prisma migrate deploy`
- Rename src/services/Twilioservice.ts -> TwilioService.ts (case fix;
  invisible on macOS APFS, would crash on Linux)

Node version guards
- .nvmrc (22.23.1) so `nvm use` picks the right runtime
- .npmrc `engine-strict=true` so npm respects `engines` on install
- Runtime guard in scripts/navigator-patch.cjs that fails fast with an
  actionable message on Node < 22.18 (native .ts stripping requirement)

Twilio error taxonomy (src/lib/twilioErrors.ts)
- `classifyTwilioError(err)` for send-time exceptions, mapping known
  Twilio REST codes (20003/20429/20500/21211..21614) plus HTTP-status
  and Node errno fallback to `{ kind: retryable|terminal, category }`
- `classifyDeliveryFailure(code)` for the 30xxx delivery-status family
- `sendViaTwilio` persists a `send_error[_retryable]` Message row on
  failure and surfaces the classified message to the user
- `/callback` extracts ErrorCode and passes classified failure info to
  `deliveryStatusCard`, which renders the code + retry-hint inline

Persistent PendingMessageStore
- New PrismaPendingMessageStore (upsert-based) becomes the default
  singleton; InMemoryPendingMessageStore retained for tests
- `startPendingMessageSweeper()` runs hourly, drops entries >24h old,
  unref()s its timer, and is disabled under NODE_ENV=test
- Wired into the shutdown handler in src/index.ts

Tests (39 total, up from 13)
- Integration: tests/integration/twilioRoutes.test.ts hits /sms and
  /callback through the real createApp() with a stub bot, real Twilio
  signatures, plus /healthz
- Unit: twilioErrors.test.ts (14), pendingMessageStore.test.ts (5)
- typecheck clean

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:41:18 -04:00

49 lines
2.1 KiB
TypeScript

import { describe, expect, it, beforeEach } from 'vitest';
import { InMemoryPendingMessageStore } from '../../src/services/PendingMessageStore.ts';
// The Prisma-backed impl is exercised by integration tests. Unit tests here
// pin down the semantics of the interface using the in-memory reference impl.
describe('InMemoryPendingMessageStore', () => {
let store: InMemoryPendingMessageStore;
beforeEach(() => { store = new InMemoryPendingMessageStore(); });
it('round-trips set → get', async () => {
await store.set('room-1', { roomId: 'room-1', text: 'hello' });
const got = await store.get('room-1');
expect(got?.text).toBe('hello');
expect(got?.roomId).toBe('room-1');
expect(got?.createdAt).toBeInstanceOf(Date);
});
it('returns undefined for unknown rooms', async () => {
expect(await store.get('nope')).toBeUndefined();
});
it('overwrites on repeated set (same roomId)', async () => {
await store.set('room-1', { roomId: 'room-1', text: 'first' });
await store.set('room-1', { roomId: 'room-1', text: 'second' });
expect((await store.get('room-1'))?.text).toBe('second');
});
it('delete is idempotent', async () => {
await store.set('room-1', { roomId: 'room-1', text: 'x' });
await store.delete('room-1');
await expect(store.delete('room-1')).resolves.not.toThrow();
expect(await store.get('room-1')).toBeUndefined();
});
it('sweep removes only entries older than maxAge', async () => {
await store.set('young', { roomId: 'young', text: 'y' });
await store.set('old', { roomId: 'old', text: 'o' });
// Backdate the 'old' entry beyond the sweep cutoff.
const oldEntry = await store.get('old');
if (oldEntry) oldEntry.createdAt = new Date(Date.now() - 1000 * 60 * 60 * 24);
const removed = await store.sweep(1000 * 60 * 60); // 1 hour
expect(removed).toBe(1);
expect(await store.get('young')).toBeDefined();
expect(await store.get('old')).toBeUndefined();
});
});