mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -3,6 +3,7 @@
|
||||
**/dist
|
||||
**/*.log
|
||||
.env
|
||||
.env.prod
|
||||
client/.env
|
||||
server/.env
|
||||
docker/postgres/data
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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: ["*"]
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.prod
|
||||
client/.env
|
||||
server/.env
|
||||
*.local
|
||||
|
||||
+21
-11
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -326,7 +358,8 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000)
|
||||
## Further Reading
|
||||
|
||||
| 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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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}": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"strict": false,
|
||||
"skipLibCheck": true
|
||||
|
||||
Executable
+31
@@ -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"
|
||||
Executable
+35
@@ -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"
|
||||
Executable
+48
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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
|
||||
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,10 +20,10 @@ 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>`),
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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()) {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user