Files
RedditBot/tests/outbox.test.ts
T

74 lines
3.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../src/database/client.js';
import { DeliveryOutboxRepository, MessagesRepository, PostsRepository } from '../src/database/repositories.js';
describe('DeliveryOutboxRepository', () => {
let db: Database.Database;
let outbox: DeliveryOutboxRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
new PostsRepository(db).insert({
id: 'post-1', subreddit: 'test', author: 'author', title: 'title', imageUrl: null, processedAt: 1,
});
outbox = new DeliveryOutboxRepository(db);
});
afterEach(() => db.close());
it('deduplicates discovery by idempotency key without creating another delivery', () => {
const first = outbox.discover({
postId: 'post-1', recipient: 'recipient', subject: 'subject', message: 'message',
idempotencyKey: 'post-1:v1', now: 100,
});
const second = outbox.discover({
postId: 'post-1', recipient: 'recipient', subject: 'changed', message: 'changed',
idempotencyKey: 'post-1:v1', now: 200,
});
expect(second).toEqual(first);
expect(outbox.count()).toBe(1);
expect(outbox.get(first.id)).toMatchObject({ status: 'discovered', subject: 'subject' });
});
it('recovers an expired lease and allows exactly one worker to claim the prepared delivery', () => {
const delivery = outbox.discover({
postId: 'post-1', recipient: 'recipient', subject: 'subject', message: 'message', idempotencyKey: 'post-1:v1', now: 100,
});
outbox.markPrepared(delivery.id, 110);
expect(outbox.claimNext('worker-a', 50, 120)).toMatchObject({ id: delivery.id, status: 'sending', leaseOwner: 'worker-a' });
expect(outbox.claimNext('worker-b', 50, 121)).toBeNull();
expect(outbox.recoverExpiredLeases(171)).toBe(1);
expect(outbox.claimNext('worker-b', 50, 172)).toMatchObject({ id: delivery.id, leaseOwner: 'worker-b', attemptCount: 2 });
});
it('claims a specific prepared delivery without taking another queued item', () => {
new PostsRepository(db).insert({ id: 'post-2', subreddit: 'test', author: 'author-2', title: 'title', imageUrl: null, processedAt: 2 });
const first = outbox.discover({ postId: 'post-1', recipient: 'a', subject: 's', message: 'm', idempotencyKey: 'first', now: 1 });
const second = outbox.discover({ postId: 'post-2', recipient: 'b', subject: 's', message: 'm', idempotencyKey: 'second', now: 2 });
outbox.markPrepared(first.id, 3);
outbox.markPrepared(second.id, 3);
expect(outbox.claim(first.id, 'worker', 100, 4)).toMatchObject({ id: first.id, status: 'sending' });
expect(outbox.get(second.id)).toMatchObject({ status: 'prepared' });
});
it('only completes a claimed delivery for its current lease owner', () => {
const delivery = outbox.discover({
postId: 'post-1', recipient: 'recipient', subject: 'subject', message: 'message', idempotencyKey: 'post-1:v1', now: 100,
});
outbox.markPrepared(delivery.id, 110);
outbox.claimNext('worker-a', 50, 120);
const sentMessageId = new MessagesRepository(db).insert({
postId: 'post-1', recipient: 'recipient', subject: 'subject', message: 'message', sentAt: 125,
});
expect(outbox.markSent(delivery.id, 'worker-b', sentMessageId, 130)).toBe(false);
expect(outbox.markSent(delivery.id, 'worker-a', sentMessageId, 130)).toBe(true);
expect(outbox.get(delivery.id)).toMatchObject({ status: 'sent', sentMessageId, leaseOwner: null });
});
});