mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
97cd03325f
Scanned books are commonly distributed as DjVu, which the scanner only stored as a supplement. Decode pages on the server with DjVuLibre so DjVu imports with a cover and page count, opens in the image reader with its embedded OCR text, and can be the preferred format of an ordinary email recipient. Migration 0113 widens the recipient format check to allow it. Decoding runs on validated private snapshots behind a bounded, reader-first queue, and revalidation and cached pages are answered without a decoder. Released iOS builds cannot open DjVu, so series continuation offers it only to callers sending includeDjvu=true, and the dashboard and notebook read file never name it. The first full scan after upgrading promotes stored .djvu supplements to content.
1104 lines
51 KiB
TypeScript
1104 lines
51 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { mkdir, writeFile } from 'fs/promises';
|
|
import { dirname, join } from 'path';
|
|
|
|
import { desc, eq } from 'drizzle-orm';
|
|
import { Permission } from '@bookorbit/types';
|
|
|
|
import * as schema from '../src/db/schema';
|
|
import { createEpubFixture } from './e2e/email/email-fixture-builder';
|
|
import {
|
|
authHeader,
|
|
closeEmailE2EContext,
|
|
createEmailE2EContext,
|
|
createLibraryWithFolder,
|
|
createUserAndLogin,
|
|
extractSmtpHeader,
|
|
grantLibraryAccess,
|
|
locateBookByAbsolutePath,
|
|
resetEmailModuleState,
|
|
seedBookMetadataContext,
|
|
triggerAndWaitForLibraryScan,
|
|
waitForCondition,
|
|
type EmailE2EContext,
|
|
type LocatedBookFile,
|
|
type TestUserSession,
|
|
} from './e2e/email/email-harness';
|
|
|
|
interface ScenarioRunResult {
|
|
id: string;
|
|
status: 'passed' | 'failed';
|
|
durationMs: number;
|
|
error?: string;
|
|
}
|
|
|
|
function buildProviderPayload(
|
|
ctx: EmailE2EContext,
|
|
namePrefix: string,
|
|
overrides: Partial<{
|
|
host: string;
|
|
port: number;
|
|
auth: boolean;
|
|
ssl: boolean;
|
|
startTls: boolean;
|
|
tlsRejectUnauthorized: boolean;
|
|
username: string;
|
|
password: string;
|
|
fromName: string;
|
|
fromAddress: string;
|
|
}> = {},
|
|
) {
|
|
return {
|
|
name: `${namePrefix}-${randomUUID().slice(0, 8)}`,
|
|
host: overrides.host ?? ctx.smtpSink.getHost(),
|
|
port: overrides.port ?? ctx.smtpSink.getPort(),
|
|
auth: overrides.auth ?? false,
|
|
ssl: overrides.ssl ?? false,
|
|
startTls: overrides.startTls ?? false,
|
|
tlsRejectUnauthorized: overrides.tlsRejectUnauthorized ?? true,
|
|
...(overrides.username !== undefined ? { username: overrides.username } : {}),
|
|
...(overrides.password !== undefined ? { password: overrides.password } : {}),
|
|
...(overrides.fromName !== undefined ? { fromName: overrides.fromName } : {}),
|
|
...(overrides.fromAddress !== undefined ? { fromAddress: overrides.fromAddress } : {}),
|
|
};
|
|
}
|
|
|
|
async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void> {
|
|
const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server');
|
|
await mkdir(reportDir, { recursive: true });
|
|
const reportPath = join(reportDir, 'email-lifecycle-e2e-scenarios.json');
|
|
await writeFile(
|
|
reportPath,
|
|
JSON.stringify(
|
|
{
|
|
generatedAt: new Date().toISOString(),
|
|
total: results.length,
|
|
passed: results.filter((result) => result.status === 'passed').length,
|
|
failed: results.filter((result) => result.status === 'failed').length,
|
|
results,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
|
|
describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => {
|
|
let ctx!: EmailE2EContext;
|
|
const scenarioResults: ScenarioRunResult[] = [];
|
|
let scenarioStartedAt = 0;
|
|
|
|
let superManager!: TestUserSession;
|
|
let managerOnly!: TestUserSession;
|
|
let senderOnly!: TestUserSession;
|
|
let senderNoLibrary!: TestUserSession;
|
|
let emaillessUser!: TestUserSession;
|
|
let foreignSender!: TestUserSession;
|
|
|
|
let book!: LocatedBookFile;
|
|
|
|
async function apiAs(user: TestUserSession, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, payload?: unknown) {
|
|
return ctx.app.inject({
|
|
method,
|
|
url,
|
|
headers: authHeader(user.accessToken),
|
|
...(payload === undefined ? {} : { payload }),
|
|
});
|
|
}
|
|
|
|
function expectError(response: Awaited<ReturnType<EmailE2EContext['app']['inject']>>, statusCode: number, messageIncludes?: string): void {
|
|
expect(response.statusCode).toBe(statusCode);
|
|
if (!messageIncludes) return;
|
|
const body = response.json() as { message?: string };
|
|
expect(body.message ?? '').toContain(messageIncludes);
|
|
}
|
|
|
|
async function latestLogForUser(userId: number): Promise<typeof schema.emailSendLog.$inferSelect | null> {
|
|
const [entry] = await ctx.db
|
|
.select()
|
|
.from(schema.emailSendLog)
|
|
.where(eq(schema.emailSendLog.userId, userId))
|
|
.orderBy(desc(schema.emailSendLog.id))
|
|
.limit(1);
|
|
return entry ?? null;
|
|
}
|
|
|
|
async function waitForLog(
|
|
id: number,
|
|
predicate: (entry: typeof schema.emailSendLog.$inferSelect) => boolean,
|
|
timeoutMs = 12_000,
|
|
): Promise<typeof schema.emailSendLog.$inferSelect> {
|
|
let entry: typeof schema.emailSendLog.$inferSelect | null = null;
|
|
|
|
await waitForCondition(async () => {
|
|
const [row] = await ctx.db.select().from(schema.emailSendLog).where(eq(schema.emailSendLog.id, id)).limit(1);
|
|
if (!row) throw new Error(`Log ${id} not found`);
|
|
if (!predicate(row)) throw new Error(`Log ${id} has unexpected state: status=${row.status} attemptCount=${row.attemptCount}`);
|
|
entry = row;
|
|
}, timeoutMs);
|
|
|
|
if (!entry) {
|
|
throw new Error(`Log ${id} not found after wait`);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createEmailE2EContext();
|
|
superManager = ctx.admin;
|
|
|
|
const library = await createLibraryWithFolder(ctx, { name: `email-lifecycle-library-${randomUUID()}` });
|
|
const bookAbsolutePath = await createEpubFixture(library.folderPath, 'email-lifecycle.epub', {
|
|
title: 'Email Lifecycle Book',
|
|
});
|
|
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
book = await locateBookByAbsolutePath(ctx, bookAbsolutePath);
|
|
await seedBookMetadataContext(ctx, {
|
|
bookId: book.bookId,
|
|
title: 'Email Lifecycle Book',
|
|
seriesName: 'Email Series',
|
|
seriesIndex: 3,
|
|
authorNames: ['Email Author One', 'Email Author Two'],
|
|
tagNames: ['tag-alpha'],
|
|
});
|
|
|
|
managerOnly = await createUserAndLogin(ctx, { permissions: [Permission.ManageEmail] });
|
|
senderOnly = await createUserAndLogin(ctx, { permissions: [Permission.EmailSend] });
|
|
senderNoLibrary = await createUserAndLogin(ctx, { permissions: [Permission.EmailSend] });
|
|
emaillessUser = await createUserAndLogin(ctx);
|
|
foreignSender = await createUserAndLogin(ctx, { permissions: [Permission.EmailSend] });
|
|
|
|
await grantLibraryAccess(ctx, senderOnly.userId, library.libraryId, 'viewer');
|
|
await grantLibraryAccess(ctx, foreignSender.userId, library.libraryId, 'viewer');
|
|
}, 180_000);
|
|
|
|
beforeEach(async () => {
|
|
scenarioStartedAt = Date.now();
|
|
await resetEmailModuleState(ctx);
|
|
});
|
|
|
|
afterEach((taskContext) => {
|
|
const result = taskContext.task.result;
|
|
if (!result) return;
|
|
|
|
const status = result.state === 'pass' ? 'passed' : 'failed';
|
|
const error = result.errors?.[0]?.message;
|
|
scenarioResults.push({
|
|
id: taskContext.task.name,
|
|
status,
|
|
durationMs: Math.max(0, Date.now() - scenarioStartedAt),
|
|
...(error ? { error } : {}),
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await writeScenarioReport(scenarioResults);
|
|
if (ctx) {
|
|
await closeEmailE2EContext(ctx);
|
|
}
|
|
});
|
|
|
|
describe('providers lifecycle and policy gates', () => {
|
|
it('enforces permission boundaries, share/system rules, and delete invariants', async () => {
|
|
const createResponse = await apiAs(managerOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'manager-provider'));
|
|
expect(createResponse.statusCode).toBe(201);
|
|
const createdProvider = createResponse.json() as { id: number; name: string; hasPassword: boolean; isShared: boolean };
|
|
expect(createdProvider).toEqual(expect.objectContaining({ hasPassword: false, isShared: false }));
|
|
|
|
const managerListResponse = await apiAs(managerOnly, 'GET', '/api/v1/email/providers');
|
|
expectError(managerListResponse, 403, Permission.EmailSend);
|
|
|
|
const senderCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'sender-provider'));
|
|
expectError(senderCreateResponse, 403, Permission.ManageEmail);
|
|
|
|
const senderListBeforeShare = await apiAs(senderOnly, 'GET', '/api/v1/email/providers');
|
|
expect(senderListBeforeShare.statusCode).toBe(200);
|
|
expect(senderListBeforeShare.json()).toEqual([]);
|
|
|
|
const senderToggleShare = await apiAs(senderOnly, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
|
|
expectError(senderToggleShare, 403, Permission.ManageEmail);
|
|
|
|
const managerToggleShare = await apiAs(managerOnly, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
|
|
expect(managerToggleShare.statusCode).toBe(200);
|
|
expect((managerToggleShare.json() as { isShared: boolean }).isShared).toBe(true);
|
|
|
|
const superToggleShare = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
|
|
expectError(superToggleShare, 403, 'Cannot modify this provider');
|
|
|
|
const superOwnedProviderResponse = await apiAs(
|
|
superManager,
|
|
'POST',
|
|
'/api/v1/email/providers',
|
|
buildProviderPayload(ctx, 'super-owned-provider'),
|
|
);
|
|
expect(superOwnedProviderResponse.statusCode).toBe(201);
|
|
const superOwnedProvider = superOwnedProviderResponse.json() as { id: number };
|
|
|
|
const superOwnedShare = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${superOwnedProvider.id}/share`);
|
|
expect(superOwnedShare.statusCode).toBe(200);
|
|
expect((superOwnedShare.json() as { isShared: boolean }).isShared).toBe(true);
|
|
|
|
const senderListAfterShare = await apiAs(senderOnly, 'GET', '/api/v1/email/providers');
|
|
expect(senderListAfterShare.statusCode).toBe(200);
|
|
expect(senderListAfterShare.json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: superOwnedProvider.id, isShared: true })]));
|
|
|
|
const managerSetSystem = await apiAs(managerOnly, 'PATCH', `/api/v1/email/providers/${superOwnedProvider.id}/system`);
|
|
expectError(managerSetSystem, 403, 'Only superusers can configure the system mail provider');
|
|
|
|
const superSetSystem = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${superOwnedProvider.id}/system`);
|
|
expect(superSetSystem.statusCode).toBe(200);
|
|
expect((superSetSystem.json() as { isSystemProvider: boolean }).isSystemProvider).toBe(true);
|
|
|
|
const deleteSystemProvider = await apiAs(superManager, 'DELETE', `/api/v1/email/providers/${superOwnedProvider.id}`);
|
|
expectError(deleteSystemProvider, 400, 'Cannot delete the system mail provider');
|
|
|
|
const clearSystemResponse = await apiAs(superManager, 'DELETE', '/api/v1/email/providers/system');
|
|
expect(clearSystemResponse.statusCode).toBe(204);
|
|
|
|
const deleteSuperProvider = await apiAs(superManager, 'DELETE', `/api/v1/email/providers/${superOwnedProvider.id}`);
|
|
expect(deleteSuperProvider.statusCode).toBe(204);
|
|
|
|
const deleteResponse = await apiAs(managerOnly, 'DELETE', `/api/v1/email/providers/${createdProvider.id}`);
|
|
expect(deleteResponse.statusCode).toBe(204);
|
|
});
|
|
|
|
it('returns connection test contract for both success and failure', async () => {
|
|
const validProviderResponse = await apiAs(managerOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'smtp-valid-connection'));
|
|
expect(validProviderResponse.statusCode).toBe(201);
|
|
const validProvider = validProviderResponse.json() as { id: number };
|
|
|
|
const validTestResponse = await apiAs(managerOnly, 'POST', `/api/v1/email/providers/${validProvider.id}/test`);
|
|
expect(validTestResponse.statusCode).toBe(200);
|
|
expect(validTestResponse.json()).toEqual({ success: true });
|
|
|
|
const invalidProviderResponse = await apiAs(
|
|
managerOnly,
|
|
'POST',
|
|
'/api/v1/email/providers',
|
|
buildProviderPayload(ctx, 'smtp-invalid-connection', {
|
|
host: '127.0.0.1',
|
|
port: 1,
|
|
}),
|
|
);
|
|
expect(invalidProviderResponse.statusCode).toBe(201);
|
|
const invalidProvider = invalidProviderResponse.json() as { id: number };
|
|
|
|
const invalidTestResponse = await apiAs(managerOnly, 'POST', `/api/v1/email/providers/${invalidProvider.id}/test`);
|
|
expect(invalidTestResponse.statusCode).toBe(200);
|
|
expect(invalidTestResponse.json()).toEqual(expect.objectContaining({ success: false, error: expect.any(String) }));
|
|
});
|
|
});
|
|
|
|
describe('templates lifecycle, defaults, and preview contract', () => {
|
|
it('enforces role-gating and system-template update restrictions', async () => {
|
|
const senderListResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/templates');
|
|
expect(senderListResponse.statusCode).toBe(200);
|
|
const senderTemplates = senderListResponse.json() as Array<{ id: number; isSystem: boolean }>;
|
|
const systemTemplate = senderTemplates.find((template) => template.isSystem);
|
|
expect(systemTemplate).toBeDefined();
|
|
|
|
const managerListResponse = await apiAs(managerOnly, 'GET', '/api/v1/email/templates');
|
|
expectError(managerListResponse, 403, Permission.EmailSend);
|
|
|
|
const senderSystemUpdate = await apiAs(senderOnly, 'PUT', `/api/v1/email/templates/${systemTemplate!.id}`, {
|
|
subject: 'sender-cannot-update-system-template',
|
|
});
|
|
expectError(senderSystemUpdate, 403, 'Only administrators can modify system templates');
|
|
|
|
const superSystemUpdate = await apiAs(superManager, 'PUT', `/api/v1/email/templates/${systemTemplate!.id}`, {
|
|
subject: `System subject ${randomUUID().slice(0, 8)}`,
|
|
});
|
|
expect(superSystemUpdate.statusCode).toBe(200);
|
|
expect((superSystemUpdate.json() as { id: number }).id).toBe(systemTemplate!.id);
|
|
});
|
|
|
|
it('renders template preview with backward-compatible aliases and /api/v1 coverUrl', async () => {
|
|
const createTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `preview-template-${randomUUID().slice(0, 8)}`,
|
|
subject: '{{title}}|{{authors}}|{{author}}|{{seriesName}}|{{series}}',
|
|
bodyText: 'cover={{coverUrl}}',
|
|
});
|
|
expect(createTemplateResponse.statusCode).toBe(201);
|
|
const template = createTemplateResponse.json() as { id: number };
|
|
|
|
const previewResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/templates/${template.id}/preview`, {
|
|
bookId: book.bookId,
|
|
});
|
|
expect(previewResponse.statusCode).toBe(200);
|
|
expect(previewResponse.json()).toEqual({
|
|
subject: 'Email Lifecycle Book|Email Author One, Email Author Two|Email Author One, Email Author Two|Email Series|Email Series',
|
|
bodyText: `cover=http://localhost:4173/api/v1/books/${book.bookId}/cover`,
|
|
});
|
|
|
|
const inaccessibleTemplateResponse = await apiAs(senderNoLibrary, 'POST', '/api/v1/email/templates', {
|
|
name: `inaccessible-preview-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'x',
|
|
bodyText: 'x',
|
|
});
|
|
expect(inaccessibleTemplateResponse.statusCode).toBe(201);
|
|
const inaccessibleTemplate = inaccessibleTemplateResponse.json() as { id: number };
|
|
|
|
const inaccessiblePreviewResponse = await apiAs(senderNoLibrary, 'POST', `/api/v1/email/templates/${inaccessibleTemplate.id}/preview`, {
|
|
bookId: book.bookId,
|
|
});
|
|
expectError(inaccessiblePreviewResponse, 403, 'No access to this library');
|
|
});
|
|
|
|
it('maps duplicate template names to 409 conflicts', async () => {
|
|
const name = `duplicate-template-${randomUUID().slice(0, 8)}`;
|
|
const firstCreate = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name,
|
|
subject: 'First subject',
|
|
bodyText: 'Body',
|
|
});
|
|
expect(firstCreate.statusCode).toBe(201);
|
|
|
|
const duplicateCreate = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name,
|
|
subject: 'Second subject',
|
|
bodyText: 'Body',
|
|
});
|
|
expectError(duplicateCreate, 409, 'already exists');
|
|
});
|
|
});
|
|
|
|
describe('recipients/groups/preferences ownership and conflicts', () => {
|
|
it('persists DjVu format preferences for ordinary recipients', async () => {
|
|
const created = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'DjVu documents',
|
|
email: 'djvu-reader@example.com',
|
|
deviceType: 'other',
|
|
preferredFormat: 'djvu',
|
|
});
|
|
expect(created.statusCode).toBe(201);
|
|
const recipient = created.json() as { id: number; preferredFormat: string };
|
|
expect(recipient.preferredFormat).toBe('djvu');
|
|
const loaded = await apiAs(senderOnly, 'GET', `/api/v1/email/recipients/${recipient.id}`);
|
|
expect(loaded.statusCode).toBe(200);
|
|
expect(loaded.json()).toMatchObject({ preferredFormat: 'djvu', deviceType: 'other' });
|
|
});
|
|
|
|
it('enforces recipient ownership and duplicate-email conflicts', async () => {
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Sender Kindle',
|
|
email: 'kindle+sender@example.com',
|
|
deviceType: 'kindle',
|
|
preferredFormat: 'epub',
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number; isDefault: boolean };
|
|
expect(recipient.isDefault).toBe(false);
|
|
|
|
const setDefaultResponse = await apiAs(senderOnly, 'PATCH', `/api/v1/email/recipients/${recipient.id}/default`);
|
|
expect(setDefaultResponse.statusCode).toBe(200);
|
|
expect((setDefaultResponse.json() as { isDefault: boolean }).isDefault).toBe(true);
|
|
|
|
const foreignReadResponse = await apiAs(foreignSender, 'GET', `/api/v1/email/recipients/${recipient.id}`);
|
|
expectError(foreignReadResponse, 403, 'Cannot modify this recipient');
|
|
|
|
const duplicateRecipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Duplicate Sender Kindle',
|
|
email: 'kindle+sender@example.com',
|
|
});
|
|
expectError(duplicateRecipientResponse, 409, 'already exists');
|
|
|
|
const emaillessCreateResponse = await apiAs(emaillessUser, 'POST', '/api/v1/email/recipients', {
|
|
name: 'No Permission',
|
|
email: 'no-permission@example.com',
|
|
});
|
|
expectError(emaillessCreateResponse, 403, Permission.EmailSend);
|
|
});
|
|
|
|
it('enforces group ownership checks and duplicate-name conflicts', async () => {
|
|
const senderRecipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Sender Group Member',
|
|
email: 'group-member@example.com',
|
|
});
|
|
expect(senderRecipientResponse.statusCode).toBe(201);
|
|
const senderRecipient = senderRecipientResponse.json() as { id: number };
|
|
|
|
const foreignRecipientResponse = await apiAs(foreignSender, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Foreign Group Member',
|
|
email: 'foreign-group-member@example.com',
|
|
});
|
|
expect(foreignRecipientResponse.statusCode).toBe(201);
|
|
const foreignRecipient = foreignRecipientResponse.json() as { id: number };
|
|
|
|
const groupResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', {
|
|
name: `kindle-group-${randomUUID().slice(0, 8)}`,
|
|
});
|
|
expect(groupResponse.statusCode).toBe(201);
|
|
const group = groupResponse.json() as { id: number; name: string };
|
|
|
|
const duplicateGroupResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', {
|
|
name: group.name,
|
|
});
|
|
expectError(duplicateGroupResponse, 409, 'already exists');
|
|
|
|
const addMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, {
|
|
recipientId: senderRecipient.id,
|
|
});
|
|
expect(addMemberResponse.statusCode).toBe(201);
|
|
expect((addMemberResponse.json() as { members: Array<{ id: number }> }).members).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: senderRecipient.id })]),
|
|
);
|
|
|
|
const addForeignMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, {
|
|
recipientId: foreignRecipient.id,
|
|
});
|
|
expectError(addForeignMemberResponse, 403, 'Cannot add a recipient you do not own');
|
|
|
|
const removeMemberResponse = await apiAs(senderOnly, 'DELETE', `/api/v1/email/recipient-groups/${group.id}/members/${senderRecipient.id}`);
|
|
expect(removeMemberResponse.statusCode).toBe(204);
|
|
});
|
|
|
|
it('validates preferences cross-references against accessible resources', async () => {
|
|
const privateProviderResponse = await apiAs(managerOnly, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'private-provider'));
|
|
expect(privateProviderResponse.statusCode).toBe(201);
|
|
const privateProvider = privateProviderResponse.json() as { id: number };
|
|
|
|
const sharedProviderResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'shared-provider'));
|
|
expect(sharedProviderResponse.statusCode).toBe(201);
|
|
const sharedProvider = sharedProviderResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${sharedProvider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Pref Recipient',
|
|
email: 'prefs-recipient@example.com',
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
const templateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `prefs-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Prefs {{title}}',
|
|
bodyText: 'Body',
|
|
});
|
|
expect(templateResponse.statusCode).toBe(201);
|
|
const template = templateResponse.json() as { id: number };
|
|
|
|
const foreignRecipientResponse = await apiAs(foreignSender, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Foreign Pref Recipient',
|
|
email: 'foreign-pref-recipient@example.com',
|
|
});
|
|
expect(foreignRecipientResponse.statusCode).toBe(201);
|
|
const foreignRecipient = foreignRecipientResponse.json() as { id: number };
|
|
|
|
const foreignTemplateResponse = await apiAs(foreignSender, 'POST', '/api/v1/email/templates', {
|
|
name: `foreign-pref-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Foreign {{title}}',
|
|
bodyText: 'Body',
|
|
});
|
|
expect(foreignTemplateResponse.statusCode).toBe(201);
|
|
const foreignTemplate = foreignTemplateResponse.json() as { id: number };
|
|
|
|
const validPreferencesResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultProviderId: sharedProvider.id,
|
|
defaultRecipientId: recipient.id,
|
|
defaultTemplateId: template.id,
|
|
});
|
|
expect(validPreferencesResponse.statusCode).toBe(200);
|
|
expect(validPreferencesResponse.json()).toEqual({
|
|
id: expect.any(Number),
|
|
userId: senderOnly.userId,
|
|
defaultProviderId: sharedProvider.id,
|
|
defaultRecipientId: recipient.id,
|
|
defaultTemplateId: template.id,
|
|
createdAt: expect.any(String),
|
|
updatedAt: expect.any(String),
|
|
});
|
|
|
|
const privateProviderPreferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultProviderId: privateProvider.id,
|
|
});
|
|
expectError(privateProviderPreferenceResponse, 403, 'No access to this provider');
|
|
|
|
const foreignRecipientPreferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultRecipientId: foreignRecipient.id,
|
|
});
|
|
expectError(foreignRecipientPreferenceResponse, 403, 'Cannot modify this recipient');
|
|
|
|
const foreignTemplatePreferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultTemplateId: foreignTemplate.id,
|
|
});
|
|
expectError(foreignTemplatePreferenceResponse, 403, 'No access to this template');
|
|
|
|
const recipientWithForeignTemplate = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Recipient With Foreign Template',
|
|
email: 'recipient-with-foreign-template@example.com',
|
|
defaultTemplateId: foreignTemplate.id,
|
|
});
|
|
expectError(recipientWithForeignTemplate, 403, 'No access to this template');
|
|
|
|
const groupWithForeignTemplate = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', {
|
|
name: `foreign-template-group-${randomUUID().slice(0, 8)}`,
|
|
defaultTemplateId: foreignTemplate.id,
|
|
});
|
|
expectError(groupWithForeignTemplate, 403, 'No access to this template');
|
|
});
|
|
});
|
|
|
|
describe('send + quick-send + log + resend flow integrity', () => {
|
|
it('uses explicit templateId over recipient default and applies provider from fields', async () => {
|
|
const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', {
|
|
...buildProviderPayload(ctx, 'sender-provider'),
|
|
fromName: 'BookOrbit Bot',
|
|
fromAddress: 'bot@example.com',
|
|
});
|
|
expect(providerCreateResponse.statusCode).toBe(201);
|
|
const provider = providerCreateResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const defaultTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `recipient-default-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Recipient {{title}}',
|
|
bodyText: 'Recipient body',
|
|
});
|
|
expect(defaultTemplateResponse.statusCode).toBe(201);
|
|
const defaultTemplate = defaultTemplateResponse.json() as { id: number };
|
|
|
|
const overrideTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `override-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Override {{title}}',
|
|
bodyText: 'Override body',
|
|
});
|
|
expect(overrideTemplateResponse.statusCode).toBe(201);
|
|
const overrideTemplate = overrideTemplateResponse.json() as { id: number };
|
|
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Send Recipient',
|
|
email: 'send-recipient@example.com',
|
|
defaultTemplateId: defaultTemplate.id,
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
recipientIds: [recipient.id],
|
|
providerId: provider.id,
|
|
templateId: overrideTemplate.id,
|
|
});
|
|
expect(sendResponse.statusCode).toBe(202);
|
|
expect(sendResponse.json()).toEqual({ queued: 1 });
|
|
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
const firstLog = await latestLogForUser(senderOnly.userId);
|
|
if (!firstLog) {
|
|
throw new Error('Missing send log entry for sender');
|
|
}
|
|
await waitForLog(firstLog.id, (entry) => entry.status === 'sent');
|
|
|
|
const [captured] = ctx.smtpSink.getMessages();
|
|
const fromHeader = extractSmtpHeader(captured.raw, 'from');
|
|
const subjectHeader = extractSmtpHeader(captured.raw, 'subject');
|
|
expect(fromHeader).toContain('BookOrbit Bot <bot@example.com>');
|
|
expect(subjectHeader).toBe('Override Email Lifecycle Book');
|
|
expect(subjectHeader).not.toContain('Recipient');
|
|
|
|
const missingTemplateSendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
recipientIds: [recipient.id],
|
|
providerId: provider.id,
|
|
templateId: 999_999_999,
|
|
});
|
|
expectError(missingTemplateSendResponse, 404, 'Template not found');
|
|
});
|
|
|
|
it('keeps group defaultTemplateId non-operational in send resolution', async () => {
|
|
const providerCreateResponse = await apiAs(
|
|
superManager,
|
|
'POST',
|
|
'/api/v1/email/providers',
|
|
buildProviderPayload(ctx, 'group-template-provider'),
|
|
);
|
|
expect(providerCreateResponse.statusCode).toBe(201);
|
|
const provider = providerCreateResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const prefsTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `prefs-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Prefs {{title}}',
|
|
bodyText: 'Prefs body',
|
|
});
|
|
expect(prefsTemplateResponse.statusCode).toBe(201);
|
|
const prefsTemplate = prefsTemplateResponse.json() as { id: number };
|
|
|
|
const groupTemplateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `group-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Group {{title}}',
|
|
bodyText: 'Group body',
|
|
});
|
|
expect(groupTemplateResponse.statusCode).toBe(201);
|
|
const groupTemplate = groupTemplateResponse.json() as { id: number };
|
|
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Group Recipient',
|
|
email: 'group-recipient@example.com',
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
const groupResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', {
|
|
name: `group-${randomUUID().slice(0, 8)}`,
|
|
defaultTemplateId: groupTemplate.id,
|
|
});
|
|
expect(groupResponse.statusCode).toBe(201);
|
|
const group = groupResponse.json() as { id: number };
|
|
|
|
const addMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, {
|
|
recipientId: recipient.id,
|
|
});
|
|
expect(addMemberResponse.statusCode).toBe(201);
|
|
|
|
const preferencesResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultProviderId: provider.id,
|
|
defaultTemplateId: prefsTemplate.id,
|
|
});
|
|
expect(preferencesResponse.statusCode).toBe(200);
|
|
|
|
const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
groupIds: [group.id],
|
|
providerId: provider.id,
|
|
});
|
|
expect(sendResponse.statusCode).toBe(202);
|
|
expect(sendResponse.json()).toEqual({ queued: 1 });
|
|
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
const [captured] = ctx.smtpSink.getMessages();
|
|
const subjectHeader = extractSmtpHeader(captured.raw, 'subject');
|
|
expect(subjectHeader).toBe('Prefs Email Lifecycle Book');
|
|
});
|
|
|
|
it('enforces quick-send defaults and library-access boundaries', async () => {
|
|
const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'quick-send-provider'));
|
|
expect(providerCreateResponse.statusCode).toBe(201);
|
|
const provider = providerCreateResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Quick Recipient',
|
|
email: 'quick-recipient@example.com',
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
const preferenceResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultProviderId: provider.id,
|
|
defaultRecipientId: recipient.id,
|
|
});
|
|
expect(preferenceResponse.statusCode).toBe(200);
|
|
|
|
const quickSendResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/send/quick/${book.bookId}`);
|
|
expect(quickSendResponse.statusCode).toBe(202);
|
|
expect(quickSendResponse.json()).toEqual({ queued: 1 });
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
|
|
const noLibraryQuickSend = await apiAs(senderNoLibrary, 'POST', `/api/v1/email/send/quick/${book.bookId}`);
|
|
expectError(noLibraryQuickSend, 403, 'No access to this library');
|
|
|
|
const noDefaultQuickSend = await apiAs(foreignSender, 'POST', `/api/v1/email/send/quick/${book.bookId}`);
|
|
expectError(noDefaultQuickSend, 400, 'No default recipient configured');
|
|
});
|
|
|
|
it('keeps user log scoped and supports failure-first then successful resend', async () => {
|
|
const failingProviderResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', {
|
|
...buildProviderPayload(ctx, 'failing-provider'),
|
|
host: '127.0.0.1',
|
|
port: 1,
|
|
});
|
|
expect(failingProviderResponse.statusCode).toBe(201);
|
|
const failingProvider = failingProviderResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${failingProvider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Retry Recipient',
|
|
email: 'retry-recipient@example.com',
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
recipientIds: [recipient.id],
|
|
providerId: failingProvider.id,
|
|
});
|
|
expect(sendResponse.statusCode).toBe(202);
|
|
expect(sendResponse.json()).toEqual({ queued: 1 });
|
|
|
|
let failedLogId = 0;
|
|
await waitForCondition(async () => {
|
|
const latest = await latestLogForUser(senderOnly.userId);
|
|
if (!latest) throw new Error('Missing sender log');
|
|
if (latest.attemptCount < 1 || latest.errorMessage === null) {
|
|
throw new Error(`Expected first-attempt failure, got attemptCount=${latest.attemptCount} error=${latest.errorMessage ?? 'null'}`);
|
|
}
|
|
failedLogId = latest.id;
|
|
}, 12_000);
|
|
|
|
const foreignLogList = await apiAs(foreignSender, 'GET', '/api/v1/email/log?page=0&size=20');
|
|
expect(foreignLogList.statusCode).toBe(200);
|
|
expect(foreignLogList.json()).toEqual([]);
|
|
|
|
const foreignDelete = await apiAs(foreignSender, 'DELETE', `/api/v1/email/log/${failedLogId}`);
|
|
expectError(foreignDelete, 403, 'No access to this log entry');
|
|
|
|
const foreignResend = await apiAs(foreignSender, 'POST', `/api/v1/email/log/${failedLogId}/resend`);
|
|
expectError(foreignResend, 403, 'No access to this log entry');
|
|
|
|
const repairProvider = await apiAs(superManager, 'PUT', `/api/v1/email/providers/${failingProvider.id}`, {
|
|
host: ctx.smtpSink.getHost(),
|
|
port: ctx.smtpSink.getPort(),
|
|
auth: false,
|
|
ssl: false,
|
|
startTls: false,
|
|
});
|
|
expect(repairProvider.statusCode).toBe(200);
|
|
|
|
const resendResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/log/${failedLogId}/resend`);
|
|
expect(resendResponse.statusCode).toBe(202);
|
|
expect(resendResponse.json()).toEqual({ queued: 1 });
|
|
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
|
|
let resendLogId = 0;
|
|
await waitForCondition(async () => {
|
|
const latest = await latestLogForUser(senderOnly.userId);
|
|
if (!latest) throw new Error('Missing latest sender log');
|
|
if (latest.id === failedLogId) throw new Error('Resend log has not been created yet');
|
|
if (latest.status !== 'sent') throw new Error(`Resend log not sent yet: ${latest.status}`);
|
|
resendLogId = latest.id;
|
|
}, 12_000);
|
|
|
|
const senderDeleteResendLog = await apiAs(senderOnly, 'DELETE', `/api/v1/email/log/${resendLogId}`);
|
|
expect(senderDeleteResendLog.statusCode).toBe(204);
|
|
});
|
|
});
|
|
|
|
describe('outbox page contracts', () => {
|
|
it('makes a starred provider and recipient the defaults that quick send uses', async () => {
|
|
const providerResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'starred-provider'));
|
|
expect(providerResponse.statusCode).toBe(201);
|
|
const provider = providerResponse.json() as { id: number };
|
|
const recipientResponse = await apiAs(superManager, 'POST', '/api/v1/email/recipients', { name: 'Starred', email: 'starred@example.com' });
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/default`)).statusCode).toBe(200);
|
|
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/recipients/${recipient.id}/default`)).statusCode).toBe(200);
|
|
|
|
const preferences = await apiAs(superManager, 'GET', '/api/v1/email/preferences');
|
|
expect(preferences.json()).toMatchObject({ defaultProviderId: provider.id, defaultRecipientId: recipient.id });
|
|
|
|
const quickSend = await apiAs(superManager, 'POST', `/api/v1/email/send/quick/${book.bookId}`);
|
|
expect(quickSend.statusCode).toBe(202);
|
|
const [message] = await ctx.smtpSink.waitForMessages(1);
|
|
expect(message.envelopeTo).toContain('starred@example.com');
|
|
await waitForCondition(async () => {
|
|
const listed = (await apiAs(superManager, 'GET', '/api/v1/email/recipients')).json() as Array<{
|
|
id: number;
|
|
lastSend: { bookTitle: string; status: string } | null;
|
|
}>;
|
|
const lastSend = listed.find((r) => r.id === recipient.id)?.lastSend;
|
|
if (lastSend?.status !== 'sent' || lastSend.bookTitle !== 'Email Lifecycle Book')
|
|
throw new Error(`Unexpected lastSend ${JSON.stringify(lastSend)}`);
|
|
}, 10_000);
|
|
|
|
// Choosing a default through preferences moves the legacy flag, so the two never disagree again.
|
|
const otherRecipient = (
|
|
await apiAs(superManager, 'POST', '/api/v1/email/recipients', { name: 'Other', email: 'other@example.com' })
|
|
).json() as { id: number };
|
|
expect((await apiAs(superManager, 'PUT', '/api/v1/email/preferences', { defaultRecipientId: otherRecipient.id })).statusCode).toBe(200);
|
|
const recipients = (await apiAs(superManager, 'GET', '/api/v1/email/recipients')).json() as Array<{ id: number; isDefault: boolean }>;
|
|
expect(recipients.find((r) => r.id === otherRecipient.id)?.isDefault).toBe(true);
|
|
expect(recipients.find((r) => r.id === recipient.id)?.isDefault).toBe(false);
|
|
});
|
|
|
|
it('lists an unresolved failure with its book until a later send replaces it', async () => {
|
|
const failingProvider = (
|
|
await apiAs(superManager, 'POST', '/api/v1/email/providers', {
|
|
...buildProviderPayload(ctx, 'attention-provider'),
|
|
host: '127.0.0.1',
|
|
port: 1,
|
|
})
|
|
).json() as { id: number };
|
|
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${failingProvider.id}/share`)).statusCode).toBe(200);
|
|
const recipient = (
|
|
await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { name: 'Attention', email: 'attention@example.com' })
|
|
).json() as {
|
|
id: number;
|
|
};
|
|
|
|
const send = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
recipientIds: [recipient.id],
|
|
providerId: failingProvider.id,
|
|
});
|
|
expect(send.statusCode).toBe(202);
|
|
let failedId = 0;
|
|
await waitForCondition(async () => {
|
|
const latest = await latestLogForUser(senderOnly.userId);
|
|
if (!latest || latest.attemptCount < 1) throw new Error('First attempt has not failed yet');
|
|
failedId = latest.id;
|
|
}, 15_000);
|
|
// The retry schedule takes two and a half minutes to give up, so settle the row the way the
|
|
// last attempt would.
|
|
await ctx.db.update(schema.emailSendLog).set({ status: 'failed', nextRetryAt: null }).where(eq(schema.emailSendLog.id, failedId));
|
|
|
|
const attention = (await apiAs(senderOnly, 'GET', '/api/v1/email/log/attention')).json() as Array<Record<string, unknown>>;
|
|
expect(attention).toHaveLength(1);
|
|
expect(attention[0]).toMatchObject({ id: failedId, bookTitle: 'Email Lifecycle Book', fileFormat: 'epub', status: 'failed' });
|
|
expect(attention[0].fileSizeBytes).toEqual(expect.any(Number));
|
|
expect((await apiAs(senderOnly, 'GET', '/api/v1/email/log/summary')).json()).toEqual({ total: 1, failed: 1, pending: 0 });
|
|
expect((await apiAs(foreignSender, 'GET', '/api/v1/email/log/attention')).json()).toEqual([]);
|
|
expect(attention[0]).toHaveProperty('bookAuthors');
|
|
|
|
// The recipient filter returns this recipient's sends, and nothing for someone else's recipient.
|
|
const forRecipient = (await apiAs(senderOnly, 'GET', `/api/v1/email/log?recipientId=${recipient.id}`)).json() as Array<{ id: number }>;
|
|
expect(forRecipient.map((row) => row.id)).toEqual([failedId]);
|
|
expect((await apiAs(foreignSender, 'GET', `/api/v1/email/log?recipientId=${recipient.id}`)).json()).toEqual([]);
|
|
|
|
await apiAs(superManager, 'PUT', `/api/v1/email/providers/${failingProvider.id}`, {
|
|
host: ctx.smtpSink.getHost(),
|
|
port: ctx.smtpSink.getPort(),
|
|
auth: false,
|
|
ssl: false,
|
|
startTls: false,
|
|
});
|
|
expect((await apiAs(senderOnly, 'POST', `/api/v1/email/log/${failedId}/resend`)).statusCode).toBe(202);
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
await waitForCondition(async () => {
|
|
const latest = await latestLogForUser(senderOnly.userId);
|
|
if (latest?.status !== 'sent') throw new Error('Resend not settled yet');
|
|
}, 15_000);
|
|
|
|
expect((await apiAs(senderOnly, 'GET', '/api/v1/email/log/attention')).json()).toEqual([]);
|
|
expect((await apiAs(senderOnly, 'GET', '/api/v1/email/log/summary')).json()).toEqual({ total: 2, failed: 1, pending: 0 });
|
|
const failedOnly = (await apiAs(senderOnly, 'GET', '/api/v1/email/log?status=failed')).json() as Array<{ id: number }>;
|
|
expect(failedOnly.map((entry) => entry.id)).toEqual([failedId]);
|
|
expectError(await apiAs(senderOnly, 'GET', '/api/v1/email/log?status=queued'), 400);
|
|
});
|
|
|
|
it('previews unsaved template text against a book the caller can read', async () => {
|
|
const preview = await apiAs(senderOnly, 'POST', '/api/v1/email/templates/preview', {
|
|
subject: 'Pick: {{title}}',
|
|
bodyText: 'The {{format}} is attached.',
|
|
bookId: book.bookId,
|
|
});
|
|
expect(preview.statusCode).toBe(200);
|
|
expect(preview.json()).toMatchObject({
|
|
subject: 'Pick: Email Lifecycle Book',
|
|
bodyText: 'The EPUB is attached.',
|
|
attachment: { format: 'epub' },
|
|
});
|
|
expect((preview.json() as { attachment: { fileName: string } }).attachment.fileName).not.toContain('/');
|
|
|
|
const denied = await apiAs(senderNoLibrary, 'POST', '/api/v1/email/templates/preview', { subject: 'S', bodyText: 'B', bookId: book.bookId });
|
|
expect(denied.statusCode).toBe(403);
|
|
});
|
|
|
|
it('sends a plain test email to an inbox and refuses a Kindle', async () => {
|
|
const provider = (await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'test-mail-provider'))).json() as {
|
|
id: number;
|
|
};
|
|
expect((await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`)).statusCode).toBe(200);
|
|
expect((await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', { defaultProviderId: provider.id })).statusCode).toBe(200);
|
|
const inbox = (await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { name: 'Inbox', email: 'test-inbox@example.com' })).json() as {
|
|
id: number;
|
|
};
|
|
const kindle = (
|
|
await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', { name: 'Kindle', email: 'reader_1@kindle.com', deviceType: 'kindle' })
|
|
).json() as { id: number };
|
|
|
|
const sent = await apiAs(senderOnly, 'POST', `/api/v1/email/recipients/${inbox.id}/test`, { subject: 'BookOrbit test', text: 'It works.' });
|
|
expect(sent.statusCode).toBe(200);
|
|
expect(sent.json()).toEqual({ success: true });
|
|
const [message] = await ctx.smtpSink.waitForMessages(1);
|
|
expect(extractSmtpHeader(message.raw, 'Subject')).toBe('BookOrbit test');
|
|
|
|
expectError(await apiAs(senderOnly, 'POST', `/api/v1/email/recipients/${kindle.id}/test`, { subject: 'S', text: 'T' }), 400);
|
|
expectError(await apiAs(foreignSender, 'POST', `/api/v1/email/recipients/${inbox.id}/test`, { subject: 'S', text: 'T' }), 403);
|
|
});
|
|
});
|
|
|
|
describe('admin log and superuser enforcement', () => {
|
|
it('restricts /email/admin/log to superusers only', async () => {
|
|
const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', buildProviderPayload(ctx, 'admin-log-provider'));
|
|
expect(providerCreateResponse.statusCode).toBe(201);
|
|
const provider = providerCreateResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Admin Log Recipient',
|
|
email: 'admin-log-recipient@example.com',
|
|
});
|
|
expect(recipientResponse.statusCode).toBe(201);
|
|
const recipient = recipientResponse.json() as { id: number };
|
|
|
|
const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
recipientIds: [recipient.id],
|
|
providerId: provider.id,
|
|
});
|
|
expect(sendResponse.statusCode).toBe(202);
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
|
|
const managerAdminLogResponse = await apiAs(managerOnly, 'GET', '/api/v1/email/admin/log?page=0&size=20');
|
|
expectError(managerAdminLogResponse, 403, 'Only superusers can view all email logs');
|
|
|
|
const senderAdminLogResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/admin/log?page=0&size=20');
|
|
expectError(senderAdminLogResponse, 403, Permission.ManageEmail);
|
|
|
|
const superAdminLogResponse = await apiAs(superManager, 'GET', '/api/v1/email/admin/log?page=0&size=20');
|
|
expect(superAdminLogResponse.statusCode).toBe(200);
|
|
const entries = superAdminLogResponse.json() as Array<{ userId: number }>;
|
|
expect(entries).toEqual(expect.arrayContaining([expect.objectContaining({ userId: senderOnly.userId })]));
|
|
});
|
|
});
|
|
|
|
describe('client contract regression checks', () => {
|
|
it('preserves API response shapes consumed by email composables', async () => {
|
|
const providerCreateResponse = await apiAs(superManager, 'POST', '/api/v1/email/providers', {
|
|
...buildProviderPayload(ctx, 'client-contract-provider'),
|
|
fromName: 'Contract Bot',
|
|
fromAddress: 'contract@example.com',
|
|
});
|
|
expect(providerCreateResponse.statusCode).toBe(201);
|
|
const provider = providerCreateResponse.json() as { id: number };
|
|
const shareResponse = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${provider.id}/share`);
|
|
expect(shareResponse.statusCode).toBe(200);
|
|
|
|
const templateCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/templates', {
|
|
name: `client-contract-template-${randomUUID().slice(0, 8)}`,
|
|
subject: 'Client Contract {{title}}',
|
|
bodyText: 'Client Contract Body',
|
|
});
|
|
expect(templateCreateResponse.statusCode).toBe(201);
|
|
const template = templateCreateResponse.json() as { id: number };
|
|
|
|
const recipientCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
|
name: 'Client Contract Recipient',
|
|
email: 'client-contract-recipient@example.com',
|
|
});
|
|
expect(recipientCreateResponse.statusCode).toBe(201);
|
|
const recipient = recipientCreateResponse.json() as { id: number };
|
|
|
|
const groupCreateResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', {
|
|
name: `client-contract-group-${randomUUID().slice(0, 8)}`,
|
|
});
|
|
expect(groupCreateResponse.statusCode).toBe(201);
|
|
const group = groupCreateResponse.json() as { id: number };
|
|
|
|
const addMemberResponse = await apiAs(senderOnly, 'POST', `/api/v1/email/recipient-groups/${group.id}/members`, {
|
|
recipientId: recipient.id,
|
|
});
|
|
expect(addMemberResponse.statusCode).toBe(201);
|
|
|
|
const savePreferencesResponse = await apiAs(senderOnly, 'PUT', '/api/v1/email/preferences', {
|
|
defaultProviderId: provider.id,
|
|
defaultRecipientId: recipient.id,
|
|
defaultTemplateId: template.id,
|
|
});
|
|
expect(savePreferencesResponse.statusCode).toBe(200);
|
|
|
|
const providersResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/providers');
|
|
expect(providersResponse.statusCode).toBe(200);
|
|
expect(providersResponse.json()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: provider.id,
|
|
fromName: 'Contract Bot',
|
|
fromAddress: 'contract@example.com',
|
|
hasPassword: false,
|
|
isShared: true,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const templatesResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/templates');
|
|
expect(templatesResponse.statusCode).toBe(200);
|
|
expect(templatesResponse.json()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: template.id,
|
|
name: expect.any(String),
|
|
subject: expect.any(String),
|
|
bodyText: expect.any(String),
|
|
isDefault: expect.any(Boolean),
|
|
isSystem: expect.any(Boolean),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const recipientsResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/recipients');
|
|
expect(recipientsResponse.statusCode).toBe(200);
|
|
expect(recipientsResponse.json()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: recipient.id,
|
|
name: expect.any(String),
|
|
email: expect.any(String),
|
|
isDefault: expect.any(Boolean),
|
|
defaultTemplateId: null,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const groupsResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/recipient-groups');
|
|
expect(groupsResponse.statusCode).toBe(200);
|
|
expect(groupsResponse.json()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: group.id,
|
|
name: expect.any(String),
|
|
members: expect.arrayContaining([expect.objectContaining({ id: recipient.id })]),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const preferencesResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/preferences');
|
|
expect(preferencesResponse.statusCode).toBe(200);
|
|
expect(preferencesResponse.json()).toEqual(
|
|
expect.objectContaining({
|
|
userId: senderOnly.userId,
|
|
defaultProviderId: provider.id,
|
|
defaultRecipientId: recipient.id,
|
|
defaultTemplateId: template.id,
|
|
}),
|
|
);
|
|
|
|
const sendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
|
|
bookIds: [book.bookId],
|
|
recipientIds: [recipient.id],
|
|
providerId: provider.id,
|
|
});
|
|
expect(sendResponse.statusCode).toBe(202);
|
|
expect(sendResponse.json()).toEqual({ queued: 1 });
|
|
await ctx.smtpSink.waitForMessages(1);
|
|
|
|
const logResponse = await apiAs(senderOnly, 'GET', '/api/v1/email/log?page=0&size=20');
|
|
expect(logResponse.statusCode).toBe(200);
|
|
expect(logResponse.json()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: expect.any(Number),
|
|
userId: senderOnly.userId,
|
|
toEmail: expect.any(String),
|
|
status: expect.stringMatching(/pending|sent|failed/),
|
|
attemptCount: expect.any(Number),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
});
|