mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
test(email): add email lifecycle E2E suite and harden send behavior
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
name: Email Lifecycle E2E
|
||||||
|
run-name: ${{ format('Email Lifecycle E2E - {0}', github.ref_name) }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "server/src/modules/email/**"
|
||||||
|
- "server/src/modules/book/**"
|
||||||
|
- "server/src/modules/library/**"
|
||||||
|
- "server/src/modules/auth/**"
|
||||||
|
- "server/src/modules/cover/**"
|
||||||
|
- "server/src/modules/scanner/**"
|
||||||
|
- "server/src/modules/seed/**"
|
||||||
|
- "server/src/db/schema/**"
|
||||||
|
- "packages/types/src/**"
|
||||||
|
- "server/test/email-lifecycle.e2e-spec.ts"
|
||||||
|
- "server/test/e2e/email/**"
|
||||||
|
- "server/test/e2e/**"
|
||||||
|
- "scripts/test/**"
|
||||||
|
- "scripts/db/**"
|
||||||
|
- "client/src/features/email/**"
|
||||||
|
- "client/src/router/index.ts"
|
||||||
|
- "client/src/features/settings/SettingsHeader.vue"
|
||||||
|
- "client/src/views/HomeView.vue"
|
||||||
|
- "client/src/views/CollectionView.vue"
|
||||||
|
- "client/src/views/LensView.vue"
|
||||||
|
- "client/src/features/book/components/BookCoverCard.vue"
|
||||||
|
- "client/src/features/book/components/BookListRow.vue"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "server/src/modules/email/**"
|
||||||
|
- "server/src/modules/book/**"
|
||||||
|
- "server/src/modules/library/**"
|
||||||
|
- "server/src/modules/auth/**"
|
||||||
|
- "server/src/modules/cover/**"
|
||||||
|
- "server/src/modules/scanner/**"
|
||||||
|
- "server/src/modules/seed/**"
|
||||||
|
- "server/src/db/schema/**"
|
||||||
|
- "packages/types/src/**"
|
||||||
|
- "server/test/email-lifecycle.e2e-spec.ts"
|
||||||
|
- "server/test/e2e/email/**"
|
||||||
|
- "server/test/e2e/**"
|
||||||
|
- "scripts/test/**"
|
||||||
|
- "scripts/db/**"
|
||||||
|
- "client/src/features/email/**"
|
||||||
|
- "client/src/router/index.ts"
|
||||||
|
- "client/src/features/settings/SettingsHeader.vue"
|
||||||
|
- "client/src/views/HomeView.vue"
|
||||||
|
- "client/src/views/CollectionView.vue"
|
||||||
|
- "client/src/views/LensView.vue"
|
||||||
|
- "client/src/features/book/components/BookCoverCard.vue"
|
||||||
|
- "client/src/features/book/components/BookListRow.vue"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
email-lifecycle-e2e:
|
||||||
|
uses: ./.github/workflows/e2e-runner.yml
|
||||||
|
with:
|
||||||
|
suite-id: email-lifecycle
|
||||||
|
check-name: Server Email Lifecycle E2E
|
||||||
|
artifact-name: email-lifecycle-e2e-report
|
||||||
|
job-timeout-minutes: 45
|
||||||
+12
-1
@@ -121,9 +121,11 @@ pnpm run test:e2e -- scanner-file-ops # scanner file operation e2e suite (dedic
|
|||||||
pnpm run test:e2e -- staging-ingest-finalize # staging ingest/finalize e2e suite (dedicated projectx_e2e DB)
|
pnpm run test:e2e -- staging-ingest-finalize # staging ingest/finalize e2e suite (dedicated projectx_e2e DB)
|
||||||
pnpm run test:e2e -- opds-auth-catalog # OPDS auth/catalog e2e suite (dedicated projectx_e2e DB)
|
pnpm run test:e2e -- opds-auth-catalog # OPDS auth/catalog e2e suite (dedicated projectx_e2e DB)
|
||||||
pnpm run test:e2e -- reader-state-isolation # reader state isolation e2e suite (dedicated projectx_e2e DB)
|
pnpm run test:e2e -- reader-state-isolation # reader state isolation e2e suite (dedicated projectx_e2e DB)
|
||||||
|
pnpm run test:e2e -- email-lifecycle # email lifecycle e2e suite (dedicated projectx_e2e DB)
|
||||||
pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all"
|
pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all"
|
||||||
pnpm run test:e2e:opds-auth-catalog # alias for "pnpm run test:e2e -- opds-auth-catalog"
|
pnpm run test:e2e:opds-auth-catalog # alias for "pnpm run test:e2e -- opds-auth-catalog"
|
||||||
pnpm run test:e2e:reader-state-isolation # alias for "pnpm run test:e2e -- reader-state-isolation"
|
pnpm run test:e2e:reader-state-isolation # alias for "pnpm run test:e2e -- reader-state-isolation"
|
||||||
|
pnpm run test:e2e:email-lifecycle # alias for "pnpm run test:e2e -- email-lifecycle"
|
||||||
pnpm run test:e2e:list # list all supported e2e suite ids
|
pnpm run test:e2e:list # list all supported e2e suite ids
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -187,6 +189,14 @@ pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flatt
|
|||||||
- `reader-state-isolation-e2e-junit.xml`
|
- `reader-state-isolation-e2e-junit.xml`
|
||||||
- `reader-state-isolation-e2e-scenarios.json`
|
- `reader-state-isolation-e2e-scenarios.json`
|
||||||
|
|
||||||
|
### Email lifecycle e2e details
|
||||||
|
|
||||||
|
- `pnpm run test:e2e -- email-lifecycle` prepares and migrates the dedicated e2e database (`projectx_e2e`) before running.
|
||||||
|
- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed.
|
||||||
|
- Results are written to `test-results/server/`:
|
||||||
|
- `email-lifecycle-e2e-junit.xml`
|
||||||
|
- `email-lifecycle-e2e-scenarios.json`
|
||||||
|
|
||||||
### E2E in CI (how to trigger)
|
### E2E in CI (how to trigger)
|
||||||
|
|
||||||
All suite workflows call the reusable `E2E Runner (reusable)` workflow, which runs:
|
All suite workflows call the reusable `E2E Runner (reusable)` workflow, which runs:
|
||||||
@@ -202,11 +212,12 @@ pnpm run test:e2e -- <suite-id>
|
|||||||
| Staging Ingest Finalize E2E | `staging-ingest-finalize` | `workflow_dispatch`, `push` (staging-related paths), `pull_request` (same paths) |
|
| Staging Ingest Finalize E2E | `staging-ingest-finalize` | `workflow_dispatch`, `push` (staging-related paths), `pull_request` (same paths) |
|
||||||
| OPDS Auth Catalog E2E | `opds-auth-catalog` | `workflow_dispatch`, `push` (opds-related paths), `pull_request` (same paths) |
|
| OPDS Auth Catalog E2E | `opds-auth-catalog` | `workflow_dispatch`, `push` (opds-related paths), `pull_request` (same paths) |
|
||||||
| Reader State Isolation E2E | `reader-state-isolation` | `workflow_dispatch`, `push` (reader-state-related paths), `pull_request` (same paths) |
|
| Reader State Isolation E2E | `reader-state-isolation` | `workflow_dispatch`, `push` (reader-state-related paths), `pull_request` (same paths) |
|
||||||
|
| Email Lifecycle E2E | `email-lifecycle` | `workflow_dispatch`, `push` (email-related paths), `pull_request` (same paths) |
|
||||||
|
|
||||||
Manual trigger steps:
|
Manual trigger steps:
|
||||||
|
|
||||||
1. Open GitHub Actions.
|
1. Open GitHub Actions.
|
||||||
2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, **OPDS Auth Catalog E2E**, or **Reader State Isolation E2E**.
|
2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, **OPDS Auth Catalog E2E**, **Reader State Isolation E2E**, or **Email Lifecycle E2E**.
|
||||||
3. Click **Run workflow**.
|
3. Click **Run workflow**.
|
||||||
|
|
||||||
Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`.
|
Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"test:e2e:metadata-write:atomicity": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"atomicity failpoints\"",
|
"test:e2e:metadata-write:atomicity": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"atomicity failpoints\"",
|
||||||
"test:e2e:metadata-write:concurrency": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"concurrency hardening\"",
|
"test:e2e:metadata-write:concurrency": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"concurrency hardening\"",
|
||||||
"test:e2e:reader-state-isolation": "node ./scripts/test/run-e2e-suite.mjs reader-state-isolation",
|
"test:e2e:reader-state-isolation": "node ./scripts/test/run-e2e-suite.mjs reader-state-isolation",
|
||||||
|
"test:e2e:email-lifecycle": "node ./scripts/test/run-e2e-suite.mjs email-lifecycle",
|
||||||
"test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list",
|
"test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list",
|
||||||
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
|
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
|
||||||
"coverage:server": "pnpm --filter server test:cov",
|
"coverage:server": "pnpm --filter server test:cov",
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export const E2E_SUITES = Object.freeze({
|
|||||||
prepareDedicatedDatabase: true,
|
prepareDedicatedDatabase: true,
|
||||||
useDedicatedDatabase: true,
|
useDedicatedDatabase: true,
|
||||||
},
|
},
|
||||||
|
"email-lifecycle": {
|
||||||
|
id: "email-lifecycle",
|
||||||
|
description: "Email lifecycle suite",
|
||||||
|
vitestTarget: "test/email-lifecycle.e2e-spec.ts",
|
||||||
|
junitOutput: `${TEST_RESULTS_DIR}/email-lifecycle-e2e-junit.xml`,
|
||||||
|
prepareDedicatedDatabase: true,
|
||||||
|
useDedicatedDatabase: true,
|
||||||
|
},
|
||||||
"reader-state-isolation": {
|
"reader-state-isolation": {
|
||||||
id: "reader-state-isolation",
|
id: "reader-state-isolation",
|
||||||
description: "Reader state isolation suite",
|
description: "Reader state isolation suite",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function isUniqueViolation(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
|
||||||
|
const directCode = (error as { code?: unknown }).code;
|
||||||
|
if (directCode === '23505') return true;
|
||||||
|
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
|
||||||
|
return causeCode === '23505';
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ describe('EmailProviderResolver', () => {
|
|||||||
port: 587,
|
port: 587,
|
||||||
username: 'test-user',
|
username: 'test-user',
|
||||||
plainPassword: 'decrypted-password',
|
plainPassword: 'decrypted-password',
|
||||||
|
fromName: 'ProjectX Bot',
|
||||||
|
fromAddress: 'bot@example.com',
|
||||||
auth: true,
|
auth: true,
|
||||||
ssl: false,
|
ssl: false,
|
||||||
startTls: true,
|
startTls: true,
|
||||||
@@ -70,6 +72,8 @@ describe('EmailProviderResolver', () => {
|
|||||||
expect(result.providerId).toBe(10);
|
expect(result.providerId).toBe(10);
|
||||||
expect(result.config.password).toBe('decrypted-password');
|
expect(result.config.password).toBe('decrypted-password');
|
||||||
expect(result.config.host).toBe('smtp.test.com');
|
expect(result.config.host).toBe('smtp.test.com');
|
||||||
|
expect(result.config.fromName).toBe('ProjectX Bot');
|
||||||
|
expect(result.config.fromAddress).toBe('bot@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve from default provider if no ID requested', async () => {
|
it('should resolve from default provider if no ID requested', async () => {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export class EmailProviderResolver {
|
|||||||
port: provider.port,
|
port: provider.port,
|
||||||
username: provider.username,
|
username: provider.username,
|
||||||
password: provider.plainPassword,
|
password: provider.plainPassword,
|
||||||
|
fromName: provider.fromName,
|
||||||
|
fromAddress: provider.fromAddress,
|
||||||
auth: provider.auth,
|
auth: provider.auth,
|
||||||
ssl: provider.ssl,
|
ssl: provider.ssl,
|
||||||
startTls: provider.startTls,
|
startTls: provider.startTls,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { EmailProviderService } from './email-provider.service';
|
import { EmailProviderService } from './email-provider.service';
|
||||||
import { EmailProviderRepository } from './email-provider.repository';
|
import { EmailProviderRepository } from './email-provider.repository';
|
||||||
@@ -96,6 +96,12 @@ describe('EmailProviderService', () => {
|
|||||||
expect(encryption.encrypt).toHaveBeenCalledWith('new-password');
|
expect(encryption.encrypt).toHaveBeenCalledWith('new-password');
|
||||||
expect(repo.insert).toHaveBeenCalled();
|
expect(repo.insert).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate provider names to ConflictException', async () => {
|
||||||
|
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
const dto = { name: 'New SMTP', host: 'new.test.com', port: 465, password: 'new-password', auth: true, ssl: true, startTls: false };
|
||||||
|
await expect(service.create(dto, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findOne', () => {
|
describe('findOne', () => {
|
||||||
@@ -119,6 +125,11 @@ describe('EmailProviderService', () => {
|
|||||||
(repo.update as vi.Mock).mockResolvedValue([]);
|
(repo.update as vi.Mock).mockResolvedValue([]);
|
||||||
await expect(service.update(10, {}, mockUser)).rejects.toThrow(NotFoundException);
|
await expect(service.update(10, {}, mockUser)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate provider names to ConflictException', async () => {
|
||||||
|
(repo.update as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.update(10, { name: 'duplicate' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remove', () => {
|
describe('remove', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
import { emailProviders } from '../../db/schema';
|
import { emailProviders } from '../../db/schema';
|
||||||
@@ -8,6 +8,7 @@ import { EmailProviderRepository } from './email-provider.repository';
|
|||||||
import { CreateEmailProviderDto } from './dto/create-email-provider.dto';
|
import { CreateEmailProviderDto } from './dto/create-email-provider.dto';
|
||||||
import { UpdateEmailProviderDto } from './dto/update-email-provider.dto';
|
import { UpdateEmailProviderDto } from './dto/update-email-provider.dto';
|
||||||
import type { EmailProvider } from '../../db/schema';
|
import type { EmailProvider } from '../../db/schema';
|
||||||
|
import { isUniqueViolation } from './email-db-error.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailProviderService {
|
export class EmailProviderService {
|
||||||
@@ -29,20 +30,27 @@ export class EmailProviderService {
|
|||||||
|
|
||||||
async create(dto: CreateEmailProviderDto, user: RequestUser) {
|
async create(dto: CreateEmailProviderDto, user: RequestUser) {
|
||||||
const passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
|
const passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
|
||||||
const [created] = await this.repo.insert({
|
try {
|
||||||
userId: user.id,
|
const [created] = await this.repo.insert({
|
||||||
name: dto.name,
|
userId: user.id,
|
||||||
host: dto.host,
|
name: dto.name,
|
||||||
port: dto.port,
|
host: dto.host,
|
||||||
username: dto.username ?? null,
|
port: dto.port,
|
||||||
passwordEnc,
|
username: dto.username ?? null,
|
||||||
fromName: dto.fromName ?? null,
|
passwordEnc,
|
||||||
fromAddress: dto.fromAddress ?? null,
|
fromName: dto.fromName ?? null,
|
||||||
auth: dto.auth,
|
fromAddress: dto.fromAddress ?? null,
|
||||||
ssl: dto.ssl,
|
auth: dto.auth,
|
||||||
startTls: dto.startTls,
|
ssl: dto.ssl,
|
||||||
});
|
startTls: dto.startTls,
|
||||||
return this.sanitize(created);
|
});
|
||||||
|
return this.sanitize(created);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email provider with this name already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser) {
|
async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser) {
|
||||||
@@ -62,9 +70,16 @@ export class EmailProviderService {
|
|||||||
patch.passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
|
patch.passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await this.repo.update(id, user.id, patch);
|
try {
|
||||||
if (!updated) throw new NotFoundException('Provider not found');
|
const [updated] = await this.repo.update(id, user.id, patch);
|
||||||
return this.sanitize(updated);
|
if (!updated) throw new NotFoundException('Provider not found');
|
||||||
|
return this.sanitize(updated);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email provider with this name already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number, user: RequestUser) {
|
async remove(id: number, user: RequestUser) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { EmailRecipientGroupService } from './email-recipient-group.service';
|
import { EmailRecipientGroupService } from './email-recipient-group.service';
|
||||||
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
|
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
|
||||||
@@ -85,6 +85,11 @@ describe('EmailRecipientGroupService', () => {
|
|||||||
expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ name: 'New Group' }));
|
expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ name: 'New Group' }));
|
||||||
expect(result.id).toBe(10);
|
expect(result.id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate group names to ConflictException', async () => {
|
||||||
|
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.create({ name: 'New Group' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
@@ -94,6 +99,11 @@ describe('EmailRecipientGroupService', () => {
|
|||||||
expect(repo.update).toHaveBeenCalledWith(10, 1, dto);
|
expect(repo.update).toHaveBeenCalledWith(10, 1, dto);
|
||||||
expect(result.id).toBe(10);
|
expect(result.id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate group names to ConflictException', async () => {
|
||||||
|
(repo.update as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.update(10, { name: 'Updated' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remove', () => {
|
describe('remove', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
|
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
|
||||||
@@ -6,6 +6,7 @@ import { EmailRecipientRepository } from './email-recipient.repository';
|
|||||||
import { CreateEmailRecipientGroupDto } from './dto/create-email-recipient-group.dto';
|
import { CreateEmailRecipientGroupDto } from './dto/create-email-recipient-group.dto';
|
||||||
import { UpdateEmailRecipientGroupDto } from './dto/update-email-recipient-group.dto';
|
import { UpdateEmailRecipientGroupDto } from './dto/update-email-recipient-group.dto';
|
||||||
import type { EmailRecipientGroup } from '../../db/schema';
|
import type { EmailRecipientGroup } from '../../db/schema';
|
||||||
|
import { isUniqueViolation } from './email-db-error.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailRecipientGroupService {
|
export class EmailRecipientGroupService {
|
||||||
@@ -42,19 +43,33 @@ export class EmailRecipientGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateEmailRecipientGroupDto, user: RequestUser) {
|
async create(dto: CreateEmailRecipientGroupDto, user: RequestUser) {
|
||||||
const [created] = await this.repo.insert({
|
try {
|
||||||
userId: user.id,
|
const [created] = await this.repo.insert({
|
||||||
name: dto.name,
|
userId: user.id,
|
||||||
defaultTemplateId: dto.defaultTemplateId ?? null,
|
name: dto.name,
|
||||||
});
|
defaultTemplateId: dto.defaultTemplateId ?? null,
|
||||||
return { ...created, members: [] };
|
});
|
||||||
|
return { ...created, members: [] };
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email recipient group with this name already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateEmailRecipientGroupDto, user: RequestUser) {
|
async update(id: number, dto: UpdateEmailRecipientGroupDto, user: RequestUser) {
|
||||||
await this.getOwned(id, user);
|
await this.getOwned(id, user);
|
||||||
const [updated] = await this.repo.update(id, user.id, dto);
|
try {
|
||||||
if (!updated) throw new NotFoundException('Group not found');
|
const [updated] = await this.repo.update(id, user.id, dto);
|
||||||
return updated;
|
if (!updated) throw new NotFoundException('Group not found');
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email recipient group with this name already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number, user: RequestUser) {
|
async remove(id: number, user: RequestUser) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { EmailRecipientService } from './email-recipient.service';
|
import { EmailRecipientService } from './email-recipient.service';
|
||||||
import { EmailRecipientRepository } from './email-recipient.repository';
|
import { EmailRecipientRepository } from './email-recipient.repository';
|
||||||
@@ -67,6 +67,11 @@ describe('EmailRecipientService', () => {
|
|||||||
expect(result.id).toBe(10);
|
expect(result.id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate recipient emails to ConflictException on create', async () => {
|
||||||
|
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.create({ name: 'New', email: 'new@test.com' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
it('should update a recipient', async () => {
|
it('should update a recipient', async () => {
|
||||||
const dto = { name: 'Updated' };
|
const dto = { name: 'Updated' };
|
||||||
const result = await service.update(10, dto, mockUser);
|
const result = await service.update(10, dto, mockUser);
|
||||||
@@ -91,6 +96,11 @@ describe('EmailRecipientService', () => {
|
|||||||
await expect(service.update(10, { name: 'U' }, mockUser)).rejects.toThrow(NotFoundException);
|
await expect(service.update(10, { name: 'U' }, mockUser)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate recipient emails to ConflictException on update', async () => {
|
||||||
|
(repo.update as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.update(10, { email: 'duplicate@test.com' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException on setDefault if not exists', async () => {
|
it('should throw NotFoundException on setDefault if not exists', async () => {
|
||||||
(repo.setDefault as vi.Mock).mockResolvedValue([]);
|
(repo.setDefault as vi.Mock).mockResolvedValue([]);
|
||||||
await expect(service.setDefault(10, mockUser)).rejects.toThrow(NotFoundException);
|
await expect(service.setDefault(10, mockUser)).rejects.toThrow(NotFoundException);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
import { EmailRecipientRepository } from './email-recipient.repository';
|
import { EmailRecipientRepository } from './email-recipient.repository';
|
||||||
import { CreateEmailRecipientDto } from './dto/create-email-recipient.dto';
|
import { CreateEmailRecipientDto } from './dto/create-email-recipient.dto';
|
||||||
import { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto';
|
import { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto';
|
||||||
import type { EmailRecipient } from '../../db/schema';
|
import type { EmailRecipient } from '../../db/schema';
|
||||||
|
import { isUniqueViolation } from './email-db-error.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailRecipientService {
|
export class EmailRecipientService {
|
||||||
@@ -19,22 +20,36 @@ export class EmailRecipientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateEmailRecipientDto, user: RequestUser) {
|
async create(dto: CreateEmailRecipientDto, user: RequestUser) {
|
||||||
const [created] = await this.repo.insert({
|
try {
|
||||||
userId: user.id,
|
const [created] = await this.repo.insert({
|
||||||
name: dto.name,
|
userId: user.id,
|
||||||
email: dto.email,
|
name: dto.name,
|
||||||
deviceType: dto.deviceType ?? null,
|
email: dto.email,
|
||||||
preferredFormat: dto.preferredFormat ?? null,
|
deviceType: dto.deviceType ?? null,
|
||||||
defaultTemplateId: dto.defaultTemplateId ?? null,
|
preferredFormat: dto.preferredFormat ?? null,
|
||||||
});
|
defaultTemplateId: dto.defaultTemplateId ?? null,
|
||||||
return created;
|
});
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email recipient with this email already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateEmailRecipientDto, user: RequestUser) {
|
async update(id: number, dto: UpdateEmailRecipientDto, user: RequestUser) {
|
||||||
await this.getOwnedById(id, user);
|
await this.getOwnedById(id, user);
|
||||||
const [updated] = await this.repo.update(id, user.id, dto);
|
try {
|
||||||
if (!updated) throw new NotFoundException('Recipient not found');
|
const [updated] = await this.repo.update(id, user.id, dto);
|
||||||
return updated;
|
if (!updated) throw new NotFoundException('Recipient not found');
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email recipient with this email already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number, user: RequestUser) {
|
async remove(id: number, user: RequestUser) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('EmailSendOrchestrator', () => {
|
|||||||
let sendLogService: EmailSendLogService;
|
let sendLogService: EmailSendLogService;
|
||||||
let transportService: EmailTransportService;
|
let transportService: EmailTransportService;
|
||||||
let bookAccessService: EmailBookAccessService;
|
let bookAccessService: EmailBookAccessService;
|
||||||
|
let templateService: EmailTemplateService;
|
||||||
|
|
||||||
const mockUser: RequestUser = {
|
const mockUser: RequestUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -53,7 +54,10 @@ describe('EmailSendOrchestrator', () => {
|
|||||||
};
|
};
|
||||||
const mockFile = { id: 100, absolutePath: '/path/to/book.mobi', format: 'MOBI', relPath: 'Books/book.mobi' };
|
const mockFile = { id: 100, absolutePath: '/path/to/book.mobi', format: 'MOBI', relPath: 'Books/book.mobi' };
|
||||||
const mockTemplate = { id: 200, subject: 'Subject {{title}}', bodyText: 'Body' };
|
const mockTemplate = { id: 200, subject: 'Subject {{title}}', bodyText: 'Body' };
|
||||||
const mockProvider = { config: { host: 'smtp.test.com' }, providerId: 300 };
|
const mockProvider = {
|
||||||
|
config: { host: 'smtp.test.com', fromName: 'ProjectX Bot', fromAddress: 'bot@example.com' },
|
||||||
|
providerId: 300,
|
||||||
|
};
|
||||||
const mockLogEntry = { id: 400 };
|
const mockLogEntry = { id: 400 };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -125,6 +129,7 @@ describe('EmailSendOrchestrator', () => {
|
|||||||
sendLogService = module.get<EmailSendLogService>(EmailSendLogService);
|
sendLogService = module.get<EmailSendLogService>(EmailSendLogService);
|
||||||
transportService = module.get<EmailTransportService>(EmailTransportService);
|
transportService = module.get<EmailTransportService>(EmailTransportService);
|
||||||
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
|
bookAccessService = module.get<EmailBookAccessService>(EmailBookAccessService);
|
||||||
|
templateService = module.get<EmailTemplateService>(EmailTemplateService);
|
||||||
|
|
||||||
(fs.createReadStream as vi.Mock).mockReturnValue('mock-stream');
|
(fs.createReadStream as vi.Mock).mockReturnValue('mock-stream');
|
||||||
});
|
});
|
||||||
@@ -160,6 +165,36 @@ describe('EmailSendOrchestrator', () => {
|
|||||||
const dto: SendBookDto = { bookIds: [1], recipientIds: [10] };
|
const dto: SendBookDto = { bookIds: [1], recipientIds: [10] };
|
||||||
await expect(orchestrator.send(dto, mockUser)).rejects.toThrow('No access to this library');
|
await expect(orchestrator.send(dto, mockUser)).rejects.toThrow('No access to this library');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should prefer explicit templateId over recipient default template', async () => {
|
||||||
|
(recipientService.getOwnedByIds as vi.Mock).mockResolvedValue([{ ...mockRecipient, defaultTemplateId: 999 }]);
|
||||||
|
|
||||||
|
await orchestrator.send(
|
||||||
|
{
|
||||||
|
bookIds: [1],
|
||||||
|
recipientIds: [10],
|
||||||
|
templateId: 123,
|
||||||
|
},
|
||||||
|
mockUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(templateService.resolveTemplate).toHaveBeenCalledWith(123, mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to user preference defaultTemplateId when recipient and request templates are missing', async () => {
|
||||||
|
(preferencesService.getForUser as vi.Mock).mockResolvedValue({ defaultTemplateId: 777 });
|
||||||
|
(recipientService.getOwnedByIds as vi.Mock).mockResolvedValue([{ ...mockRecipient, defaultTemplateId: null }]);
|
||||||
|
|
||||||
|
await orchestrator.send(
|
||||||
|
{
|
||||||
|
bookIds: [1],
|
||||||
|
recipientIds: [10],
|
||||||
|
},
|
||||||
|
mockUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(templateService.resolveTemplate).toHaveBeenCalledWith(777, mockUser);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('quickSend', () => {
|
describe('quickSend', () => {
|
||||||
@@ -258,6 +293,19 @@ describe('EmailSendOrchestrator', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include from header when provider sender fields are configured', async () => {
|
||||||
|
const task = { recipientEmail: 'test@test.com' } as any;
|
||||||
|
const file = { absolutePath: '/test.mobi', relPath: 'test.mobi' } as any;
|
||||||
|
|
||||||
|
await (orchestrator as any).dispatchSend(400, { fromName: 'ProjectX Bot', fromAddress: 'bot@example.com' }, task, file, 'Subject', 'Body', 0);
|
||||||
|
|
||||||
|
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
from: 'ProjectX Bot <bot@example.com>',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use "convert" subject in resend if original was "convert"', async () => {
|
it('should use "convert" subject in resend if original was "convert"', async () => {
|
||||||
const existingLog = {
|
const existingLog = {
|
||||||
userId: mockUser.id,
|
userId: mockUser.id,
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export class EmailSendOrchestrator {
|
|||||||
try {
|
try {
|
||||||
await this.bookAccessService.assertUserCanAccessBooks(dto.bookIds, user);
|
await this.bookAccessService.assertUserCanAccessBooks(dto.bookIds, user);
|
||||||
|
|
||||||
const tasks = await this.buildTasks(dto, user);
|
const preferences = await this.preferencesService.getForUser(user.id);
|
||||||
|
const tasks = await this.buildTasks(dto, user, preferences?.defaultTemplateId ?? null);
|
||||||
if (tasks.length === 0) throw new BadRequestException('No recipients specified');
|
if (tasks.length === 0) throw new BadRequestException('No recipients specified');
|
||||||
|
|
||||||
const resolved = await this.providerResolver.resolve(user, dto.providerId);
|
const resolved = await this.providerResolver.resolve(user, dto.providerId);
|
||||||
@@ -177,7 +178,7 @@ export class EmailSendOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildTasks(dto: SendBookDto, user: RequestUser): Promise<Omit<SendTask, 'bookId'>[]> {
|
private async buildTasks(dto: SendBookDto, user: RequestUser, defaultTemplateId: number | null): Promise<Omit<SendTask, 'bookId'>[]> {
|
||||||
const recipientIds = new Set<number>(dto.recipientIds ?? []);
|
const recipientIds = new Set<number>(dto.recipientIds ?? []);
|
||||||
|
|
||||||
if (dto.groupIds?.length) {
|
if (dto.groupIds?.length) {
|
||||||
@@ -197,7 +198,7 @@ export class EmailSendOrchestrator {
|
|||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
deviceType: recipient.deviceType,
|
deviceType: recipient.deviceType,
|
||||||
preferredFormat: recipient.preferredFormat,
|
preferredFormat: recipient.preferredFormat,
|
||||||
effectiveTemplateId: recipient.defaultTemplateId ?? dto.templateId ?? null,
|
effectiveTemplateId: dto.templateId ?? recipient.defaultTemplateId ?? defaultTemplateId,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +241,10 @@ export class EmailSendOrchestrator {
|
|||||||
try {
|
try {
|
||||||
const transporter = this.transportService.buildTransporter(smtpConfig);
|
const transporter = this.transportService.buildTransporter(smtpConfig);
|
||||||
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : subject;
|
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : subject;
|
||||||
|
const from = this.buildFromHeader(smtpConfig.fromName, smtpConfig.fromAddress);
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
|
...(from ? { from } : {}),
|
||||||
to: task.recipientEmail,
|
to: task.recipientEmail,
|
||||||
subject: effectiveSubject,
|
subject: effectiveSubject,
|
||||||
text: bodyText,
|
text: bodyText,
|
||||||
@@ -266,7 +269,10 @@ export class EmailSendOrchestrator {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} attempt=${attempt} willRetry=true retryDelayMs=${delayMs} errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
|
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} attempt=${attempt} willRetry=true retryDelayMs=${delayMs} errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
|
||||||
);
|
);
|
||||||
setTimeout(() => void this.dispatchSend(logId, smtpConfig, task, file, subject, bodyText, attemptCount + 1), delayMs);
|
const retryTimer = setTimeout(() => void this.dispatchSend(logId, smtpConfig, task, file, subject, bodyText, attemptCount + 1), delayMs);
|
||||||
|
if (typeof retryTimer.unref === 'function') {
|
||||||
|
retryTimer.unref();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} toEmail=${task.recipientEmail} attempt=${attempt} willRetry=false errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
|
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} toEmail=${task.recipientEmail} attempt=${attempt} willRetry=false errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
|
||||||
@@ -286,6 +292,16 @@ export class EmailSendOrchestrator {
|
|||||||
return `${base}${ext}`;
|
return `${base}${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildFromHeader(fromName: string | null | undefined, fromAddress: string | null | undefined): string | undefined {
|
||||||
|
const normalizedAddress = fromAddress?.trim();
|
||||||
|
if (!normalizedAddress) return undefined;
|
||||||
|
|
||||||
|
const normalizedName = fromName?.trim();
|
||||||
|
if (!normalizedName) return normalizedAddress;
|
||||||
|
|
||||||
|
return `${normalizedName} <${normalizedAddress}>`;
|
||||||
|
}
|
||||||
|
|
||||||
private getErrorClass(error: unknown): string {
|
private getErrorClass(error: unknown): string {
|
||||||
if (error instanceof Error && error.name) return error.name;
|
if (error instanceof Error && error.name) return error.name;
|
||||||
return 'Error';
|
return 'Error';
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ describe('EmailTemplateContextService', () => {
|
|||||||
|
|
||||||
expect(context.title).toBe('Book Title');
|
expect(context.title).toBe('Book Title');
|
||||||
expect(context.author).toBe('A1, A2');
|
expect(context.author).toBe('A1, A2');
|
||||||
|
expect(context.authors).toBe('A1, A2');
|
||||||
|
expect(context.series).toBe('S');
|
||||||
|
expect(context.seriesName).toBe('S');
|
||||||
expect(context.tags).toBe('T1');
|
expect(context.tags).toBe('T1');
|
||||||
expect(context.fileSize).toBe('1.0 MB');
|
expect(context.fileSize).toBe('1.0 MB');
|
||||||
expect(context.coverUrl).toBe('http://localhost/api/books/1/cover');
|
expect(context.coverUrl).toBe('http://localhost/api/v1/books/1/cover');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if book not found', async () => {
|
it('should throw NotFoundException if book not found', async () => {
|
||||||
|
|||||||
@@ -28,13 +28,17 @@ export class EmailTemplateContextService {
|
|||||||
const selectedFile: BookFile | null = fileId !== null ? file : null;
|
const selectedFile: BookFile | null = fileId !== null ? file : null;
|
||||||
|
|
||||||
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
|
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
|
||||||
const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
|
const coverUrl = meta ? `${appUrl}/api/v1/books/${bookId}/cover` : '';
|
||||||
|
const authors = authorRows.map((a) => a.name).join(', ');
|
||||||
|
const seriesName = meta?.seriesName ?? '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: meta?.title ?? '',
|
title: meta?.title ?? '',
|
||||||
subtitle: meta?.subtitle ?? '',
|
subtitle: meta?.subtitle ?? '',
|
||||||
author: authorRows.map((a) => a.name).join(', '),
|
author: authors,
|
||||||
series: meta?.seriesName ?? '',
|
authors,
|
||||||
|
series: seriesName,
|
||||||
|
seriesName,
|
||||||
seriesIndex: meta?.seriesIndex ?? null,
|
seriesIndex: meta?.seriesIndex ?? null,
|
||||||
format: selectedFile?.format?.toUpperCase() ?? '',
|
format: selectedFile?.format?.toUpperCase() ?? '',
|
||||||
fileSize: formatFileSize(selectedFile?.sizeBytes ?? null),
|
fileSize: formatFileSize(selectedFile?.sizeBytes ?? null),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export interface TemplateContext {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
|
authors?: string | null;
|
||||||
series?: string | null;
|
series?: string | null;
|
||||||
|
seriesName?: string | null;
|
||||||
seriesIndex?: number | null;
|
seriesIndex?: number | null;
|
||||||
format?: string | null;
|
format?: string | null;
|
||||||
fileSize?: string | null;
|
fileSize?: string | null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
import { ConflictException, ForbiddenException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { EmailTemplateService } from './email-template.service';
|
import { EmailTemplateService } from './email-template.service';
|
||||||
import { EmailTemplateRepository } from './email-template.repository';
|
import { EmailTemplateRepository } from './email-template.repository';
|
||||||
@@ -90,6 +90,11 @@ describe('EmailTemplateService', () => {
|
|||||||
expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ name: 'New', userId: 1 }));
|
expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ name: 'New', userId: 1 }));
|
||||||
expect(result.id).toBe(10);
|
expect(result.id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate template names to ConflictException', async () => {
|
||||||
|
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.create({ name: 'New', subject: 'S', bodyText: 'B' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
@@ -111,6 +116,11 @@ describe('EmailTemplateService', () => {
|
|||||||
const result = await service.update(20, { name: 'New' }, mockAdmin);
|
const result = await service.update(20, { name: 'New' }, mockAdmin);
|
||||||
expect(result.name).toBe('New');
|
expect(result.name).toBe('New');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map duplicate template names to ConflictException on update', async () => {
|
||||||
|
(repo.update as vi.Mock).mockRejectedValue({ code: '23505' });
|
||||||
|
await expect(service.update(10, { name: 'New' }, mockUser)).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remove', () => {
|
describe('remove', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
import { EmailTemplateRepository } from './email-template.repository';
|
import { EmailTemplateRepository } from './email-template.repository';
|
||||||
@@ -8,6 +8,7 @@ import { EmailBookAccessService } from './email-book-access.service';
|
|||||||
import { CreateEmailTemplateDto } from './dto/create-email-template.dto';
|
import { CreateEmailTemplateDto } from './dto/create-email-template.dto';
|
||||||
import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
|
import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
|
||||||
import type { EmailTemplate } from '../../db/schema';
|
import type { EmailTemplate } from '../../db/schema';
|
||||||
|
import { isUniqueViolation } from './email-db-error.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailTemplateService {
|
export class EmailTemplateService {
|
||||||
@@ -29,13 +30,20 @@ export class EmailTemplateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateEmailTemplateDto, user: RequestUser) {
|
async create(dto: CreateEmailTemplateDto, user: RequestUser) {
|
||||||
const [created] = await this.repo.insert({
|
try {
|
||||||
userId: user.id,
|
const [created] = await this.repo.insert({
|
||||||
name: dto.name,
|
userId: user.id,
|
||||||
subject: dto.subject,
|
name: dto.name,
|
||||||
bodyText: dto.bodyText,
|
subject: dto.subject,
|
||||||
});
|
bodyText: dto.bodyText,
|
||||||
return created;
|
});
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email template with this name already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateEmailTemplateDto, user: RequestUser) {
|
async update(id: number, dto: UpdateEmailTemplateDto, user: RequestUser) {
|
||||||
@@ -45,9 +53,16 @@ export class EmailTemplateService {
|
|||||||
if (template.isSystem && !isSuperuser) throw new ForbiddenException('Only administrators can modify system templates');
|
if (template.isSystem && !isSuperuser) throw new ForbiddenException('Only administrators can modify system templates');
|
||||||
if (!template.isSystem && template.userId !== user.id) throw new ForbiddenException('Cannot modify this template');
|
if (!template.isSystem && template.userId !== user.id) throw new ForbiddenException('Cannot modify this template');
|
||||||
|
|
||||||
const [updated] = template.isSystem ? await this.repo.updateById(id, dto) : await this.repo.update(id, user.id, dto);
|
try {
|
||||||
if (!updated) throw new NotFoundException('Template not found');
|
const [updated] = template.isSystem ? await this.repo.updateById(id, dto) : await this.repo.update(id, user.id, dto);
|
||||||
return updated;
|
if (!updated) throw new NotFoundException('Template not found');
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('An email template with this name already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number, user: RequestUser) {
|
async remove(id: number, user: RequestUser) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface SmtpConfig {
|
|||||||
port: number;
|
port: number;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
|
fromName?: string | null;
|
||||||
|
fromAddress?: string | null;
|
||||||
auth: boolean;
|
auth: boolean;
|
||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
startTls: boolean;
|
startTls: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
export { createEpubFixture } from '../metadata-write/metadata-write-fixture-builder';
|
||||||
|
|
||||||
|
export interface EmailFixtureRoot {
|
||||||
|
rootPath: string;
|
||||||
|
booksPath: string;
|
||||||
|
stagingPath: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelativePath(path: string): void {
|
||||||
|
if (path.startsWith('/')) {
|
||||||
|
throw new Error(`Fixture paths must be relative. Received "${path}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmailFixtureRoot(prefix = 'email-lifecycle-e2e-'): Promise<EmailFixtureRoot> {
|
||||||
|
const rootPath = await mkdtemp(join(tmpdir(), prefix));
|
||||||
|
const booksPath = join(rootPath, 'books');
|
||||||
|
const stagingPath = join(booksPath, 'staging');
|
||||||
|
await mkdir(stagingPath, { recursive: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootPath,
|
||||||
|
booksPath,
|
||||||
|
stagingPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(rootPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeFixtureFile(rootPath: string, relativePath: string, content: string | Buffer): Promise<string> {
|
||||||
|
assertRelativePath(relativePath);
|
||||||
|
|
||||||
|
const absolutePath = join(rootPath, relativePath);
|
||||||
|
await mkdir(dirname(absolutePath), { recursive: true });
|
||||||
|
await writeFile(absolutePath, content);
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,756 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { hash } from 'bcryptjs';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { mkdir } from 'fs/promises';
|
||||||
|
import { createServer, type Socket } from 'net';
|
||||||
|
import { and, eq, inArray, isNotNull } from 'drizzle-orm';
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { DEFAULT_FORMAT_PRIORITY, type Permission } from '@projectx/types';
|
||||||
|
|
||||||
|
import { AppModule } from '../../../src/app.module';
|
||||||
|
import { DB } from '../../../src/db';
|
||||||
|
import * as schema from '../../../src/db/schema';
|
||||||
|
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
|
||||||
|
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
|
||||||
|
import { waitForScanCompletion } from '../app-harness';
|
||||||
|
import { createEmailFixtureRoot, type EmailFixtureRoot } from './email-fixture-builder';
|
||||||
|
|
||||||
|
type Db = NodePgDatabase<typeof schema>;
|
||||||
|
|
||||||
|
const ADMIN_SETUP_DTO = {
|
||||||
|
username: 'email-lifecycle-e2e-admin',
|
||||||
|
name: 'Email Lifecycle E2E Admin',
|
||||||
|
email: 'email-lifecycle-e2e-admin@example.com',
|
||||||
|
password: 'EmailLifecycleAdmin123',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvSnapshot {
|
||||||
|
booksPath: string | undefined;
|
||||||
|
stagingPath: string | undefined;
|
||||||
|
appUrl: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestUserSession {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailE2EContext {
|
||||||
|
app: NestFastifyApplication;
|
||||||
|
db: Db;
|
||||||
|
admin: TestUserSession;
|
||||||
|
fixture: EmailFixtureRoot;
|
||||||
|
envSnapshot: EnvSnapshot;
|
||||||
|
smtpSink: InProcessSmtpSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedLibrary {
|
||||||
|
libraryId: number;
|
||||||
|
libraryFolderId: number;
|
||||||
|
folderPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocatedBookFile {
|
||||||
|
libraryId: number;
|
||||||
|
bookId: number;
|
||||||
|
bookFileId: number;
|
||||||
|
absolutePath: string;
|
||||||
|
relPath: string | null;
|
||||||
|
format: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapturedSmtpMessage {
|
||||||
|
envelopeFrom: string | null;
|
||||||
|
envelopeTo: string[];
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SmtpSessionState {
|
||||||
|
mode: 'command' | 'data';
|
||||||
|
buffer: string;
|
||||||
|
envelopeFrom: string | null;
|
||||||
|
envelopeTo: string[];
|
||||||
|
dataLines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSmtpAddress(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const match = /^<(.+)>$/.exec(trimmed);
|
||||||
|
return (match?.[1] ?? trimmed).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLine(line: string): string {
|
||||||
|
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSmtpHeader(rawMessage: string, headerName: string): string | null {
|
||||||
|
const targetHeader = headerName.toLowerCase();
|
||||||
|
const [headerBlock] = rawMessage.split(/\r?\n\r?\n/, 1);
|
||||||
|
if (!headerBlock) return null;
|
||||||
|
|
||||||
|
const lines = headerBlock.split(/\r?\n/);
|
||||||
|
let currentHeader: string | null = null;
|
||||||
|
let currentValue = '';
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
if (/^\s/.test(rawLine)) {
|
||||||
|
if (currentHeader === targetHeader) {
|
||||||
|
currentValue = `${currentValue} ${rawLine.trim()}`;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^([^:]+):\s*(.*)$/.exec(rawLine);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
currentHeader = match[1].trim().toLowerCase();
|
||||||
|
currentValue = match[2].trim();
|
||||||
|
|
||||||
|
if (currentHeader === targetHeader) {
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InProcessSmtpSink {
|
||||||
|
private readonly host = '127.0.0.1';
|
||||||
|
private readonly sockets = new Set<Socket>();
|
||||||
|
private readonly messages: CapturedSmtpMessage[] = [];
|
||||||
|
private readonly server = createServer((socket) => {
|
||||||
|
this.handleConnection(socket);
|
||||||
|
});
|
||||||
|
private port = 0;
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
this.server.off('listening', onListening);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const onListening = () => {
|
||||||
|
this.server.off('error', onError);
|
||||||
|
const address = this.server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
reject(new Error('SMTP sink did not expose a TCP address'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.port = address.port;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.server.once('error', onError);
|
||||||
|
this.server.once('listening', onListening);
|
||||||
|
this.server.listen(0, this.host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
for (const socket of this.sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
this.sockets.clear();
|
||||||
|
|
||||||
|
if (!this.server.listening) return;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessages(): void {
|
||||||
|
this.messages.splice(0, this.messages.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages(): CapturedSmtpMessage[] {
|
||||||
|
return [...this.messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
getHost(): string {
|
||||||
|
return this.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPort(): number {
|
||||||
|
if (this.port < 1) {
|
||||||
|
throw new Error('SMTP sink is not started');
|
||||||
|
}
|
||||||
|
return this.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForMessages(count: number, timeoutMs = 10_000): Promise<CapturedSmtpMessage[]> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (this.messages.length >= count) return this.getMessages();
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for ${count} SMTP message(s). Captured=${this.messages.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleConnection(socket: Socket): void {
|
||||||
|
this.sockets.add(socket);
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
|
||||||
|
const state: SmtpSessionState = {
|
||||||
|
mode: 'command',
|
||||||
|
buffer: '',
|
||||||
|
envelopeFrom: null,
|
||||||
|
envelopeTo: [],
|
||||||
|
dataLines: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.write('220 email-lifecycle-smtp-sink ESMTP\r\n');
|
||||||
|
|
||||||
|
socket.on('data', (chunk: string) => {
|
||||||
|
state.buffer += chunk;
|
||||||
|
this.processBufferedInput(socket, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.sockets.delete(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
this.sockets.delete(socket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private processBufferedInput(socket: Socket, state: SmtpSessionState): void {
|
||||||
|
while (true) {
|
||||||
|
const newlineIndex = state.buffer.indexOf('\n');
|
||||||
|
if (newlineIndex < 0) return;
|
||||||
|
|
||||||
|
const rawLine = state.buffer.slice(0, newlineIndex);
|
||||||
|
state.buffer = state.buffer.slice(newlineIndex + 1);
|
||||||
|
const line = normalizeLine(rawLine);
|
||||||
|
|
||||||
|
if (state.mode === 'data') {
|
||||||
|
this.handleDataLine(socket, state, line);
|
||||||
|
} else {
|
||||||
|
this.handleCommandLine(socket, state, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDataLine(socket: Socket, state: SmtpSessionState, line: string): void {
|
||||||
|
if (line === '.') {
|
||||||
|
this.messages.push({
|
||||||
|
envelopeFrom: state.envelopeFrom,
|
||||||
|
envelopeTo: [...state.envelopeTo],
|
||||||
|
raw: state.dataLines.join('\r\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.mode = 'command';
|
||||||
|
state.envelopeFrom = null;
|
||||||
|
state.envelopeTo = [];
|
||||||
|
state.dataLines = [];
|
||||||
|
|
||||||
|
socket.write('250 2.0.0 Message accepted\r\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dataLines.push(line.startsWith('..') ? line.slice(1) : line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCommandLine(socket: Socket, state: SmtpSessionState, line: string): void {
|
||||||
|
const [commandRaw, ...rest] = line.split(' ');
|
||||||
|
const command = commandRaw?.toUpperCase() ?? '';
|
||||||
|
const arg = rest.join(' ').trim();
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'EHLO':
|
||||||
|
case 'HELO':
|
||||||
|
socket.write('250-email-lifecycle-smtp-sink\r\n250 PIPELINING\r\n');
|
||||||
|
return;
|
||||||
|
case 'MAIL':
|
||||||
|
if (!arg.toUpperCase().startsWith('FROM:')) {
|
||||||
|
socket.write('501 5.5.2 Syntax error in MAIL FROM\r\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.envelopeFrom = parseSmtpAddress(arg.slice(5));
|
||||||
|
state.envelopeTo = [];
|
||||||
|
socket.write('250 2.1.0 Sender OK\r\n');
|
||||||
|
return;
|
||||||
|
case 'RCPT':
|
||||||
|
if (!arg.toUpperCase().startsWith('TO:')) {
|
||||||
|
socket.write('501 5.5.2 Syntax error in RCPT TO\r\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.envelopeTo.push(parseSmtpAddress(arg.slice(3)));
|
||||||
|
socket.write('250 2.1.5 Recipient OK\r\n');
|
||||||
|
return;
|
||||||
|
case 'DATA':
|
||||||
|
state.mode = 'data';
|
||||||
|
state.dataLines = [];
|
||||||
|
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||||
|
return;
|
||||||
|
case 'RSET':
|
||||||
|
state.mode = 'command';
|
||||||
|
state.envelopeFrom = null;
|
||||||
|
state.envelopeTo = [];
|
||||||
|
state.dataLines = [];
|
||||||
|
socket.write('250 2.0.0 Reset state\r\n');
|
||||||
|
return;
|
||||||
|
case 'NOOP':
|
||||||
|
socket.write('250 2.0.0 OK\r\n');
|
||||||
|
return;
|
||||||
|
case 'QUIT':
|
||||||
|
socket.write('221 2.0.0 Bye\r\n');
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
case 'STARTTLS':
|
||||||
|
socket.write('454 4.7.0 TLS not available\r\n');
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
socket.write('250 2.0.0 OK\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeader(token: string): Record<string, string> {
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmailE2EContext(): Promise<EmailE2EContext> {
|
||||||
|
const fixture = await createEmailFixtureRoot();
|
||||||
|
const envSnapshot: EnvSnapshot = {
|
||||||
|
booksPath: process.env.BOOKS_PATH,
|
||||||
|
stagingPath: process.env.STAGING_PATH,
|
||||||
|
appUrl: process.env.APP_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.BOOKS_PATH = fixture.booksPath;
|
||||||
|
process.env.STAGING_PATH = fixture.stagingPath;
|
||||||
|
process.env.APP_URL = 'http://localhost:4173';
|
||||||
|
|
||||||
|
const smtpSink = new InProcessSmtpSink();
|
||||||
|
await smtpSink.start();
|
||||||
|
|
||||||
|
const moduleFixture = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
})
|
||||||
|
.overrideProvider(MetadataService)
|
||||||
|
.useValue(makeMetadataNoopMock())
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.register(fastifyCookie as never);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
await stopStagingWatcher(app);
|
||||||
|
|
||||||
|
const db = app.get<Db>(DB);
|
||||||
|
const admin = await getAdminSession(app, db);
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
fixture,
|
||||||
|
envSnapshot,
|
||||||
|
smtpSink,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeEmailE2EContext(ctx: EmailE2EContext): Promise<void> {
|
||||||
|
await ctx.app.close();
|
||||||
|
await ctx.smtpSink.stop();
|
||||||
|
await ctx.fixture.cleanup();
|
||||||
|
restoreEnv(ctx.envSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLibraryWithFolder(
|
||||||
|
ctx: EmailE2EContext,
|
||||||
|
options: {
|
||||||
|
mode?: 'book_per_file' | 'book_per_folder';
|
||||||
|
allowedFormats?: string[];
|
||||||
|
name?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<CreatedLibrary> {
|
||||||
|
const folderPath = `${ctx.fixture.booksPath}/library-${randomUUID()}`;
|
||||||
|
await mkdir(folderPath, { recursive: true });
|
||||||
|
|
||||||
|
const [library] = await ctx.db
|
||||||
|
.insert(schema.libraries)
|
||||||
|
.values({
|
||||||
|
name: options.name ?? `email-e2e-library-${randomUUID()}`,
|
||||||
|
watch: false,
|
||||||
|
organizationMode: options.mode ?? 'book_per_file',
|
||||||
|
allowedFormats: options.allowedFormats ?? [],
|
||||||
|
excludePatterns: [],
|
||||||
|
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
|
||||||
|
})
|
||||||
|
.returning({ id: schema.libraries.id });
|
||||||
|
|
||||||
|
const [libraryFolder] = await ctx.db
|
||||||
|
.insert(schema.libraryFolders)
|
||||||
|
.values({
|
||||||
|
libraryId: library.id,
|
||||||
|
path: folderPath,
|
||||||
|
})
|
||||||
|
.returning({ id: schema.libraryFolders.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
folderPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerAndWaitForLibraryScan(
|
||||||
|
ctx: EmailE2EContext,
|
||||||
|
libraryId: number,
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
): Promise<typeof schema.scanJobs.$inferSelect> {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
|
||||||
|
headers: authHeader(ctx.admin.accessToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode !== 202) {
|
||||||
|
throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = response.json() as { jobId?: number };
|
||||||
|
if (!body.jobId) {
|
||||||
|
throw new Error(`Scan endpoint returned no jobId: ${response.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitForScanCompletion(ctx.db, body.jobId, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function locateBookByAbsolutePath(ctx: EmailE2EContext, absolutePath: string): Promise<LocatedBookFile> {
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select({
|
||||||
|
libraryId: schema.books.libraryId,
|
||||||
|
bookId: schema.books.id,
|
||||||
|
bookFileId: schema.bookFiles.id,
|
||||||
|
absolutePath: schema.bookFiles.absolutePath,
|
||||||
|
relPath: schema.bookFiles.relPath,
|
||||||
|
format: schema.bookFiles.format,
|
||||||
|
})
|
||||||
|
.from(schema.bookFiles)
|
||||||
|
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||||
|
.where(and(eq(schema.books.status, 'present'), eq(schema.bookFiles.absolutePath, absolutePath)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(`No present book file found for path "${absolutePath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserAndLogin(
|
||||||
|
ctx: EmailE2EContext,
|
||||||
|
options: {
|
||||||
|
permissions?: Permission[];
|
||||||
|
isSuperuser?: boolean;
|
||||||
|
isDefaultPassword?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
email?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestUserSession> {
|
||||||
|
const suffix = randomUUID().replaceAll('-', '');
|
||||||
|
const username = options.username ?? `email-e2e-user-${suffix}`;
|
||||||
|
const password = options.password ?? 'EmailE2EUser123';
|
||||||
|
const email = options.email ?? `${username}@example.com`;
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
username,
|
||||||
|
name: `Email E2E User ${suffix}`,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
active: options.active ?? true,
|
||||||
|
isSuperuser: options.isSuperuser ?? false,
|
||||||
|
isDefaultPassword: options.isDefaultPassword ?? false,
|
||||||
|
provisioningMethod: 'local',
|
||||||
|
})
|
||||||
|
.returning({ id: schema.users.id });
|
||||||
|
|
||||||
|
const permissions = options.permissions ?? [];
|
||||||
|
if (permissions.length > 0) {
|
||||||
|
await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId: created.id, permissionName })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await loginForToken(ctx.app, username, password);
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error(`Login failed for ${username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: created.id,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantLibraryAccess(
|
||||||
|
ctx: EmailE2EContext,
|
||||||
|
userId: number,
|
||||||
|
libraryId: number,
|
||||||
|
accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer',
|
||||||
|
): Promise<void> {
|
||||||
|
await ctx.db
|
||||||
|
.insert(schema.userLibraryAccess)
|
||||||
|
.values({ userId, libraryId, accessLevel })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.userLibraryAccess.userId, schema.userLibraryAccess.libraryId],
|
||||||
|
set: { accessLevel },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetEmailModuleState(ctx: EmailE2EContext): Promise<void> {
|
||||||
|
await ctx.db.delete(schema.emailSendLog);
|
||||||
|
await ctx.db.delete(schema.emailPreferences);
|
||||||
|
await ctx.db.delete(schema.emailRecipientGroupMembers);
|
||||||
|
await ctx.db.delete(schema.emailRecipientGroups);
|
||||||
|
await ctx.db.delete(schema.emailRecipients);
|
||||||
|
await ctx.db.delete(schema.emailProviders);
|
||||||
|
await ctx.db.delete(schema.emailTemplates).where(isNotNull(schema.emailTemplates.userId));
|
||||||
|
ctx.smtpSink.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedBookMetadataContext(
|
||||||
|
ctx: EmailE2EContext,
|
||||||
|
input: {
|
||||||
|
bookId: number;
|
||||||
|
title: string;
|
||||||
|
seriesName?: string;
|
||||||
|
seriesIndex?: number;
|
||||||
|
authorNames: string[];
|
||||||
|
tagNames?: string[];
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const metadataPatch = {
|
||||||
|
title: input.title,
|
||||||
|
seriesName: input.seriesName ?? null,
|
||||||
|
seriesIndex: input.seriesIndex ?? null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.insert(schema.bookMetadata)
|
||||||
|
.values({
|
||||||
|
bookId: input.bookId,
|
||||||
|
...metadataPatch,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.bookMetadata.bookId,
|
||||||
|
set: metadataPatch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.delete(schema.bookAuthors).where(eq(schema.bookAuthors.bookId, input.bookId));
|
||||||
|
const authorIds: number[] = [];
|
||||||
|
for (const authorName of input.authorNames) {
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(schema.authors)
|
||||||
|
.values({ name: authorName, sortName: authorName })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: schema.authors.id });
|
||||||
|
|
||||||
|
const authorId =
|
||||||
|
inserted?.id ??
|
||||||
|
(await ctx.db.select({ id: schema.authors.id }).from(schema.authors).where(eq(schema.authors.name, authorName)).limit(1))[0]?.id;
|
||||||
|
|
||||||
|
if (!authorId) {
|
||||||
|
throw new Error(`Unable to resolve author id for "${authorName}"`);
|
||||||
|
}
|
||||||
|
authorIds.push(authorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorIds.length > 0) {
|
||||||
|
await ctx.db.insert(schema.bookAuthors).values(
|
||||||
|
authorIds.map((authorId, index) => ({
|
||||||
|
bookId: input.bookId,
|
||||||
|
authorId,
|
||||||
|
displayOrder: index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(schema.bookTags).where(eq(schema.bookTags.bookId, input.bookId));
|
||||||
|
const tagNames = [...new Set(input.tagNames ?? [])];
|
||||||
|
if (tagNames.length === 0) return;
|
||||||
|
|
||||||
|
const [existingTags, insertedTags] = await Promise.all([
|
||||||
|
ctx.db.select({ id: schema.tags.id, name: schema.tags.name }).from(schema.tags).where(inArray(schema.tags.name, tagNames)),
|
||||||
|
ctx.db
|
||||||
|
.insert(schema.tags)
|
||||||
|
.values(tagNames.map((name) => ({ name })))
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: schema.tags.id, name: schema.tags.name }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tagIdByName = new Map<string, number>();
|
||||||
|
for (const row of existingTags) {
|
||||||
|
tagIdByName.set(row.name, row.id);
|
||||||
|
}
|
||||||
|
for (const row of insertedTags) {
|
||||||
|
tagIdByName.set(row.name, row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert(schema.bookTags).values(
|
||||||
|
tagNames.map((name) => {
|
||||||
|
const tagId = tagIdByName.get(name);
|
||||||
|
if (!tagId) {
|
||||||
|
throw new Error(`Unable to resolve tag id for "${name}"`);
|
||||||
|
}
|
||||||
|
return { bookId: input.bookId, tagId };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 15_000, pollMs = 100): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await check();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMetadataNoopMock(): Pick<
|
||||||
|
MetadataService,
|
||||||
|
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
extractAndSave: () => Promise.resolve(undefined),
|
||||||
|
refreshCoverForBook: () => Promise.resolve(false),
|
||||||
|
extractAudioFileDuration: () => Promise.resolve(undefined),
|
||||||
|
aggregateAudioDuration: () => Promise.resolve(undefined),
|
||||||
|
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
|
||||||
|
const watcher = app.get(StagingWatcherService);
|
||||||
|
await watcher.onModuleDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminSession(app: NestFastifyApplication, db: Db): Promise<TestUserSession> {
|
||||||
|
const setupResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/setup',
|
||||||
|
payload: ADMIN_SETUP_DTO,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (setupResponse.statusCode === 201) {
|
||||||
|
const body = setupResponse.json() as { accessToken?: string };
|
||||||
|
if (!body.accessToken) throw new Error('Setup succeeded but accessToken is missing');
|
||||||
|
const [admin] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, ADMIN_SETUP_DTO.username)).limit(1);
|
||||||
|
if (!admin) throw new Error('Setup succeeded but admin user was not created');
|
||||||
|
return {
|
||||||
|
userId: admin.id,
|
||||||
|
username: ADMIN_SETUP_DTO.username,
|
||||||
|
password: ADMIN_SETUP_DTO.password,
|
||||||
|
accessToken: body.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupResponse.statusCode === 409) {
|
||||||
|
const existingToken = await loginForToken(app, ADMIN_SETUP_DTO.username, ADMIN_SETUP_DTO.password);
|
||||||
|
if (existingToken) {
|
||||||
|
const [admin] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, ADMIN_SETUP_DTO.username)).limit(1);
|
||||||
|
if (!admin) throw new Error('Admin login succeeded but admin row is missing');
|
||||||
|
return {
|
||||||
|
userId: admin.id,
|
||||||
|
username: ADMIN_SETUP_DTO.username,
|
||||||
|
password: ADMIN_SETUP_DTO.password,
|
||||||
|
accessToken: existingToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = randomUUID().replaceAll('-', '');
|
||||||
|
const fallbackUsername = `email-lifecycle-e2e-admin-${suffix}`;
|
||||||
|
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
|
||||||
|
|
||||||
|
const [fallbackAdmin] = await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
username: fallbackUsername,
|
||||||
|
name: 'Email Lifecycle E2E Admin',
|
||||||
|
email: `${fallbackUsername}@example.com`,
|
||||||
|
passwordHash,
|
||||||
|
isSuperuser: true,
|
||||||
|
isDefaultPassword: false,
|
||||||
|
provisioningMethod: 'local',
|
||||||
|
})
|
||||||
|
.returning({ id: schema.users.id });
|
||||||
|
|
||||||
|
const fallbackToken = await loginForToken(app, fallbackUsername, ADMIN_SETUP_DTO.password);
|
||||||
|
if (!fallbackToken) {
|
||||||
|
throw new Error('Setup is already complete and fallback admin login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: fallbackAdmin.id,
|
||||||
|
username: fallbackUsername,
|
||||||
|
password: ADMIN_SETUP_DTO.password,
|
||||||
|
accessToken: fallbackToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to complete setup: ${setupResponse.statusCode} ${setupResponse.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise<string | null> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { username, password },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200) return null;
|
||||||
|
const body = response.json() as { accessToken?: string };
|
||||||
|
return body.accessToken ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(snapshot: EnvSnapshot): void {
|
||||||
|
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
|
||||||
|
else process.env.BOOKS_PATH = snapshot.booksPath;
|
||||||
|
|
||||||
|
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
|
||||||
|
else process.env.STAGING_PATH = snapshot.stagingPath;
|
||||||
|
|
||||||
|
if (snapshot.appUrl === undefined) delete process.env.APP_URL;
|
||||||
|
else process.env.APP_URL = snapshot.appUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,915 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { mkdir, writeFile } from 'fs/promises';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { Permission } from '@projectx/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;
|
||||||
|
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,
|
||||||
|
...(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 managerToggleShare = await apiAs(managerOnly, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
|
||||||
|
expectError(managerToggleShare, 403, 'Only superusers can share providers');
|
||||||
|
|
||||||
|
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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'ProjectX 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('ProjectX Bot <bot@example.com>');
|
||||||
|
expect(subjectHeader).toBe('Override Email Lifecycle Book');
|
||||||
|
expect(subjectHeader).not.toContain('Recipient');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user