87 lines
4.1 KiB
TypeScript
87 lines
4.1 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 });
|
|
});
|
|
|
|
it('surfaces failed deliveries for the attention inbox', () => {
|
|
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.claim(delivery.id, 'worker-a', 50, 120);
|
|
outbox.markFailed(delivery.id, 'worker-a', 'Reddit rejected the invitation', 130);
|
|
|
|
expect(outbox.getFailed()).toEqual([
|
|
expect.objectContaining({ id: delivery.id, status: 'failed', lastError: 'Reddit rejected the invitation' }),
|
|
]);
|
|
});
|
|
});
|