ci: overhaul CI workflows for reliability and maintainability

- add CODEOWNERS assigning .github/ to @neonsolstice
- configure automated updates for npm, docker, and github-actions ecosystems
- replace custom change-detection bash script with dorny/paths-filter@v4
- consolidate lint, typecheck, and test into a single checks matrix job
- gate per-package change detection via has-changes step (matrix unavailable in job-level if)
- fix duplicate actions/setup-node call in setup-node-pnpm composite action
- scope pnpm store cache key by pnpm version to prevent stale cross-version cache hits
- pin all action references to commit SHAs
- fix container-image concurrency group to use commit SHA across all trigger types
- add timeout-minutes to all jobs
- document fail-fast: false rationale and checks: write trade-off
This commit is contained in:
neon
2026-03-07 18:14:36 -06:00
parent f911c552a7
commit a3579ac57f
72 changed files with 1290 additions and 812 deletions
+1
View File
@@ -3,6 +3,7 @@
**/dist
**/*.log
.env
.env.prod
client/.env
server/.env
docker/postgres/data
+31
View File
@@ -0,0 +1,31 @@
# Immutable image reference built by CI (use a sha-* tag or @sha256 digest)
APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
# App publish port on host
APP_PORT=3000
# Database credentials
POSTGRES_USER=projectx
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=projectx
# App secrets
JWT_SECRET=change-this-to-a-long-random-secret
EMAIL_ENCRYPTION_KEY=
# External/public URL used by emails and Kobo endpoints
APP_URL=https://projectx.example.com
# Optional client URL override
CLIENT_URL=https://projectx.example.com
# Optional SMTP config
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@projectx.local
# Optional API keys
GOOGLE_BOOKS_API_KEY=
+1
View File
@@ -0,0 +1 @@
/.github/ @neonsolstice
@@ -0,0 +1,60 @@
name: Setup Node and pnpm
description: Set up Node.js, activate pnpm via Corepack, and optionally install dependencies.
inputs:
node-version:
description: Node.js version to install.
required: false
default: "24"
pnpm-version:
description: pnpm version to activate via Corepack.
required: false
default: "10"
install:
description: Whether to run pnpm install.
required: false
default: "true"
cache:
description: Whether to enable pnpm dependency caching.
required: false
default: "true"
cache-dependency-path:
description: Path(s) used to generate the cache key.
required: false
default: "pnpm-lock.yaml"
runs:
using: composite
steps:
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ inputs.node-version }}
- name: Enable Corepack and activate pnpm
shell: bash
run: |
corepack enable
corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate
# pnpm must be on PATH before its store path can be resolved for cache keying,
# so caching is configured in a separate step after Corepack activates pnpm.
- name: Get pnpm store directory
if: ${{ inputs.cache == 'true' }}
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: ${{ inputs.cache == 'true' }}
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-${{ inputs.pnpm-version }}-store-${{ hashFiles(inputs.cache-dependency-path) }}
restore-keys: |
${{ runner.os }}-pnpm-${{ inputs.pnpm-version }}-store-
- name: Install dependencies
if: ${{ inputs.install == 'true' }}
shell: bash
run: pnpm install --frozen-lockfile
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
versioning-strategy: "auto"
groups:
production-dependencies:
dependency-type: "production"
update-types: ["minor", "patch"]
development-dependencies:
dependency-type: "development"
update-types: ["minor", "patch"]
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns: ["*"]
+218
View File
@@ -0,0 +1,218 @@
name: CI
run-name: >-
${{ (github.event_name == 'push' && format('CI - {0} ({1})', github.event.head_commit.message, github.ref_name)) || (github.event_name == 'pull_request' && format('CI - PR #{0}: {1}', github.event.pull_request.number, github.event.pull_request.title)) || 'CI - manual' }}
on:
workflow_dispatch:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
PNPM_VERSION: "10"
jobs:
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Dependency review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
detect-changes:
name: Detect Changed Paths
runs-on: ubuntu-latest
needs: [dependency-review]
timeout-minutes: 10
if: always() && (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped')
outputs:
server: ${{ steps.manual.outputs.server || steps.changes.outputs.server }}
client: ${{ steps.manual.outputs.client || steps.changes.outputs.client }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Manual dispatch runs all scopes
id: manual
if: github.event_name == 'workflow_dispatch'
shell: bash
run: |
echo "server=true" >> "$GITHUB_OUTPUT"
echo "client=true" >> "$GITHUB_OUTPUT"
# For push events dorny/paths-filter diffs github.event.before..github.sha.
# New-branch pushes (before = 0000...) are handled by treating all files as changed.
# Force-push edge cases (before SHA no longer reachable) may mark all files changed too.
- name: Compute changed scopes
id: changes
if: github.event_name != 'workflow_dispatch'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with:
filters: |
server:
- 'server/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/ci.yml'
client:
- 'client/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/ci.yml'
- name: Print change scope
shell: bash
run: |
echo "server=${{ steps.manual.outputs.server || steps.changes.outputs.server }}"
echo "client=${{ steps.manual.outputs.client || steps.changes.outputs.client }}"
checks:
name: ${{ matrix.check }} (${{ matrix.package }})
runs-on: ubuntu-latest
needs: [dependency-review, detect-changes]
timeout-minutes: 20
# matrix context is not available in job-level if; per-package change filtering
# is done via the has-changes gate step below.
if: >
always() &&
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
needs.detect-changes.result == 'success'
strategy:
# All matrix cells run independently so all failures are visible in one pass.
fail-fast: false
matrix:
package: [server, client]
check: [lint, typecheck, test]
permissions:
contents: read
# checks: write is only used by publish-unit-test-result-action in test matrix cells.
# Lint and typecheck cells receive this permission too due to the shared job definition.
checks: write
steps:
- name: Check for package changes
id: has-changes
shell: bash
env:
PACKAGE: ${{ matrix.package }}
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
run: |
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.has-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
if: steps.has-changes.outputs.run == 'true'
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Run lint
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'lint'
shell: bash
run: pnpm --filter "${{ matrix.package }}" lint:check
- name: Run typecheck
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'typecheck'
shell: bash
env:
PACKAGE: ${{ matrix.package }}
run: |
if [[ "$PACKAGE" == "server" ]]; then
pnpm --filter server type-check
else
pnpm --filter @projectx/types build
pnpm run typecheck:client:baseline
fi
- name: Prepare test report directory
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'test'
shell: bash
run: mkdir -p "$GITHUB_WORKSPACE/test-results/${{ matrix.package }}"
- name: Run tests
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'test'
shell: bash
env:
PACKAGE: ${{ matrix.package }}
JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml
run: |
if [[ "$PACKAGE" == "server" ]]; then
JEST_JUNIT_OUTPUT_FILE="$JUNIT_OUTPUT" pnpm --filter server exec jest --ci --reporters=default --reporters=jest-junit
else
pnpm --filter client exec vitest --run --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT"
fi
- name: Publish test report annotations
if: >
always() &&
steps.has-changes.outputs.run == 'true' &&
matrix.check == 'test' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: ${{ matrix.package == 'server' && 'Server Unit Tests' || 'Client Unit Tests' }}
files: ${{ format('test-results/{0}/junit.xml', matrix.package) }}
fail_on: nothing
comment_mode: off
- name: Upload test report artifact
if: >
always() &&
steps.has-changes.outputs.run == 'true' &&
matrix.check == 'test' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: test-report-${{ matrix.package }}
path: test-results/${{ matrix.package }}
retention-days: 7
quality-gate:
name: Quality Gate
runs-on: ubuntu-latest
needs: [dependency-review, detect-changes, checks]
timeout-minutes: 5
if: always()
steps:
- name: Fail if any quality job failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "One or more quality jobs failed."
exit 1
- name: Quality checks complete
run: |
echo "dependency-review=${{ needs.dependency-review.result }}"
echo "detect-changes=${{ needs.detect-changes.result }}"
echo "checks=${{ needs.checks.result }}"
+120
View File
@@ -0,0 +1,120 @@
name: Container Image
run-name: ${{ github.event_name == 'workflow_run' && format('Image - {0} ({1})', github.event.workflow_run.head_commit.message, github.event.workflow_run.head_branch) || format('Image - {0}', github.ref_name) }}
on:
workflow_dispatch:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
push:
tags:
- "v*"
concurrency:
# Use the commit SHA across all trigger types so a workflow_run completion and a
# simultaneous tag push for the same commit share one concurrency slot and cancel correctly.
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
cancel-in-progress: true
permissions:
actions: read
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
image:
name: Build and Push Image
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- name: Resolve source and image tags
id: vars
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
GH_REF: ${{ github.ref }}
GH_REF_NAME: ${{ github.ref_name }}
GH_SHA: ${{ github.sha }}
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
# SHA resolution mirrors the concurrency group logic at the top of this workflow.
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then
echo "workflow_dispatch is only supported from main."
exit 1
fi
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
SHA="$WORKFLOW_RUN_HEAD_SHA"
else
SHA="$GH_SHA"
fi
SHORT_SHA="${SHA:0:12}"
IMAGE="$REGISTRY/$IMAGE_NAME"
PRIMARY_TAG="$GH_REF_NAME"
echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:${PRIMARY_TAG}" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.vars.outputs.checkout_ref }}
- name: Verify CI succeeded for tag commit
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMMIT_SHA: ${{ github.sha }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
echo "Checking CI status for commit ${COMMIT_SHA}..."
success_count=$(gh api --paginate \
"repos/${GH_REPO}/actions/workflows/ci.yml/runs?head_sha=${COMMIT_SHA}&status=completed" \
--jq '[.workflow_runs[] | select(.conclusion == "success")] | length' \
| awk '{sum += $1} END {print sum}')
if [[ "${success_count:-0}" -lt 1 ]]; then
echo "No successful CI run found for tag commit ${COMMIT_SHA}. Refusing to publish image."
exit 1
fi
echo "CI verification passed: ${success_count} successful run(s) found."
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
id: build_push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.vars.outputs.tags }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/
.env
.env.local
.env.prod
client/.env
server/.env
*.local
+21 -11
View File
@@ -1,5 +1,7 @@
ARG NODE_IMAGE=node:24.11-alpine
# Stage 1: Build client
FROM node:24-alpine AS client-builder
FROM ${NODE_IMAGE} AS client-builder
RUN npm install -g pnpm@10
WORKDIR /app
@@ -11,8 +13,8 @@ RUN pnpm install --filter client... --frozen-lockfile
COPY client/ ./client/
RUN pnpm --filter client run build-only
# Stage 2: Build server + create prod deploy bundle
FROM node:24-alpine AS server-builder
# Stage 2: Build server + create deploy bundle
FROM ${NODE_IMAGE} AS server-builder
RUN npm install -g pnpm@10
WORKDIR /app
@@ -24,21 +26,29 @@ RUN pnpm install --filter server... --frozen-lockfile
COPY server/ ./server/
RUN pnpm --filter server run build
# pnpm deploy prunes to prod deps; dist/ is gitignored so copy it in after
# pnpm deploy prunes to prod deps; dist/ is gitignored so copy it in after.
RUN pnpm --filter server deploy --prod --legacy /deploy
RUN cp -r /app/server/dist /deploy/dist
RUN mkdir -p /deploy/migrations && cp -r /app/server/src/db/migrations/. /deploy/migrations/
# Stage 3: Final image
FROM node:24-alpine
# Stage 3: Runtime image
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
COPY --from=server-builder /deploy ./
COPY --from=client-builder /app/client/dist ./public
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh && mkdir -p /books /tmp && chown -R node:node /books /tmp
USER node
EXPOSE 3000
ENV NODE_ENV=production
COPY --from=server-builder /app/server/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["sh", "entrypoint.sh"]
+38 -5
View File
@@ -72,8 +72,12 @@ projectx/
│ └── types/ Shared TypeScript types (@projectx/types)
├── docker/
│ └── postgres/ Postgres init scripts (extensions)
├── docs/
│ └── production.md Production deploy/backup/restore runbook
├── local/ Local dev data (covers, docs) - gitignored
├── docker-compose.yml Dev: runs Postgres only (app runs natively)
├── docker-compose.prod.yml Prod: app + Postgres + migration job
├── .env.prod.example Template env file for production compose
├── Dockerfile Production multi-stage build
└── Dockerfile.dev Runs entire stack in Docker (not used for normal dev)
```
@@ -133,6 +137,16 @@ All commands run from the **repo root** unless noted otherwise.
| `pnpm --filter server db:generate <name>` | Generate a migration from schema changes |
| `pnpm --filter server db:studio` | Open Drizzle Studio (DB browser) |
### Production operations
| Command | Description |
| ------------------------------------------ | -------------------------------------------- |
| `pnpm prod:up` | Start/update production compose stack |
| `pnpm prod:down` | Stop production compose stack |
| `pnpm db:backup:prod` | Create Postgres backup (`local/backups/`) |
| `pnpm db:restore:prod -- <file.dump>` | Restore backup into production database |
| `pnpm db:restore:test:prod -- <file.dump>` | Restore into temp DB to validate backup file |
### Linting & formatting
| Command | Description |
@@ -215,6 +229,24 @@ Server environment is configured in `server/.env` (created from `.env.example` d
| `SMTP_*` | (empty) | Optional SMTP config for password-reset emails |
| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) |
## Production Deploy
Production uses a separate compose file and env file:
- Compose: `docker-compose.prod.yml`
- Env file: `.env.prod` (from `.env.prod.example`)
- Image source: immutable images built in CI (`.github/workflows/container-image.yml`)
Quick start:
```bash
cp .env.prod.example .env.prod
# edit .env.prod and set APP_IMAGE + secrets
pnpm prod:up
```
Detailed runbook (deploy, backup, restore, restore drills): [docs/production.md](docs/production.md)
---
## Database Workflow
@@ -325,8 +357,9 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000)
## Further Reading
| Doc | What it covers |
| -------------------------------------- | ------------------------------------------------------------ |
| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions |
| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions |
| [`packages/types/`](packages/types/) | Shared type definitions between server and client |
| Doc | What it covers |
| ------------------------------------------ | ------------------------------------------------------------ |
| [`docs/production.md`](docs/production.md) | Production compose deployment, backups, restore testing |
| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions |
| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions |
| [`packages/types/`](packages/types/) | Shared type definitions between server and client |
+7 -7
View File
@@ -39,31 +39,31 @@
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"vue-sonner": "^2.0.9",
"vue-virtual-scroller": "^2.0.0-beta.8"
"vue-virtual-scroller": "^1.1.2"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.0",
"@types/node": "^24.11.0",
"@types/node": "^25.3.5",
"@vitejs/plugin-vue": "^6.0.4",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^10.0.2",
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.50.0",
"eslint-plugin-oxlint": "~1.51.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.50.0",
"oxlint": "~1.51.0",
"prettier": "3.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.6",
"vite-plugin-vue-devtools": "^8.0.7",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.5"
},
@@ -5,10 +5,10 @@ import type { BookCard } from '@projectx/types'
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn() }) }
return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
@@ -5,10 +5,10 @@ import type { BookCard } from '@projectx/types'
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn() }) }
return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
})
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
@@ -5,16 +5,25 @@ import type { BookDetail } from '@projectx/types'
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return { ...actual, useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }
return {
...actual,
useRouter: () => ({
push: vi.fn<(...args: unknown[]) => unknown>(),
replace: vi.fn<(...args: unknown[]) => unknown>(),
}),
}
})
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => false }),
}))
vi.mock('@/lib/api', () => ({
api: vi.fn(async () => ({ ok: false, json: async () => ({}) })),
api: vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<Record<string, never>> }>>(async () => ({
ok: false,
json: async () => ({}),
})),
}))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
@@ -13,7 +13,7 @@ vi.mock('@/features/scanner/composables/useScanProgress', () => ({
}))
vi.mock('vue-sonner', () => ({
toast: { warning: vi.fn() },
toast: { warning: vi.fn<(message: string) => void>() },
}))
// Import the composable after mocks are in place.
@@ -42,7 +42,7 @@ afterEach(() => {
describe('onBookMissing', () => {
it('fires the callback with the bookIds array on book:missing event', () => {
const { onBookMissing } = useBookEvents()
const cb = vi.fn()
const cb = vi.fn<(bookIds: number[]) => void>()
const cleanup = onBookMissing(cb)
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [10, 20, 30] })
@@ -54,7 +54,7 @@ describe('onBookMissing', () => {
it('cleanup function deregisters the callback', () => {
const { onBookMissing } = useBookEvents()
const cb = vi.fn()
const cb = vi.fn<(bookIds: number[]) => void>()
const cleanup = onBookMissing(cb)
cleanup()
@@ -10,6 +10,9 @@ export interface OutlineItem {
expanded: boolean
}
type PdfPageRef = Parameters<pdfjsLib.PDFDocumentProxy['getPageIndex']>[0]
type PdfOutline = NonNullable<Awaited<ReturnType<pdfjsLib.PDFDocumentProxy['getOutline']>>>
export function usePdfOutline(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy | null>) {
const outline = ref<OutlineItem[]>([])
const loading = ref(false)
@@ -19,14 +22,14 @@ export function usePdfOutline(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy | nul
try {
const resolved = typeof dest === 'string' ? await doc.getDestination(dest) : (dest as unknown[])
if (!resolved || !resolved[0]) return null
const pageIndex = await doc.getPageIndex(resolved[0] as pdfjsLib.RefProxy)
const pageIndex = await doc.getPageIndex(resolved[0] as PdfPageRef)
return pageIndex + 1
} catch {
return null
}
}
async function buildTree(doc: pdfjsLib.PDFDocumentProxy, raw: pdfjsLib.OutlineNode[]): Promise<OutlineItem[]> {
async function buildTree(doc: pdfjsLib.PDFDocumentProxy, raw: PdfOutline): Promise<OutlineItem[]> {
const items: OutlineItem[] = []
for (const node of raw) {
const pageNum = await resolvePageNum(doc, node.dest)
@@ -59,4 +62,3 @@ export function usePdfOutline(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy | nul
return { outline, loading, load, toggleExpand }
}
+85 -13
View File
@@ -1,33 +1,105 @@
name: projectx
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
postgres:
image: postgres:16-alpine
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER?POSTGRES_USER is required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB?POSTGRES_DB is required}
volumes:
- postgres_data:/var/lib/postgresql/data
# Not exposed externally in prod
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER?POSTGRES_USER is required} -d ${POSTGRES_DB?POSTGRES_DB is required}",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
logging: *default-logging
migrate:
image: ${APP_IMAGE?APP_IMAGE is required}
restart: "no"
init: true
environment:
DATABASE_URL: postgres://${POSTGRES_USER?POSTGRES_USER is required}:${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB?POSTGRES_DB is required}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
command: ["node", "dist/scripts/migrate.js"]
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging: *default-logging
app:
build:
context: .
dockerfile: Dockerfile
image: ${APP_IMAGE?APP_IMAGE is required}
restart: unless-stopped
init: true
ports:
- "3000:3000"
- "${APP_PORT:-3000}:3000"
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
DATABASE_URL: postgres://${POSTGRES_USER?POSTGRES_USER is required}:${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB?POSTGRES_DB is required}
JWT_SECRET: ${JWT_SECRET?JWT_SECRET is required}
APP_URL: ${APP_URL?APP_URL is required}
NODE_ENV: production
env_file:
- ./server/.env
BOOKS_PATH: /books
STAGING_PATH: /books/staging
PORT: 3000
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
CLIENT_URL: ${CLIENT_URL:-http://localhost:5173}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-noreply@projectx.local}
EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:-}
GOOGLE_BOOKS_API_KEY: ${GOOGLE_BOOKS_API_KEY:-}
depends_on:
- postgres
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
- books_data:/books
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
healthcheck:
test:
[
"CMD-SHELL",
'node -e "fetch(''http://127.0.0.1:3000/api/v1/health'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging: *default-logging
volumes:
postgres_data:
+86
View File
@@ -0,0 +1,86 @@
# Production Deployment (Docker Compose)
This project's production Compose stack is intentionally separate from local development.
## 1) Configure production environment
Create `.env.prod` from the example and set real values:
```bash
cp .env.prod.example .env.prod
```
Required fields:
- `APP_IMAGE` (immutable tag or digest from CI, for example `ghcr.io/<owner>/<repo>:sha-<commit>`)
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `POSTGRES_DB`
- `JWT_SECRET`
- `APP_URL`
## 2) Deploy
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait
```
What happens:
- `postgres` starts with `pgvector` enabled via init SQL.
- `migrate` runs once (`node dist/scripts/migrate.js`) and must complete successfully.
- `app` starts only after DB health is `healthy` and migration service exits successfully.
## 3) Roll forward
1. Update `APP_IMAGE` in `.env.prod` to the new immutable image.
2. Pull + restart:
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait
```
## 4) Backup strategy
Create a compressed custom-format Postgres backup:
```bash
pnpm db:backup:prod
```
By default this writes to `local/backups/`.
Recommended cadence:
- Daily backups
- Keep at least 7 daily + 4 weekly + 3 monthly snapshots
- Store a copy off-host
## 5) Restore strategy
Restore into the live DB:
```bash
pnpm db:restore:prod -- local/backups/<file>.dump
```
Run a non-destructive restore test into a temporary DB:
```bash
pnpm db:restore:test:prod -- local/backups/<file>.dump
```
Run the restore test at least monthly so backup integrity is continuously verified.
## 6) CI image pipeline
Workflow: `.github/workflows/container-image.yml`
On `main` and tag pushes it:
- builds the Docker image
- pushes immutable tags to GHCR (`sha-*`)
- scans the image with Trivy (high/critical)
- generates an SPDX SBOM artifact
+7 -2
View File
@@ -34,6 +34,11 @@
"coverage:server": "pnpm --filter server test:cov",
"coverage:client": "pnpm --filter client test:cov --run",
"db:up": "docker compose up -d --wait",
"prod:up": "docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait",
"prod:down": "docker compose --env-file .env.prod -f docker-compose.prod.yml down",
"db:backup:prod": "bash ./scripts/db/backup-prod.sh .env.prod",
"db:restore:prod": "bash ./scripts/db/restore-prod.sh .env.prod",
"db:restore:test:prod": "bash ./scripts/db/restore-test-prod.sh .env.prod",
"db:migrate": "pnpm --filter server db:migrate",
"db:reset": "bash ./scripts/db/reset.sh",
"db:seed": "bash ./scripts/db/seed.sh",
@@ -46,8 +51,8 @@
"devDependencies": {
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"prettier": "^3.6.2"
"lint-staged": "^16.3.2",
"prettier": "^3.8.1"
},
"lint-staged": {
"*.{md,json,yml,yaml}": [
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.9.3"
},
"scripts": {
"build": "tsc"
+1
View File
@@ -5,6 +5,7 @@
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"rootDir": "./src",
"outDir": "./dist",
"strict": false,
"skipLibCheck": true
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
OUT_DIR="${2:-$ROOT_DIR/local/backups}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
mkdir -p "$OUT_DIR"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_file="$OUT_DIR/projectx-${POSTGRES_DB}-${timestamp}.dump"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc >"$backup_file"
echo "Backup created: $backup_file"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
BACKUP_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then
echo "Usage: $0 <env-file> <backup-file.dump>" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$POSTGRES_DB' AND pid <> pg_backend_pid();" >/dev/null
cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges
echo "Restore complete: $BACKUP_FILE -> $POSTGRES_DB"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
BACKUP_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then
echo "Usage: $0 <env-file> <backup-file.dump>" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
test_db="${POSTGRES_DB}_restore_test_$(date -u +%Y%m%d%H%M%S)"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE \"$test_db\";" >/dev/null
cleanup() {
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$test_db\";" >/dev/null || true
}
trap cleanup EXIT
cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_restore -U "$POSTGRES_USER" -d "$test_db" --no-owner --no-privileges >/dev/null
table_count="$(
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$test_db" -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';"
)"
echo "Restore test succeeded: $BACKUP_FILE -> $test_db (${table_count} public tables)"
+30 -26
View File
@@ -6,6 +6,7 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "pnpm --filter @projectx/types build",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -15,10 +16,12 @@
"start:prod": "node dist/main",
"db:generate": "drizzle-kit generate --name",
"db:migrate": "drizzle-kit migrate",
"db:migrate:runtime": "node dist/scripts/migrate.js",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/scripts/db-seed.ts",
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
"sim:missing": "ts-node -r tsconfig-paths/register src/scripts/missing-detection-sim.ts",
"pretype-check": "pnpm --filter @projectx/types build",
"type-check": "tsc --noEmit -p tsconfig.build.json",
"lint": "pnpm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"",
@@ -38,9 +41,9 @@
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/core": "^11.1.16",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.16",
@@ -58,7 +61,7 @@
"class-validator": "^0.15.1",
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "^5.4.2",
"fastify": "^5.8.1",
"fastify": "^5.8.2",
"jose": "^6.2.0",
"ms": "^2.1.3",
"nestjs-pino": "^4.6.0",
@@ -67,50 +70,51 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
"pg": "^8.20.0",
"pg": "^8.18.0",
"pino-http": "^11.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"rxjs": "^7.8.2",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"unzipper": "^0.12.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.7.10",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.16",
"@swc/cli": "^0.8.0",
"@swc/core": "^1.15.18",
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/express": "^5.0.0",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/node": "^25.3.5",
"@types/nodemailer": "^7.0.11",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.2",
"@types/pg": "^8.18.0",
"@types/supertest": "^7.2.0",
"@types/unzipper": "^0.10.11",
"drizzle-kit": "^0.31.9",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"globals": "^17.4.0",
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.4.2",
"prettier": "^3.8.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
},
"jest": {
"moduleFileExtensions": [
@@ -65,9 +65,7 @@ describe('MailerService', () => {
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'a+b&c/d==')).resolves.toBeUndefined();
expect(createTransportMock).not.toHaveBeenCalled();
expect(logger.log).toHaveBeenCalledWith(
'[DEV] Password reset URL for Alice: http://app.local/reset-password?token=a%2Bb%26c%2Fd%3D%3D',
);
expect(logger.log).toHaveBeenCalledWith('[DEV] Password reset URL for Alice: http://app.local/reset-password?token=a%2Bb%26c%2Fd%3D%3D');
});
it('sends password reset email with expected payload and no DEV log in production', async () => {
+1 -2
View File
@@ -12,7 +12,7 @@ export const DB = Symbol('DB');
{
provide: DB,
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
useFactory: (config: ConfigService) => {
const pool = new Pool({
connectionString: config.get('db.url'),
max: 20,
@@ -20,7 +20,6 @@ export const DB = Symbol('DB');
connectionTimeoutMillis: 5_000,
statement_timeout: 30_000,
});
await pool.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
return drizzle(pool, { schema });
},
},
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,15 @@ import { emailRecipientGroupMembers, emailRecipientGroups, emailRecipients } fro
const fkByColumn = (table: unknown) => {
const config = getTableConfig(table as never);
return new Map(config.foreignKeys.map((fk) => [fk.reference().columns.map((col) => col.name).join(','), fk]));
return new Map(
config.foreignKeys.map((fk) => [
fk
.reference()
.columns.map((col) => col.name)
.join(','),
fk,
]),
);
};
describe('email-recipients schema', () => {
+3 -3
View File
@@ -84,9 +84,9 @@ export const koboReadingStates = pgTable(
statusInfo: jsonb('status_info'),
progressSyncedAt: timestamp('progress_synced_at'),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [unique().on(t.userId, t.bookId)],
);
+9 -1
View File
@@ -4,7 +4,15 @@ import { annotations, bookmarks, readerDefaultPreferences, readerPreferences, re
const fkByColumn = (table: unknown) => {
const config = getTableConfig(table as never);
return new Map(config.foreignKeys.map((fk) => [fk.reference().columns.map((col) => col.name).join(','), fk]));
return new Map(
config.foreignKeys.map((fk) => [
fk
.reference()
.columns.map((col) => col.name)
.join(','),
fk,
]),
);
};
describe('reader schema', () => {
+4 -4
View File
@@ -45,16 +45,16 @@ describe('Database Schema Logic', () => {
});
it('fromDriver should handle null/undefined safely', () => {
// @ts-ignore
// @ts-expect-error: testing invalid null input
expect(embeddingColumn.mapFromDriverValue(null)).toEqual([]);
// @ts-ignore
// @ts-expect-error: testing invalid undefined input
expect(embeddingColumn.mapFromDriverValue(undefined)).toEqual([]);
});
it('fromDriver should handle non-string inputs safely', () => {
// @ts-ignore
// @ts-expect-error: testing invalid number input
expect(embeddingColumn.mapFromDriverValue(123)).toEqual([]);
// @ts-ignore
// @ts-expect-error: testing invalid object input
expect(embeddingColumn.mapFromDriverValue({})).toEqual([]);
});
});
+6 -18
View File
@@ -7,9 +7,6 @@ import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { join } from 'path';
import { Readable } from 'stream';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import fastifyRateLimit from '@fastify/rate-limit';
@@ -20,15 +17,6 @@ import fastifyCompress from '@fastify/compress';
const MAX_COVER_BYTES = 20 * 1024 * 1024;
async function bootstrap() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 3,
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
});
await migrate(drizzle(pool), { migrationsFolder: join(__dirname, '..', 'src', 'db', 'migrations') });
await pool.end();
const adapter = new FastifyAdapter({ logger: false });
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { bufferLogs: true });
app.useLogger(app.get(Logger));
@@ -74,7 +62,7 @@ async function bootstrap() {
app.useGlobalFilters(new GlobalExceptionFilter());
await app.register(fastifyHelmet, {
await app.register(fastifyHelmet as never, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
@@ -89,15 +77,15 @@ async function bootstrap() {
},
});
await app.register(fastifyCompress, { encodings: ['gzip', 'br'] });
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });
await app.register(fastifyCookie);
await app.register(fastifyMultipart, { limits: { fileSize: MAX_COVER_BYTES } });
await app.register(fastifyCookie as never);
await app.register(fastifyMultipart as never, { limits: { fileSize: MAX_COVER_BYTES } });
// Rate limit unauthenticated requests only (brute-force protection on public endpoints).
// Bypass for: JWT cookie (web app), Authorization header (OPDS Basic Auth),
// and Kobo device endpoints (token-authenticated via URL).
await app.register(fastifyRateLimit, {
await app.register(fastifyRateLimit as never, {
max: 100,
timeWindow: '1 minute',
allowList: (req: { cookies?: { access_token?: string }; headers?: { authorization?: string }; url?: string }) =>
@@ -115,7 +103,7 @@ async function bootstrap() {
}
if (process.env.NODE_ENV === 'production') {
await app.register(fastifyStatic, {
await app.register(fastifyStatic as never, {
root: join(__dirname, '..', 'public'),
prefix: '/',
decorateReply: false,
+1 -3
View File
@@ -355,9 +355,7 @@ describe('AuthService', () => {
passwordHash: 'hash',
});
await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(
BadRequestException,
);
await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(BadRequestException);
});
});
@@ -216,9 +216,9 @@ describe('BookController', () => {
it('streams server-sent events for bulk metadata refresh progress', async () => {
const { controller, bookService } = makeController();
const { reply, raw } = makeReply();
bookService.bulkRefreshMetadata.mockImplementation(async (_bookIds: number[], _user: RequestUser, onProgress: (bookId: number) => void) => {
bookService.bulkRefreshMetadata.mockImplementation((_bookIds: number[], _user: RequestUser, onProgress: (bookId: number) => void) => {
onProgress(9);
return { processed: 1, failed: 0 };
return Promise.resolve({ processed: 1, failed: 0 });
});
await controller.bulkRefreshMetadata({ bookIds: [9] }, makeUser(), reply);
+1 -1
View File
@@ -502,7 +502,7 @@ export class BookService {
processed++;
onProgress?.(id);
} catch (err) {
this.logger.warn(`Bulk metadata refresh failed for book ${id}: ${err}`);
this.logger.warn(`Bulk metadata refresh failed for book ${id}: ${err instanceof Error ? err.message : String(err)}`);
failed++;
onProgress?.(id);
}
@@ -18,10 +18,7 @@ describe('EmailFileSelector', () => {
};
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailFileSelector,
{ provide: DB, useValue: db },
],
providers: [EmailFileSelector, { provide: DB, useValue: db }],
}).compile();
selector = module.get<EmailFileSelector>(EmailFileSelector);
@@ -1,4 +1,4 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EmailRecipientService } from './email-recipient.service';
import { EmailRecipientRepository } from './email-recipient.repository';
@@ -19,11 +19,8 @@ jest.mock('fs');
describe('EmailSendOrchestrator', () => {
let orchestrator: EmailSendOrchestrator;
let providerResolver: EmailProviderResolver;
let fileSelector: EmailFileSelector;
let recipientService: EmailRecipientService;
let groupService: EmailRecipientGroupService;
let templateService: EmailTemplateService;
let templateContextService: EmailTemplateContextService;
let preferencesService: EmailPreferencesService;
let sendLogService: EmailSendLogService;
let transportService: EmailTransportService;
@@ -42,7 +39,14 @@ describe('EmailSendOrchestrator', () => {
roles: [],
};
const mockRecipient = { id: 10, email: 'recipient@test.com', name: 'Recipient', deviceType: 'kindle', preferredFormat: 'mobi', defaultTemplateId: null };
const mockRecipient = {
id: 10,
email: 'recipient@test.com',
name: 'Recipient',
deviceType: 'kindle',
preferredFormat: 'mobi',
defaultTemplateId: null,
};
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 mockProvider = { config: { host: 'smtp.test.com' }, providerId: 300 };
@@ -101,11 +105,8 @@ describe('EmailSendOrchestrator', () => {
orchestrator = module.get<EmailSendOrchestrator>(EmailSendOrchestrator);
providerResolver = module.get<EmailProviderResolver>(EmailProviderResolver);
fileSelector = module.get<EmailFileSelector>(EmailFileSelector);
recipientService = module.get<EmailRecipientService>(EmailRecipientService);
groupService = module.get<EmailRecipientGroupService>(EmailRecipientGroupService);
templateService = module.get<EmailTemplateService>(EmailTemplateService);
templateContextService = module.get<EmailTemplateContextService>(EmailTemplateContextService);
preferencesService = module.get<EmailPreferencesService>(EmailPreferencesService);
sendLogService = module.get<EmailSendLogService>(EmailSendLogService);
transportService = module.get<EmailTransportService>(EmailTransportService);
@@ -171,9 +172,11 @@ describe('EmailSendOrchestrator', () => {
const result = await orchestrator.resend(400, mockUser);
expect(result.queued).toBe(1);
expect(sendLogService.create).toHaveBeenCalledWith(expect.objectContaining({
toEmail: 'resend@test.com',
}));
expect(sendLogService.create).toHaveBeenCalledWith(
expect.objectContaining({
toEmail: 'resend@test.com',
}),
);
});
});
@@ -191,11 +194,13 @@ describe('EmailSendOrchestrator', () => {
await (orchestrator as any).dispatchSend(400, {}, task, file, 'Subject', 'Body', 0);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
to: 'test@test.com',
subject: 'Subject',
text: 'Body',
}));
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@test.com',
subject: 'Subject',
text: 'Body',
}),
);
expect(sendLogService.markSent).toHaveBeenCalledWith(400);
});
@@ -221,9 +226,11 @@ describe('EmailSendOrchestrator', () => {
await (orchestrator as any).dispatchSend(400, {}, task, file, 'Original', 'Body', 0);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
subject: 'convert',
}));
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'convert',
}),
);
});
it('should use "convert" subject in resend if original was "convert"', async () => {
@@ -241,9 +248,11 @@ describe('EmailSendOrchestrator', () => {
await orchestrator.resend(400, mockUser);
expect(sendLogService.create).toHaveBeenCalledWith(expect.objectContaining({
subject: 'convert',
}));
expect(sendLogService.create).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'convert',
}),
);
});
it('should not retry if isFinal is true', async () => {
@@ -7,7 +7,6 @@ import { DB } from '../../db';
describe('EmailTemplateContextService', () => {
let service: EmailTemplateContextService;
let db: any;
let config: ConfigService;
beforeEach(async () => {
db = {
@@ -28,7 +27,6 @@ describe('EmailTemplateContextService', () => {
}).compile();
service = module.get<EmailTemplateContextService>(EmailTemplateContextService);
config = module.get<ConfigService>(ConfigService);
});
it('should build context for a book', async () => {
@@ -47,24 +45,22 @@ describe('EmailTemplateContextService', () => {
// Authors and tags use orderBy or innerJoin and don't have limit
(db.orderBy as jest.Mock).mockResolvedValueOnce(mockAuthors);
(db.where as jest.Mock).mockImplementation(function (this: any) {
if (db.where.mock.calls.length === 4) { // tags call
return Promise.resolve(mockTags);
}
return this;
if (db.where.mock.calls.length === 4) {
// tags call
return Promise.resolve(mockTags);
}
return this;
});
// Actually, it's cleaner to mock each stage
db.limit = jest.fn()
.mockResolvedValueOnce([mockBook])
.mockResolvedValueOnce([mockMeta])
.mockResolvedValueOnce([mockFile]);
db.limit = jest.fn().mockResolvedValueOnce([mockBook]).mockResolvedValueOnce([mockMeta]).mockResolvedValueOnce([mockFile]);
db.orderBy = jest.fn().mockResolvedValueOnce(mockAuthors);
// For tags, it's select().from().innerJoin().where() -> no limit, no orderBy
// We need to make where() return mockTags for the 4th call
let callCount = 0;
db.where = jest.fn().mockImplementation(function(this: any) {
db.where = jest.fn().mockImplementation(function (this: any) {
callCount++;
if (callCount === 4) return Promise.resolve(mockTags);
return this;
@@ -1,4 +1,4 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ForbiddenException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EmailTemplateService } from './email-template.service';
import { EmailTemplateRepository } from './email-template.repository';
@@ -12,7 +12,7 @@ describe('FileLockService', () => {
return 1;
});
const second = lock.withLock('/tmp/a', async () => {
const second = lock.withLock('/tmp/a', () => {
sequence.push('second:start');
sequence.push('second:end');
return 2;
@@ -27,10 +27,10 @@ describe('FileLockService', () => {
const starts: string[] = [];
await Promise.all([
lock.withLock('/tmp/a', async () => {
lock.withLock('/tmp/a', () => {
starts.push('a');
}),
lock.withLock('/tmp/b', async () => {
lock.withLock('/tmp/b', () => {
starts.push('b');
}),
]);
@@ -43,13 +43,13 @@ describe('FileLockService', () => {
const lock = new FileLockService();
await expect(
lock.withLock('/tmp/fail', async () => {
lock.withLock('/tmp/fail', () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
await expect(
lock.withLock('/tmp/fail', async () => {
lock.withLock('/tmp/fail', () => {
return 'ok';
}),
).resolves.toBe('ok');
@@ -17,10 +17,7 @@ describe('FileWriteRepository', () => {
const c2 = chain([]);
const db = {
select: jest
.fn()
.mockReturnValueOnce(c1)
.mockReturnValueOnce(c2),
select: jest.fn().mockReturnValueOnce(c1).mockReturnValueOnce(c2),
};
const repo = new FileWriteRepository(db as never);
@@ -78,12 +75,7 @@ describe('FileWriteRepository', () => {
};
const db = {
select: jest
.fn()
.mockReturnValueOnce(metaChain)
.mockReturnValueOnce(authorChain)
.mockReturnValueOnce(genreChain)
.mockReturnValueOnce(tagChain),
select: jest.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain).mockReturnValueOnce(genreChain).mockReturnValueOnce(tagChain),
};
const repo = new FileWriteRepository(db as never);
@@ -41,13 +41,7 @@ describe('FileWriteService', () => {
get: jest.fn().mockImplementation((key: string) => (key === 'storage.booksPath' ? '/books' : undefined)),
} as unknown as ConfigService;
const service = new FileWriteService(
fileWriteRepo as never,
settingsService as never,
registry as never,
lockService as never,
config,
);
const service = new FileWriteService(fileWriteRepo as never, settingsService as never, registry as never, lockService as never, config);
return { service, fileWriteRepo, settingsService, registry, writer, lockService };
}
@@ -106,9 +106,7 @@ describe('Cb7FormatWriter', () => {
const writer = new Cb7FormatWriter();
await expect(
writer.write('/book.cb7', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false }),
).rejects.toThrow('rename denied');
await expect(writer.write('/book.cb7', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false })).rejects.toThrow('rename denied');
expect(mockBuildComicInfoXml).toHaveBeenCalledWith(null, { title: 'Dune' }, new Set(['title']));
expect(mockUnlink).toHaveBeenCalledWith('/.cbx-write-abcdef123');
@@ -39,11 +39,7 @@ describe('CbzFormatWriter', () => {
mockBuildComicInfoXml.mockReturnValue('<ComicInfo><Title>Dune</Title></ComicInfo>');
mockWriteComicInfoToZip.mockResolvedValue(undefined);
const result = await writer.write(
'/book.cbz',
{ title: 'Dune', tags: ['classic'] },
{ fieldMask: new Set(['title', 'tags']), dryRun: false },
);
const result = await writer.write('/book.cbz', { title: 'Dune', tags: ['classic'] }, { fieldMask: new Set(['title', 'tags']), dryRun: false });
expect(mockReadComicInfoFromZip).toHaveBeenCalledWith('/book.cbz');
expect(mockBuildComicInfoXml).toHaveBeenCalledWith('<ComicInfo/>', { title: 'Dune', tags: ['classic'] }, new Set(['title', 'tags']));
@@ -81,10 +81,7 @@ describe('cbz-zip-patcher', () => {
it('readComicInfoFromZip finds comicinfo case-insensitively in root or nested paths', async () => {
mockOpenFile.mockResolvedValue({
files: [
{ path: 'pages/001.jpg' },
{ path: 'META/ComicInfo.XML', buffer: jest.fn().mockResolvedValue(Buffer.from('<ComicInfo/>')) },
],
files: [{ path: 'pages/001.jpg' }, { path: 'META/ComicInfo.XML', buffer: jest.fn().mockResolvedValue(Buffer.from('<ComicInfo/>')) }],
} as never);
await expect(readComicInfoFromZip('/book.cbz')).resolves.toBe('<ComicInfo/>');
@@ -53,17 +53,10 @@ describe('EpubFormatWriter', () => {
const coverBytes = Buffer.from('cover');
const result = await writer.write(
'/book.epub',
{ title: 'Dune', coverBytes },
{ fieldMask: new Set(['title', 'coverBytes']), dryRun: false },
);
const result = await writer.write('/book.epub', { title: 'Dune', coverBytes }, { fieldMask: new Set(['title', 'coverBytes']), dryRun: false });
expect(mockCoverLocate).toHaveBeenCalledWith('<package />', 'OPS/');
expect(mockPatch).toHaveBeenCalledWith(
'/book.epub',
expect.any(Map),
);
expect(mockPatch).toHaveBeenCalledWith('/book.epub', expect.any(Map));
const patches = mockPatch.mock.calls[0][1];
expect(patches.get('OPS/content.opf')).toEqual(Buffer.from('<package>new</package>'));
@@ -83,11 +76,7 @@ describe('EpubFormatWriter', () => {
const coverBytes = Buffer.from([1, 2, 3]);
await writer.write(
'/book.epub',
{ title: 'Dune', coverBytes },
{ fieldMask: new Set(['title', 'coverBytes']), dryRun: false },
);
await writer.write('/book.epub', { title: 'Dune', coverBytes }, { fieldMask: new Set(['title', 'coverBytes']), dryRun: false });
expect(mockCoverInject).toHaveBeenCalledWith('<package>new</package>', 'OPS/', coverBytes);
@@ -24,9 +24,7 @@ describe('epub-opf-builder', () => {
it('throws on unsupported package version', () => {
const opf = `<package version="1.0"><metadata></metadata></package>`;
expect(() => build(opf, { title: 'Book' }, { fieldMask: new Set(['title']), dryRun: false })).toThrow(
'Unsupported EPUB version: "1.0"',
);
expect(() => build(opf, { title: 'Book' }, { fieldMask: new Set(['title']), dryRun: false })).toThrow('Unsupported EPUB version: "1.0"');
});
it('rewrites metadata for EPUB3, preserves UID, and appends required namespace prefix', () => {
@@ -20,11 +20,11 @@ describe('locateOpf', () => {
files: [
{
path: 'META-INF/container.xml',
buffer: jest.fn().mockResolvedValue(
Buffer.from(
`<?xml version="1.0"?><container><rootfiles><rootfile full-path="OPS/content.opf"/></rootfiles></container>`,
buffer: jest
.fn()
.mockResolvedValue(
Buffer.from(`<?xml version="1.0"?><container><rootfiles><rootfile full-path="OPS/content.opf"/></rootfiles></container>`),
),
),
},
],
} as never);
@@ -139,9 +139,9 @@ describe('PdfFormatWriter', () => {
const writer = new PdfFormatWriter();
await expect(
writer.write('/books/dune.pdf', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false }),
).rejects.toThrow('permission denied');
await expect(writer.write('/books/dune.pdf', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false })).rejects.toThrow(
'permission denied',
);
expect(mockUnlink).toHaveBeenCalledWith('/books/.tmp-abc-uuid.pdf');
});
@@ -43,17 +43,11 @@ describe('buildXmp', () => {
});
it('writes series only when both seriesName and seriesIndex are selected and present', () => {
const withBoth = buildXmp(
{ seriesName: 'Dune', seriesIndex: 1 },
new Set(['seriesName', 'seriesIndex']),
);
const withBoth = buildXmp({ seriesName: 'Dune', seriesIndex: 1 }, new Set(['seriesName', 'seriesIndex']));
expect(withBoth).toContain('<projectx:seriesName>Dune</projectx:seriesName>');
expect(withBoth).toContain('<projectx:seriesIndex>1</projectx:seriesIndex>');
const missingMask = buildXmp(
{ seriesName: 'Dune', seriesIndex: 1 },
new Set(['seriesName']),
);
const missingMask = buildXmp({ seriesName: 'Dune', seriesIndex: 1 }, new Set(['seriesName']));
expect(missingMask).not.toContain('projectx:seriesName');
expect(missingMask).not.toContain('projectx:seriesIndex');
});
@@ -98,7 +98,10 @@ describe('LibraryService', () => {
}),
);
expect(scannerService.startScanAsync).toHaveBeenCalledWith(5);
expect(result.folders).toEqual([{ id: 11, path: '/a' }, { id: 12, path: '/b' }]);
expect(result.folders).toEqual([
{ id: 11, path: '/a' },
{ id: 12, path: '/b' },
]);
});
it('create rejects duplicate library names', async () => {
@@ -146,14 +149,14 @@ describe('LibraryService', () => {
it('prescan counts primary files recursively and flags overlapping paths', async () => {
libraryRepo.findAllFolderPaths.mockResolvedValue([{ path: '/books/existing', libraryName: 'Existing Library' }]);
mockReaddir.mockImplementation(async (path: Parameters<typeof readdir>[0]) => {
mockReaddir.mockImplementation((path: Parameters<typeof readdir>[0]) => {
if (path === '/books/new') {
return [dirent('a.epub', 'file'), dirent('.hidden.epub', 'file'), dirent('sub', 'dir')] as any;
return Promise.resolve([dirent('a.epub', 'file'), dirent('.hidden.epub', 'file'), dirent('sub', 'dir')] as any);
}
if (path === '/books/new/sub') {
return [dirent('b.pdf', 'file'), dirent('note.txt', 'file')] as any;
return Promise.resolve([dirent('b.pdf', 'file'), dirent('note.txt', 'file')] as any);
}
return [] as any;
return Promise.resolve([] as any);
});
mockIsPrimaryFormat.mockImplementation((path: string) => path.endsWith('.epub') || path.endsWith('.pdf'));
+14 -11
View File
@@ -1,6 +1,6 @@
import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { access, readdir, rm, stat } from 'fs/promises';
import { readdir, rm, stat } from 'fs/promises';
import { join } from 'path';
import type { RequestUser } from '../../common/types/request-user';
@@ -130,20 +130,23 @@ export class LibraryService {
const results = await Promise.all(
dto.paths.map(async (inputPath) => {
let accessible = false;
let accessible: boolean;
let fileCount = 0;
let overlapLibrary: string | undefined;
try {
await access(inputPath);
const s = await stat(inputPath);
if (!s.isDirectory()) {
return { path: inputPath, accessible: false, fileCount: 0, error: 'Not a directory' };
}
accessible = true;
fileCount = await countPrimaryFiles(inputPath);
} catch {
const s = await stat(inputPath).catch(() => null);
if (s === null) {
accessible = false;
} else if (!s.isDirectory()) {
return { path: inputPath, accessible: false, fileCount: 0, error: 'Not a directory' };
} else {
accessible = true;
try {
fileCount = await countPrimaryFiles(inputPath);
} catch {
accessible = false;
fileCount = 0;
}
}
// Check if this path overlaps an existing library folder
@@ -96,7 +96,7 @@ export class AmazonProvider implements IdentifiableProvider {
}
return res.text();
} catch (err) {
this.logger.warn(`Amazon fetch failed for ${url}: ${err}`);
this.logger.warn(`Amazon fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
@@ -86,7 +86,7 @@ export class GoodreadsProvider implements IdentifiableProvider {
}
return res.text();
} catch (err) {
this.logger.warn(`Goodreads fetch failed for ${url}: ${err}`);
this.logger.warn(`Goodreads fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
@@ -10,7 +10,7 @@ function zipFile(path: string, content: string | Buffer) {
const buf = typeof content === 'string' ? Buffer.from(content) : content;
return {
path,
buffer: async () => buf,
buffer: () => Promise.resolve(buf),
};
}
@@ -29,11 +29,7 @@ describe('extractEpubCover', () => {
const cover = Buffer.from([1, 2, 3]);
mockOpenFile.mockResolvedValue({
files: [
zipFile('META-INF/container.xml', containerXml),
zipFile('OPS/content.opf', opfXml),
zipFile('OPS/images/cover.jpg', cover),
],
files: [zipFile('META-INF/container.xml', containerXml), zipFile('OPS/content.opf', opfXml), zipFile('OPS/images/cover.jpg', cover)],
});
await expect(extractEpubCover('/book.epub')).resolves.toEqual(cover);
+29 -3
View File
@@ -13,7 +13,7 @@ function zipFile(path: string, content: string | Buffer) {
const buf = typeof content === 'string' ? Buffer.from(content) : content;
return {
path,
buffer: async () => buf,
buffer: () => Promise.resolve(buf),
};
}
@@ -33,7 +33,20 @@ describe('extractEpubMetadata', () => {
files: [zipFile('META-INF/container.xml', containerXml), zipFile('OPS/content.opf', '<package/>')],
});
mockParseOpf.mockReturnValue({ title: 'Dune', subtitle: null, description: null, isbn10: null, isbn13: null, publisher: null, publishedYear: null, language: null, seriesName: null, seriesIndex: null, authors: [], tags: [] });
mockParseOpf.mockReturnValue({
title: 'Dune',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
tags: [],
});
await expect(extractEpubMetadata('/books/dune.epub')).resolves.toEqual(expect.objectContaining({ title: 'Dune' }));
});
@@ -45,7 +58,20 @@ describe('extractEpubMetadata', () => {
files: [zipFile('META-INF/container.xml', containerXml), zipFile('OPS/content.opf', '<package/>')],
});
mockParseOpf.mockReturnValue({ title: 'Leading Slash', subtitle: null, description: null, isbn10: null, isbn13: null, publisher: null, publishedYear: null, language: null, seriesName: null, seriesIndex: null, authors: [], tags: [] });
mockParseOpf.mockReturnValue({
title: 'Leading Slash',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
tags: [],
});
await expect(extractEpubMetadata('/books/x.epub')).resolves.toEqual(expect.objectContaining({ title: 'Leading Slash' }));
});
@@ -114,7 +114,7 @@ describe('MetadataService', () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => Buffer.from('image-bytes'),
arrayBuffer: () => Promise.resolve(Buffer.from('image-bytes')),
}) as never;
await service.downloadAndSaveCover('https://img.example/cover.png', 9);
@@ -132,7 +132,7 @@ describe('MetadataService', () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => Buffer.alloc(0),
arrayBuffer: () => Promise.resolve(Buffer.alloc(0)),
}) as never;
await service.downloadAndSaveCover('https://img.example/empty.png', 4);
@@ -238,7 +238,7 @@ describe('MetadataService', () => {
db.select.mockImplementation(() => ({
from: () => ({
where: () => ({
limit: async () => [],
limit: () => Promise.resolve([]),
}),
}),
}));
@@ -247,7 +247,7 @@ describe('MetadataService', () => {
return {
values: (row: { name: string; sortName: string | null }) => {
insertedAuthors.push(row);
return { returning: async () => [{ id: 81 }] };
return { returning: () => Promise.resolve([{ id: 81 }]) };
},
};
}
@@ -255,7 +255,7 @@ describe('MetadataService', () => {
return {
values: (row: { bookId: number; authorId: number; displayOrder: number }) => {
insertedBookAuthors.push(row);
return { onConflictDoNothing: async () => undefined };
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
@@ -283,7 +283,7 @@ describe('MetadataService', () => {
db.select.mockImplementation(() => ({
from: () => ({
where: () => ({
limit: async () => [{ id: 9 }],
limit: () => Promise.resolve([{ id: 9 }]),
}),
}),
}));
@@ -292,7 +292,7 @@ describe('MetadataService', () => {
return {
values: (row: { bookId: number; authorId: number; displayOrder: number }) => {
insertedBookAuthors.push(row);
return { onConflictDoNothing: async () => undefined };
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
@@ -55,7 +55,7 @@ export class MetadataService {
this.logger.debug(`Online cover saved for book ${bookId}`);
} catch (err) {
this.logger.warn(`Cover download failed for book ${bookId}: ${err}`);
this.logger.warn(`Cover download failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -233,7 +233,7 @@ export class MetadataService {
this.logger.debug(`Cover saved for book ${bookId}: ${saved}`);
}
} catch (err) {
this.logger.warn(`Cover extraction failed for book ${bookId}: ${err}`);
this.logger.warn(`Cover extraction failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -249,7 +249,7 @@ export class MetadataService {
await this.setCoverSourceIfUnset(bookId, 'extracted');
this.logger.debug(`PDF cover saved for book ${bookId}`);
} catch (err) {
this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`);
this.logger.warn(`PDF cover save failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -154,7 +154,7 @@ export class FileEventProcessorService {
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = join(entry.parentPath ?? entry.path, entry.name);
const fullPath = join(entry.parentPath ?? dirPath, entry.name);
const { role } = classifyFile(fullPath);
if (role !== 'primary') continue;
@@ -119,7 +119,7 @@ export class ScannerService implements OnApplicationBootstrap {
try {
candidates = await findBookCandidates(folder.path);
} catch (err) {
this.logger.warn(`Cannot walk ${folder.path}: ${err}`);
this.logger.warn(`Cannot walk ${folder.path}: ${err instanceof Error ? err.message : String(err)}`);
}
if (allowed) {
@@ -251,7 +251,7 @@ export class ScannerService implements OnApplicationBootstrap {
try {
isNew = await this.processFile(fileStat, format, role, book.id, libraryFolderId, fileByPath, fileByIno, fileCount);
} catch (err) {
this.logger.warn(`Failed to process file ${fileStat.absolutePath}: ${err}`);
this.logger.warn(`Failed to process file ${fileStat.absolutePath}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
@@ -262,7 +262,7 @@ export class ScannerService implements OnApplicationBootstrap {
try {
await this.metadataService.extractAndSave(book.id, fileStat.absolutePath, format);
} catch (err) {
this.logger.warn(`Metadata extraction failed for ${fileStat.absolutePath}: ${err}`);
this.logger.warn(`Metadata extraction failed for ${fileStat.absolutePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -276,7 +276,7 @@ export class StagingFinalizeService implements OnModuleInit {
const bytes = await readFile(row.coverPath);
await this.metadataService.saveExtractedCoverBytes(bookId, bytes);
} catch (err) {
this.logger.warn(`Failed to copy staging cover to book ${bookId}: ${err instanceof Error ? err.message : err}`);
this.logger.warn(`Failed to copy staging cover to book ${bookId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -145,7 +145,7 @@ export class StagingIngestService {
: { status: 'ready' as const };
await this.repo.update(fileId, updates);
} catch (err) {
this.logger.warn(`Auto-fetch metadata failed for staging file ${fileId}: ${err instanceof Error ? err.message : err}`);
this.logger.warn(`Auto-fetch metadata failed for staging file ${fileId}: ${err instanceof Error ? err.message : String(err)}`);
await this.repo.update(fileId, { status: 'ready' });
}
}
@@ -33,7 +33,7 @@ export class StagingMetadataService {
status: 'ready',
});
} catch (err) {
this.logger.warn(`Metadata extraction failed for staging file ${fileId}: ${err}`);
this.logger.warn(`Metadata extraction failed for staging file ${fileId}: ${err instanceof Error ? err.message : String(err)}`);
await this.repo.update(fileId, {
status: 'error',
errorMessage: err instanceof Error ? err.message : 'Metadata extraction failed',
@@ -29,9 +29,7 @@ export class UploadValidatorService {
* Preserves the original extension.
*/
sanitizeFilename(raw: string): string {
const sanitized = raw
.replace(/[/\\:*?"<>|\0]/g, '_')
.trim();
const sanitized = raw.replace(/[/\\:*?"<>|\0]/g, '_').trim();
if (!sanitized) return 'upload';
@@ -44,7 +42,10 @@ export class UploadValidatorService {
if (!ext) return sanitized.slice(0, 255);
if (ext.length >= 255) return sanitized.slice(0, 255);
const stem = sanitized.slice(0, -ext.length).slice(0, 255 - ext.length).trim();
const stem = sanitized
.slice(0, -ext.length)
.slice(0, 255 - ext.length)
.trim();
return `${stem || 'upload'}${ext}`;
}
}
@@ -25,7 +25,9 @@ describe('UploadController', () => {
file: jest.fn().mockResolvedValue({ filename: 'a.epub', file: {} }),
};
await expect(controller.uploadBook(1, 'abc', { id: 1, roles: [] } as any, req as any)).rejects.toThrow(new BadRequestException('Invalid folderId'));
await expect(controller.uploadBook(1, 'abc', { id: 1, roles: [] } as any, req as any)).rejects.toThrow(
new BadRequestException('Invalid folderId'),
);
});
it('passes parsed arguments to upload service and overrides multipart size limit', async () => {
@@ -95,7 +95,15 @@ describe('UploadService', () => {
expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/Frank Herbert', '/library/Frank Herbert/Dune.epub', 'Frank Herbert/Dune.epub', 'epub', 456);
expect(processor.createBookRecord).toHaveBeenCalledWith(
1,
2,
'/library/Frank Herbert',
'/library/Frank Herbert/Dune.epub',
'Frank Herbert/Dune.epub',
'epub',
456,
);
expect(processor.extractMetadataAsync).toHaveBeenCalledWith(99, '/library/Frank Herbert/Dune.epub', 'epub');
});
@@ -141,9 +149,7 @@ describe('UploadService', () => {
});
it('rejects uploads when no default folder exists for the library', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([]));
db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])).mockReturnValueOnce(selectChain([]));
await expect(service.upload(1, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
@@ -19,26 +19,26 @@ describe('UserController', () => {
jest.resetAllMocks();
});
it('passes optional pagination args to service', () => {
controller.findAll(undefined, 25);
it('passes optional pagination args to service', async () => {
await controller.findAll(undefined, 25);
expect(userService.findAll).toHaveBeenCalledWith(undefined, 25);
});
it('routes updateMe to current user id', () => {
it('routes updateMe to current user id', async () => {
const user = { id: 7 } as any;
const dto = { name: 'Updated' };
controller.updateMe(user, dto as any);
await controller.updateMe(user, dto as any);
expect(userService.updateMe).toHaveBeenCalledWith(7, dto);
});
it('delegates role assignment/revocation and admin reset', () => {
it('delegates role assignment/revocation and admin reset', async () => {
const requester = { id: 1 } as any;
controller.assignRole(8, { roleId: 4 } as any, requester);
controller.revokeRole(8, 4, requester);
controller.adminResetPassword(8, requester);
await controller.assignRole(8, { roleId: 4 } as any, requester);
await controller.revokeRole(8, 4, requester);
await controller.adminResetPassword(8, requester);
expect(userService.assignRole).toHaveBeenCalledWith(8, 4, requester);
expect(userService.revokeRole).toHaveBeenCalledWith(8, 4, requester);
+4 -6
View File
@@ -113,11 +113,7 @@ describe('UserService', () => {
it('assignRole allows superuser-role assignment for manage_roles users', async () => {
(db.query.roles.findFirst as jest.Mock).mockResolvedValue({ id: 9, isSuperuser: true });
await service.assignRole(
2,
9,
reqUser({ roles: [{ isSuperuser: false, permissions: [{ name: 'manage_roles' }] }] }),
);
await service.assignRole(2, 9, reqUser({ roles: [{ isSuperuser: false, permissions: [{ name: 'manage_roles' }] }] }));
expect(userRepo.assignRole).toHaveBeenCalledWith(2, 9);
});
@@ -143,6 +139,8 @@ describe('UserService', () => {
it('adminResetPassword throws when target user does not exist', async () => {
userRepo.findByIdWithRolesAndPermissions.mockResolvedValue(null);
await expect(service.adminResetPassword(9, reqUser({ roles: [{ isSuperuser: true, permissions: [] }] }))).rejects.toBeInstanceOf(NotFoundException);
await expect(service.adminResetPassword(9, reqUser({ roles: [{ isSuperuser: true, permissions: [] }] }))).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { existsSync } from 'fs';
import { join } from 'path';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
function resolveMigrationsFolder(): string {
const candidates = [
join(__dirname, '..', '..', 'migrations'),
join(__dirname, '..', 'db', 'migrations'),
join(process.cwd(), 'migrations'),
join(process.cwd(), 'src', 'db', 'migrations'),
];
const match = candidates.find((path) => existsSync(path));
if (!match) {
throw new Error(`Unable to locate migrations folder. Checked: ${candidates.join(', ')}`);
}
return match;
}
async function runMigrations() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required');
}
const pool = new Pool({
connectionString,
max: 3,
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
});
try {
const migrationsFolder = resolveMigrationsFolder();
await migrate(drizzle(pool), { migrationsFolder });
console.log(`Migrations applied successfully from ${migrationsFolder}`);
} finally {
await pool.end();
}
}
void runMigrations();