Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Nothaft e1ae562a37 before/after for stray-0 fix PR 2026-07-03 08:58:55 +02:00
Paul Nothaft c153b5b891 screenshots for refactor/codebase-cleanup PR 2026-07-03 08:28:05 +02:00
740 changed files with 141177 additions and 15484 deletions
+30 -14
View File
@@ -4,8 +4,11 @@
# Environment
NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker: the secrets-init service writes it to a private volume and reuses it
# across restarts). Set it explicitly only to pin your own value.
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
@@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
DB_PASSWORD=your_secure_postgres_password_here
#DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
# IMPORTANT: Same warning applies - avoid $ or escape as $$
REDIS_PASSWORD=your_secure_redis_password_here
#REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#ADMIN_EMAIL=admin@yourdomain.com
#ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
@@ -68,6 +80,16 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# Static HTML title + description used for social link previews when the
# fetcher doesn't trigger the per-event OG endpoint — most notably the
# WhatsApp Business API and various 3rd-party preview-service caches
# (#521). Set these to your brand so link previews aren't generic.
# Substituted into index.html at frontend-container start, so changes
# take effect on the next `docker compose up -d frontend` — no rebuild
# required.
BRAND_TITLE=PicPeak
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
@@ -97,12 +119,6 @@ UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
# Runtime user mapping for Docker (optional)
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
PUID=1001
PGID=1001
# Analytics (Optional - Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/the-luap/picpeak/discussions
url: https://github.com/PicPeak/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
+9 -9
View File
@@ -42,16 +42,16 @@ Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/picpeak/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/the-luap/picpeak/frontend:latest
docker pull ghcr.io/picpeak/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
```
### Using in Docker Compose
@@ -61,14 +61,14 @@ version: '3.8'
services:
backend:
image: ghcr.io/the-luap/picpeak/backend:latest
image: ghcr.io/picpeak/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/the-luap/picpeak/frontend:latest
image: ghcr.io/picpeak/picpeak/frontend:latest
ports:
- "80:80"
```
@@ -86,7 +86,7 @@ spec:
spec:
containers:
- name: backend
image: ghcr.io/the-luap/picpeak/backend:latest
image: ghcr.io/picpeak/picpeak/backend:latest
imagePullPolicy: Always
```
@@ -149,8 +149,8 @@ If images aren't visible after successful push:
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
+70
View File
@@ -0,0 +1,70 @@
name: Bypass size gate
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
# self-merge without a maintainer review. The branch-protection bypass list
# alone is binary — once a user is on it they can merge anything without
# review. This workflow reports a REQUIRED status check that fails when a
# bypass user's PR exceeds the configured size threshold, which blocks the
# merge even with bypass enabled. Other contributors are unaffected (the
# check reports success for them so the required-check gate doesn't trip).
#
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
#
# Trigger note: uses `pull_request_target` so the workflow has the elevated
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
# checks). The script never executes code FROM the PR — it only reads
# metadata via the API — so this is safe against fork-PR attacks.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: read
checks: write
jobs:
size-gate:
runs-on: ubuntu-latest
steps:
- name: Compute PR size and report check status
uses: actions/github-script@v7
with:
script: |
// Tune these two constants if the policy shifts.
const LINE_LIMIT = 300;
const BYPASS_USERS = ['Luca-Timo'];
const pr = context.payload.pull_request;
const author = pr.user.login;
const linesChanged = pr.additions + pr.deletions;
const filesChanged = pr.changed_files;
let conclusion, title, summary;
if (!BYPASS_USERS.includes(author)) {
// Not a bypass user — this gate doesn't apply to them. They
// go through normal review. Report success so the required
// check doesn't block their merge.
conclusion = 'success';
title = 'Not applicable';
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
} else if (linesChanged <= LINE_LIMIT) {
conclusion = 'success';
title = `OK — within bypass limit (${linesChanged} lines)`;
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
} else {
conclusion = 'failure';
title = `Too large for bypass (${linesChanged} lines)`;
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'bypass-size-gate',
head_sha: pr.head.sha,
status: 'completed',
conclusion,
output: { title, summary }
});
+133 -49
View File
@@ -1,7 +1,8 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
# builds; stable → ':stable' + ':latest' for the curated channel)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
@@ -20,10 +21,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, beta ]
branches: [ main, stable ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, beta ]
branches: [ main, stable ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -37,6 +38,16 @@ on:
- 'true'
- 'false'
# Once release-please authors releases with a PAT (#719), a new version fires
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
# suppress them). They build the same immutable version, so collapse them into a
# single run by grouping on the ref. Branch and PR builds use different refs and
# still run independently; a superseding push cancels an in-flight run for the
# same ref (only the newest build per ref is kept).
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
@@ -45,6 +56,13 @@ env:
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing.
# Default GITHUB_TOKEN to read-only at the workflow level. Each job that
# needs to publish to GHCR sets `packages: write` explicitly. This keeps
# the rest of the workflow (and any future steps) from inheriting unneeded
# privileges (CKV2_GHA_1).
permissions:
contents: read
jobs:
# -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest
@@ -62,6 +80,11 @@ jobs:
permissions:
contents: read
packages: write
# Trivy uploads its SARIF to the Security tab from this job — see
# the "Run Trivy" step below. Scanning per-arch by digest (#476)
# is reliable; scanning the multi-arch index by tag from the
# merge-* job was not.
security-events: write
steps:
- name: Checkout code
@@ -122,7 +145,10 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
@@ -146,13 +172,58 @@ jobs:
if-no-files-found: error
retention-days: 1
# Per-arch vulnerability scan (#476). Scanning the multi-arch
# manifest from the merge-* job by tag is unreliable — Trivy's
# remote resolver crashes intermittently with "no child with
# platform linux/amd64 in index". The fix is to scan each leg
# by its single-platform digest right here, where it just landed
# in GHCR. Tag pinned (was @master) so the action + bundled
# Trivy binary don't float between runs.
#
# exit-code is left unset (=0) for now: Trivy reports findings
# to the Security tab but doesn't fail the build. Flipping that
# to '1' to actually gate CI is a deliberate follow-up — needs an
# audit pass first so the next beta build doesn't surprise red.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# docker/build-push-action wraps every push in an OCI index
# (carries the SLSA provenance attestation alongside the
# actual image). Trivy's remote backend defaults to
# linux/amd64 regardless of host arch when resolving an
# index, which makes the arm64 leg crash with "no child
# with platform linux/amd64". Telling Trivy which child to
# scan keeps the provenance attestation intact and fixes
# the resolver crash. Pin to matrix.platform so each leg
# scans its own arch.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
# Distinct category per arch so the Security tab surfaces
# per-platform findings independently — an amd64-only CVE in
# a base layer doesn't get masked by the arm64 scan.
category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-backend:
needs: build-backend
runs-on: ubuntu-latest
# No security-events permission here — vulnerability scanning moved
# to per-arch build-backend jobs (#476). This job's only job is to
# combine the per-arch digests into a multi-arch manifest.
permissions:
contents: read
packages: write
security-events: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -185,7 +256,9 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -210,9 +283,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
@@ -224,23 +302,6 @@ jobs:
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
format: 'sarif'
output: 'trivy-backend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
@@ -257,6 +318,9 @@ jobs:
permissions:
contents: read
packages: write
# See build-backend for the rationale (#476). Same pattern: per-arch
# vulnerability scan by digest, SARIF uploaded to the Security tab.
security-events: write
steps:
- name: Checkout code
@@ -317,7 +381,10 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
@@ -341,13 +408,40 @@ jobs:
if-no-files-found: error
retention-days: 1
# Per-arch vulnerability scan (#476). See build-backend for the
# full rationale; identical pattern here, only the image-ref +
# SARIF filename + category change.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# See build-backend for the rationale — pin Trivy's platform
# to the matrix arch so its remote-index resolver picks the
# right child instead of defaulting to linux/amd64 and
# crashing on the arm64 leg.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-frontend:
needs: build-frontend
runs-on: ubuntu-latest
# See merge-backend — vulnerability scanning moved to the per-arch
# build-frontend matrix (#476). This job only publishes the manifest.
permissions:
contents: read
packages: write
security-events: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
@@ -379,7 +473,9 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -404,9 +500,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
@@ -418,23 +519,6 @@ jobs:
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
format: 'sarif'
output: 'trivy-frontend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
+221
View File
@@ -0,0 +1,221 @@
name: Fresh-install smoke
# Verifies that a clean Postgres install boots cleanly under the same
# conditions a new user hits on their first `docker compose up -d`. The
# specific scenarios this guards against — see #484 for the original
# reproduction:
#
# 1. Bind-mounted host directories owned by a UID other than 1001
# (the container's nodejs user). The entrypoint must self-chown
# and drop privileges via su-exec.
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
# in #494, the index/created_at error fixed in #511, and any
# future migration-order issue that only surfaces on an empty DB).
#
# Triggers only on changes that touch the install path so unrelated PRs
# don't pay the build cost.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
# required check "missing" forever and block the merge. Better to
# pay the boot cost on every PR than maintain a per-path allowlist
# that drifts as the install surface evolves. (Branches also updated
# post-#669 rename: beta → main, old main → stable.)
push:
branches: [main, stable]
pull_request:
branches: [main, stable]
workflow_dispatch:
permissions:
contents: read
jobs:
fresh-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Build for the runner's arch only — we just need a runnable image.
# The full multi-arch build is the docker-build workflow's job.
- name: Build backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
# ignore-error: a flaky GHA cache write must not fail the build.
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
- name: Create Docker network
run: docker network create picpeak-smoke
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
# common mismatch case on Linux hosts). The entrypoint must chown
# this to 1001 itself — that's the regression we're guarding.
- name: Prepare host bind-mount dirs owned by UID 1000
run: |
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
chmod 755 smoke-mounts smoke-mounts/*
ls -ld smoke-mounts/*
- name: Start Postgres
run: |
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
-e POSTGRES_USER=picpeak \
-e POSTGRES_PASSWORD=smokepass \
-e POSTGRES_DB=picpeak_prod \
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
--health-interval=2s --health-timeout=2s --health-retries=30 \
postgres:15-alpine
- name: Wait for Postgres healthy
run: |
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
if [ "$status" = "healthy" ]; then
echo "postgres healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "postgres did not become healthy in 60s"
docker logs picpeak-smoke-pg
exit 1
- name: Start backend with mismatched-UID bind mounts (fresh install)
run: |
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
-e NODE_ENV=production \
-e JWT_SECRET=smoketestsecretvalueof32characters \
-e DB_HOST=picpeak-smoke-pg \
-e DB_USER=picpeak \
-e DB_PASSWORD=smokepass \
-e DB_NAME=picpeak_prod \
-e ADMIN_EMAIL=admin@smoke.local \
-e ADMIN_PASSWORD=smokeAdminPass12345 \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke
- name: Wait for backend healthy
run: |
for i in $(seq 1 120); do
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
if [ "$status" = "exited" ]; then
echo "FAIL: backend exited during cold-start (restart loop scenario)"
docker logs picpeak-smoke-bk
echo "--- error.log ---"
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
exit 1
fi
if [ "$health" = "healthy" ]; then
echo "backend healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "FAIL: backend did not become healthy in 120s"
docker ps -a
docker logs picpeak-smoke-bk
exit 1
- name: Verify chown happened (container view)
run: |
# All three dirs should now be owned by nodejs (UID 1001).
# If the entrypoint's self-chown branch didn't fire, they'd
# still be owned by the runner UID and node would have hit
# EACCES creating storage subdirs.
for d in /app/storage /app/data /app/logs; do
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
if [ "$owner_uid" != "1001" ]; then
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
exit 1
fi
echo "ok: $d owned by UID $owner_uid"
done
- name: Verify app is actually serving
run: |
# /health is what docker's HEALTHCHECK polls, but hit it
# directly to confirm the response shape matches what the
# frontend + reverse proxy expect.
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
echo "/health => $body"
echo "$body" | grep -q '"status":"ok"' || {
echo "FAIL: /health did not return status:ok"
exit 1
}
- name: Verify node runs as nodejs (not root)
run: |
# dumb-init runs as root (PID 1), node must be running as
# nodejs (UID 1001) — if su-exec drop didn't happen the app
# would be running as root which is the security regression
# we're guarding against. Alpine ships BusyBox ps, which
# doesn't support `-p PID` or pgrep, so list + awk instead.
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
if [ "$user" != "nodejs" ]; then
echo "FAIL: node running as '$user' (expected nodejs)"
docker exec picpeak-smoke-bk ps -o pid,user,comm
exit 1
fi
echo "ok: node running as $user"
- name: Verify no restart loop
run: |
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
if [ "$restart_count" -gt 0 ]; then
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
docker logs picpeak-smoke-bk
exit 1
fi
echo "ok: 0 restarts"
# Restart with `--user 5005:5005` (no root, can't chown) against
# bind mounts owned by 1000 — entrypoint must fail loud with the
# actionable preflight error, not silently restart-loop.
- name: Verify preflight fails loud on unwritable mounts
run: |
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
set +e
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
-e NODE_ENV=production -e JWT_SECRET=x \
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke 2>&1)
rc=$?
set -e
echo "$out"
if [ $rc -eq 0 ]; then
echo "FAIL: preflight should have exited non-zero"
exit 1
fi
echo "$out" | grep -q "is not writable by UID 5005" || {
echo "FAIL: preflight error message missing or wrong"
exit 1
}
echo "ok: preflight failed loud with actionable error"
- name: Cleanup
if: always()
run: |
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
docker network rm picpeak-smoke 2>/dev/null || true
+36
View File
@@ -0,0 +1,36 @@
name: PR Title Lint
# Release Please derives version bumps and the changelog from Conventional
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
# without a version bump or a changelog entry. This check fails a PR whose
# title is not a valid Conventional Commit so the release stays automated.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint-pr-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR title is a Conventional Commit
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
revert
docs
style
chore
refactor
test
build
ci
+54 -3
View File
@@ -2,7 +2,7 @@ name: Release Please (Beta)
on:
push:
branches: [beta]
branches: [main]
permissions:
contents: write
@@ -20,10 +20,48 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# A dedicated token (fine-grained PAT) makes the release PR run CI
# automatically (no "workflows awaiting approval") and lets it be
# merged without a manual review. Falls back to GITHUB_TOKEN so the
# workflow still works before the secret is added (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
target-branch: main
# Auto-approve + enable auto-merge on the open release PR so betas publish
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
# a valid review (requires the org's "Allow GitHub Actions to approve pull
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
# set: without it the PR is bot-authored and can't be self-approved, so we
# skip and leave today's manual flow. Best-effort — never blocks the run.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# This job has no checkout, so gh can't infer the repo from a git
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
# than the PR author (the PAT) — so it counts as a valid review.
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
# Enable auto-merge as the PAT so the eventual merge commit is
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
# push is suppressed by recursion prevention and the follow-up run that
# cuts the tag/release never fires (#719).
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -35,3 +73,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+44 -2
View File
@@ -2,7 +2,7 @@ name: Release Please
on:
push:
branches: [main]
branches: [stable]
permissions:
contents: write
@@ -20,10 +20,39 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Dedicated token so the release PR runs CI + can auto-merge without a
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
@@ -34,3 +63,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+186
View File
@@ -0,0 +1,186 @@
name: Schema drift (#530)
# Verifies that `migrate:safe` can recover a DB that's been seeded only
# by `initializeDatabase()` — the recovery scenario where the migrations
# tracking table is empty but the schema already has the modern bootstrap.
#
# This is NOT how production reaches its state on normal installs or
# upgrades. The scenario only fires when:
# - A backup was restored that captured tables but not the migrations
# table (manifest divergence),
# - Someone manually invoked initializeDatabase() outside the migration
# runner (recovery / debugging),
# - The DB was moved between systems and the migrations table was not
# copied along.
#
# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
# (photo_categories + cms_pages tables) but an empty migrations table,
# it treats it as an "existing deployment" — which runs the legacy
# chain first. Legacy/008 renames email_templates.subject → subject_en,
# but core/029 (which runs later in this chain) inserts email templates
# referencing the pre-rename column name. The chain dies with a
# "column subject does not exist" error.
#
# Fix (in the same PR as this workflow): when the modern-bootstrap
# fingerprint is detected, mark all legacy migrations as applied so the
# chain matches what a fresh install runs — only core/*, in order.
#
# This workflow boots the failing scenario from scratch on every PR
# that touches the migrations or db.js, so any future migration with
# the same shape is caught before merge.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
# trigger that skipped on unrelated PRs would leave the required
# check "missing" forever, blocking every PR that doesn't touch
# migrations. The ~75-second cost on every PR buys an unconditional
# safety net. (Branches also updated post-#669 rename: beta → main,
# old main → stable.)
push:
branches: [main, stable]
pull_request:
branches: [main, stable]
workflow_dispatch:
permissions:
contents: read
jobs:
upgrade-from-bootstrap:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_drift
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_drift"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: ./backend
run: npm ci
# Step 1: simulate the recovery state — DB has the modern bootstrap
# (post-initializeDatabase) but no migrations recorded. Calling
# initializeDatabase() directly outside the migration runner is the
# one-line repro for backup-restore-lost-migrations and manual-
# invocation paths.
- name: Seed DB with initializeDatabase() only
working-directory: ./backend
env:
NODE_ENV: production
DATABASE_CLIENT: pg
DB_HOST: localhost
DB_PORT: 5432
DB_USER: picpeak
DB_PASSWORD: testpass
DB_NAME: picpeak_drift
run: |
node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
# Sanity-check the recovery shape before migrate:safe runs. If
# initializeDatabase() ever stops producing photo_categories +
# cms_pages, the fingerprint check would silently no-op and this
# workflow would lose its teeth — assert the precondition.
- name: Assert recovery-state fingerprint
env:
PGPASSWORD: testpass
run: |
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
if [ "$installed" != "2" ]; then
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
# initializeDatabase() doesn't create the `migrations` tracking
# table — that's the migrate:safe runner's job. So in the recovery
# scenario, the table either (a) doesn't exist yet or (b) exists
# but is empty (e.g. someone created it but didn't populate it).
# Both are valid recovery states; check via to_regclass first so
# we don't parse a SELECT against a nonexistent table.
has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
if [ -z "$has_migrations_table" ]; then
migrations_count=0
else
migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
fi
if [ "$migrations_count" != "0" ]; then
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
exit 1
fi
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
# Step 2: run migrate:safe — the test. Before #530's fix in
# detectExistingSchema, this died at core/029 with a "column
# subject does not exist" error. After the fix, it should complete
# cleanly with every migration either applied or marked.
- name: Run migrate:safe against the recovery state
working-directory: ./backend
env:
NODE_ENV: production
DATABASE_CLIENT: pg
DB_HOST: localhost
DB_PORT: 5432
DB_USER: picpeak
DB_PASSWORD: testpass
DB_NAME: picpeak_drift
run: npm run migrate:safe
# Step 3: schema-shape assertion. A fresh install through migrate:
# safe produces 48 tables; the recovery scenario should converge
# to the same number. Off-by-one is fine but a 10+ table delta
# means a migration silently bailed in the recovery path.
- name: Assert final schema matches fresh-install shape
env:
PGPASSWORD: testpass
run: |
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
echo "Final table count: $tables"
# Allow a small drift window — exact count creeps over time as
# new migrations land; tight pin would force a workflow edit
# on every schema PR. 40+ is a healthy floor that catches the
# original bug (which left 17 tables) while staying robust to
# forward changes.
if [ "$tables" -lt 40 ]; then
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
echo "ok: schema converged to a fresh-install-equivalent shape."
# Step 4: verify the legacy migrations were all marked applied
# (rather than silently bailing inside the chain). The fix in
# detectExistingSchema marks legacy/* when the modern bootstrap
# is detected — confirm the markings actually landed.
- name: Assert legacy migrations marked applied
env:
PGPASSWORD: testpass
run: |
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
if [ "$legacy_count" -lt 7 ]; then
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
exit 1
fi
echo "ok: legacy migrations marked applied by detectExistingSchema."
+89
View File
@@ -0,0 +1,89 @@
name: Tests
# Runs the backend Jest suite and the frontend Vitest suite on every PR.
# Both suites already exist and cover the CRM service layer (quoteService,
# contractService, invoiceService.*, customerHoursService, eventService.
# calendar) plus the photo / settings / OG / auth surface — wiring them
# into CI makes regressions visible at PR time instead of post-merge.
#
# Six backend suites are excluded via --testPathIgnorePatterns. They
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
# regressions). Excluding them here keeps CI green from day 1; revisit
# each individually as its own fix.
#
# Triggers on any change that could affect either suite. The backend
# job intentionally omits frontend paths and vice versa so unrelated
# PRs don't pay both build costs.
on:
push:
branches: [main, beta]
pull_request:
branches: [main, beta]
workflow_dispatch:
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: ./backend
run: npm ci
- name: Run Jest suite
working-directory: ./backend
env:
# backupService tests would otherwise try a real S3 round-trip.
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
# adminSettings.logo — supertest fixture
# integration/adminPhotos.reference — supertest fixture
# integration/webhookDelivery — supertest fixture
# services/backupService.enhanced — knex mock chain
# routes/__tests__/adminAuth — supertest fixture
# (adminNotifications was excluded; #597 fix re-enables it.)
npx jest \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
--ci
frontend:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
working-directory: ./frontend
run: npm ci
- name: Run Vitest suite
working-directory: ./frontend
run: npm test -- --run
+98
View File
@@ -0,0 +1,98 @@
# What's New highlights — GitHub Models release step (reusable)
#
# Called by the release-please workflows AFTER a release is created
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
# as a job in the SAME workflow run rather than on its own `release: published`
# trigger, because release-please creates the release with the default
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
# events — a standalone `release:` workflow would simply never fire.
#
# What it does: condenses the new release's "### Features" into <=8 short
# bullets via GitHub Models (free tier, `models: read`) and injects a
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
# Features list for releases without it — so this is purely a quality upgrade,
# never a hard dependency. Failure is isolated by `continue-on-error` + the
# deterministic fallback below, so it can never break a release.
#
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
# step fails soft (continue-on-error) and the deterministic fallback produces
# the bullets instead — the feature works either way, Models just polishes them.
#
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
# real release notes; app parseWhatsNew() reads the block back).
name: What's New highlights
on:
workflow_call:
inputs:
tag:
description: Release tag to annotate (e.g. v2.3.0)
required: true
type: string
jobs:
highlights:
runs-on: ubuntu-latest
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
# GH_REPO at job scope so every `gh` call targets the right repo without
# needing an actions/checkout step. Without this, `gh` falls back to
# parsing `.git/config` in the runner's empty workspace and dies with
# "fatal: not a git repository" — which hard-fails the whole job before
# any continue-on-error can save it.
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Extract Features from the published release
id: feat
# Belt-and-braces: the job-level comment says "never let highlights
# break a release", but the original wiring only marked the AI +
# inject steps as continue-on-error. A hiccup here (rate limit,
# transient API error) would still hard-fail the job. Match the
# design intent and fail soft.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
BODY=$(gh release view "$TAG" --json body -q .body)
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
- name: Summarize with GitHub Models
if: ${{ steps.feat.outputs.features != '' }}
id: ai
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
uses: actions/ai-inference@v1
with:
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
system-prompt: >
You write release highlights for the admins of a self-hosted
photo-gallery + CRM app. Given raw changelog "Features" lines, output
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
no jargon, no issue numbers. One bullet per distinct user-visible
feature. Output ONLY "- " bullets, nothing else.
prompt: ${{ steps.feat.outputs.features }}
- name: Inject the What's New block
if: ${{ steps.feat.outputs.features != '' }}
continue-on-error: true # never let highlights break a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
AI: ${{ steps.ai.outputs.response }}
FEATURES: ${{ steps.feat.outputs.features }}
run: |
BULLETS="$AI"
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
if [ -z "$BULLETS" ]; then
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
fi
BODY=$(gh release view "$TAG" --json body -q .body)
# Idempotent: strip any prior block before re-injecting.
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
+3
View File
@@ -129,3 +129,6 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.42.0-beta.0"
".": "3.79.1-beta.0"
}
+1025
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
+43 -11
View File
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1. **Fork the repo** and create your branch from `main`
1. **Fork the repo** and create your branch from `main` (active development)
2. **Install dependencies**:
```bash
cd backend && npm install
@@ -50,7 +50,10 @@ Unsure where to begin? You can start by looking through these issues:
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Create a Pull Request**
6. **Attach a screenshot for any UI change** (see below)
7. **Create a Pull Request**
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
## 💻 Development Setup
@@ -79,6 +82,15 @@ cp .env.example .env
docker-compose -f docker-compose.dev.yml up
```
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
```bash
docker compose -f docker-compose.dev.yml up -d --build backend
# (or `frontend`, or both)
```
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
### Running Tests
```bash
@@ -144,17 +156,37 @@ picpeak/
│ └── public/ # Static assets
```
## 🌿 Branch model
PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
## 🔄 Release Process
1. Update version numbers in package.json files
2. Update CHANGELOG.md
3. Create a new release on GitHub
4. Docker images are automatically built and published
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
## 📮 Contact
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
+118 -29
View File
@@ -1,5 +1,13 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
@@ -49,6 +57,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
@@ -68,40 +77,57 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
### For Studios — CRM & Accounting (Beta · off by default)
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
git clone https://github.com/PicPeak/picpeak.git
cd picpeak
# Copy environment template
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
docker compose up -d
# Access at http://localhost:3000
```
Note on Docker file permissions (PUID/PGID)
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a nonroot user by default.
- Set `PUID` and `PGID` in your `.env` to match your host users UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
### First run — create your admin account
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 46 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
### Stable Channel (Recommended)
- Production-ready releases
@@ -150,7 +176,8 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
@@ -181,6 +208,7 @@ Perfect for:
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
## 🏗️ Tech Stack
@@ -295,7 +323,12 @@ For local development with a receiver on the same machine or docker network, set
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: 2GB minimum
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
decodes the full uncompressed frame before resize, and the default two
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
@@ -305,6 +338,26 @@ For local development with a receiver on the same machine or docker network, set
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Low-memory hosts
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
tuning the upload-processor concurrency down. The backend auto-detects
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
a one-shot warning. You can pin the value explicitly in `.env`:
```env
# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1
```
The trade-off is throughput: a single worker processes one photo at a
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
note**: if the backend dies under memory pressure, the gallery serves
`503 Service Unavailable` on thumbnails until Docker's
`restart: unless-stopped` brings the container back. Persistent 503s
during/after an upload batch on a low-memory host are almost always this.
### Video Support Requirements
When enabling video uploads, consider these additional resources:
@@ -338,17 +391,23 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de |
|---------|---------|---------|--------------|
| Self-Hosted | ✅ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited |
| Monthly Cost | $0 | $29-199 | €19-99 |
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
| Client Uploads | ✅ | ✅ | ✅ |
| API Access | ✅ | Paid | ❌ |
| Open Source | ✅ | ❌ | ❌ |
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|---------|---------|---------|--------------|----------|
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GBUnlimited*** |
| Client Uploads | ✅ | ✅ | ✅ | Limited |
| API Access | ✅ | Paid | ❌ | ❌ |
| Open Source | ✅ | ❌ | ❌ | ❌ |
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
*Limited only by your server storage
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
## 🛡️ Security
@@ -360,7 +419,7 @@ PicPeak takes security seriously:
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
@@ -405,6 +464,7 @@ These features are currently in beta testing and may have limited functionality
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
@@ -443,7 +503,8 @@ PicPeak is inspired by the best features of commercial platforms while remaining
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
@@ -458,6 +519,34 @@ This project was generated with the assistance of AI technology, but has been:
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
report and the accountant exports) ship seeded content and computed
figures that are intended as a **starting point only**:
- **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
Every operator must have their lawyer review and adapt them before
sending any contract to a customer.
- **QR-bills and SEPA EPC payloads** are rendered from the data you
typed. Picpeak is open source — please scan a test invoice with your
bank's app to check the QR actually works. We are not responsible for
any mistakes that come from sending an invoice with bad data on it.
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
from the data you enter and the defaults you configure. They are
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
rate) and filing duties differ by country and change over time. **Every
operator must check their own tax / VAT regulations and verify the
numbers with their accountant / Treuhänder / tax authority before
relying on any figure or export.** Picpeak makes no warranty that the
output is correct for your jurisdiction or situation.
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts, Invoices or Accounting features.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
@@ -476,7 +565,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
</p>
+94
View File
@@ -0,0 +1,94 @@
# Release Process
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
## TL;DR
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if `main` has been quiet and ready for promotion.
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
## Cadence target
46 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 46 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
## Promotion criteria
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
## How a stable release is cut
The actual mechanics, in order:
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
2. **Create the release branch from the `main` tip.**
```bash
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
```
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
## Versioning
PicPeak follows [Semantic Versioning](https://semver.org/) with one project-specific convention:
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Test-only changes** — same.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
## When this doc is wrong
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.
+3 -3
View File
@@ -16,7 +16,7 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+16 -3
View File
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
@@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🔑 First Login — Create Your Admin
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
### Direct Access (Simplest)
@@ -472,7 +485,7 @@ sudo -u picpeak node scripts/reset-admin-password.js
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
@@ -550,4 +563,4 @@ sudo ./picpeak-setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
+22 -9
View File
@@ -10,18 +10,31 @@ PORT=3001
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access
# login appears to succeed but the browser silently drops the
# cookie, leaving you in a redirect loop. Only set this if you
# ALWAYS reach the site via HTTPS)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
# req.secure from Express which respects X-Forwarded-Proto from a
# trusted reverse proxy. This is the default and is the right
# choice for most deployments.
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
# Why 'auto' is the default in production:
# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
# true → Secure flag is still emitted. No security regression vs. true.
# - On plain HTTP (LAN access, first-time install before reverse proxy is
# wired up), req.secure is false → Secure flag is omitted → login works
# instead of silently looping back to /admin/login.
#
# Requirements for auto mode:
# When you'd set this explicitly:
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
# defense in depth against accidentally serving over HTTP.
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
# don't want the per-request check (rare).
#
# Requirements for 'auto' mode to detect HTTPS correctly:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
+47 -8
View File
@@ -7,7 +7,7 @@ ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
@@ -30,17 +30,39 @@ WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
# privilege drop in wait-for-db.sh (see #484: container starts as root so it
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
# `ffprobe` built natively against musl libc — the npm
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
# PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback.
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see docs/accounting-inbound-invoices.md).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fc-cache -f
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -52,13 +74,30 @@ COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
# rasterises an SVG logo its <text> renders in the actual brand typeface
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
# internal family name and recurses into the per-family subdirectories.
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
USER nodejs
# No USER directive — the container starts as root so wait-for-db.sh can
# chown bind-mounted host directories to UID 1001 before dropping privs
# via su-exec. See #484 for the fresh-install restart loop this avoids.
EXPOSE 3000
# Healthcheck hits the same /health endpoint already used by the e2e
# runner and by the docker-compose `depends_on: condition: service_healthy`
# checks. wget is part of the Alpine base image. Long start-period covers
# the wait-for-db.sh delay before the Node process starts listening.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["./wait-for-db.sh", "node", "server.js"]
+3
View File
@@ -30,5 +30,8 @@ USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "run", "dev"]
@@ -0,0 +1,242 @@
/**
* Integration test for GET /api/admin/system-health/backup-coverage.
*
* Pins the Stage C diagnostic that tells admins what the next
* "Run Backup Now" will include, skip, or silently miss.
*
* Test surface:
* 1. Empty / fresh install → default seed (7 paths), inline mode,
* no DB dump on file yet, no drift
* 2. Toggle `include_in_default=false` → coverage flips to
* 'skipped-by-toggle'
* 3. Feature_flag gating reflects the actual app_settings value
* (events/archived ⇄ backup_include_archived)
* 4. Drift detection: a top-level subdir on disk with no
* `backup_paths` row is flagged in `unconfiguredOnDisk`
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
* 6. Scheduled-only mode + recent dump → `database.ok = true`
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
* and `lastDumpStale = true`
*
* Same auth/permission pass-through strategy as
* adminBackupIntegrity.test.js — we exercise the route's logic,
* not the auth middleware.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
let cleanup;
let storagePath;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkdir(rel) {
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
}
function rmdir(rel) {
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
}
async function restoreDefaultPaths() {
await db('backup_paths').del();
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
}
beforeEach(async () => {
await restoreDefaultPaths();
await db('database_backup_runs').del().catch(() => {});
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
});
it('returns the canonical 7 paths + database block on a fresh install', async () => {
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
const { report } = res.body;
expect(report.paths.map((p) => p.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Default mode is inline — no inline_dump setting present means
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
expect(report.database.mode).toBe('inline');
expect(report.database.ok).toBe(true);
expect(report.summary).toMatchObject({
configuredCount: 7,
tableMissingFallbackInUse: false,
});
});
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
expect(thumbnails.coverage).toBe('skipped-by-toggle');
expect(thumbnails.includeInDefault).toBe(false);
});
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
// backup_include_archived not set → archived skipped via flag
const off = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
expect(archivedOff.featureFlag).toBe('backup_include_archived');
expect(archivedOff.featureFlagValue).toBe(null); // unset
// Now set the flag — but path is missing on disk, so coverage
// resolves to 'missing-on-disk', proving the flag was honoured.
await db('app_settings').insert({
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const on = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOn.featureFlagValue).toBe(true);
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
});
it('detects unconfigured top-level subdirs as drift', async () => {
mkdir('events/active'); // configured
mkdir('plugin-store/cache'); // DRIFT
mkdir('shiny-new-feature/data'); // DRIFT
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
'plugin-store',
'shiny-new-feature',
]));
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
rmdir('plugin-store');
rmdir('shiny-new-feature');
});
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
mkdir('backups');
mkdir('tmp');
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
expect.arrayContaining(['backups', 'tmp']),
);
rmdir('backups');
rmdir('tmp');
});
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
fs.writeFileSync(recentDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(), // just now
status: 'completed',
backup_type: 'pg',
file_path: recentDump,
file_size_bytes: fs.statSync(recentDump).size,
destination_path: recentDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.mode).toBe('scheduled-only');
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
expect(res.body.report.database.lastDumpStale).toBe(false);
expect(res.body.report.database.ok).toBe(true);
});
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
fs.writeFileSync(oldDump, 'pretend old dump');
// 48 hours ago — well past the 26h staleness threshold. ISO
// string instead of a Date object because knex-sqlite's datetime
// serialisation has a quirk where some Date instances coerce to
// '[object Object]' on insert (the test 6 "recent dump" case
// passes only because `new Date()` happens to round-trip safely;
// arithmetic Dates don't).
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
await db('database_backup_runs').insert({
started_at: stale,
completed_at: stale,
status: 'completed',
backup_type: 'pg',
file_path: oldDump,
file_size_bytes: fs.statSync(oldDump).size,
destination_path: oldDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.lastDumpStale).toBe(true);
expect(res.body.report.database.ok).toBe(false);
// Top-level summary reflects the failed DB check.
expect(res.body.report.summary.databaseOk).toBe(false);
expect(res.body.report.summary.overallOk).toBe(false);
});
});
@@ -0,0 +1,140 @@
/**
* Integration test for GET /api/admin/system-health/backup-integrity.
*
* Auth + permission middleware are mocked to pass-through so the test
* focuses on the route's own behaviour: scope-param validation, the
* successResponse envelope, and that the underlying service report
* surfaces correctly in the JSON body.
*
* The verifier service itself is exercised against the real schema
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Pass-through auth so we don't need to mint JWTs.
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
// Pass-through permissions so settings.view always allows.
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
let db;
let customerId;
let app;
let storagePath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
// Mount the route on a minimal Express app. Cold-require after
// bootCrmDb so the route's downstream `require('../database/db')`
// sees the same db instance.
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('contracts').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quotes').del().catch(() => {});
});
it('returns a report envelope when nothing references any path', async () => {
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
expect(res.body.report.summary).toMatchObject({
totalRows: 0,
missingFiles: 0,
hashMismatches: 0,
verifiedOk: 0,
existsButNoHash: 0,
});
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
it('surfaces a missing file in the response payload', async () => {
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
created_at: new Date(),
});
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body.report.summary.missingFiles).toBe(1);
expect(res.body.report.missing[0]).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
});
});
it('honours the ?scope=invoice filter', async () => {
// Seed both an invoice and a contract with missing files. With
// scope=invoice the contract row must not appear.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
due_date: '2026-01-31',
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
created_at: new Date(),
});
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'invoice' });
expect(res.status).toBe(200);
expect(res.body.report.scopes).toEqual(['invoice']);
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
});
it('rejects an unknown scope with 400 + a code', async () => {
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'gallery' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
expect(res.body.validScopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
});
@@ -0,0 +1,88 @@
/**
* Regression net for the business-docs coverage gap fixed in this PR.
*
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
* list of storage subdirectories (events/active, events/archived,
* thumbnails, previews, heroes, uploads) and silently omitted the
* entire `business-docs/` tree. That meant every CRM PDF + signature
* drawing — quotes, contracts (system-rendered + wet uploads),
* invoices, Storno, imported historical invoices, and the customer
* signature PNG/JPG drawn on the public signing page — fell outside
* the in-app scheduled backup, leaving every `*_path` column on
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
*
* The fix is a single `scanDirectory(business-docs, ...)` call. This
* suite pins the contract so a future refactor of the walker cannot
* silently drop business-docs again.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
describe('backupService — business-docs is in the backup walker', () => {
let cleanup;
let backupService;
let storagePath;
beforeAll(async () => {
({ cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
// Cold-require after bootCrmDb so backupService picks up the same
// db instance + STORAGE_PATH the test harness configured.
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seed(relPath, content = 'dummy bytes for backup test') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
it('does not error when business-docs is absent', async () => {
// Fresh harness has no business-docs/ tree at all. The walker
// must short-circuit on ENOENT rather than throw — installs that
// never used CRM features have to keep backing up fine.
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
});
it('picks up every CRM-relevant business-docs subdirectory', async () => {
// Seed one file in each of the five subpaths the renderer + import
// routes write to. The signature path is the one most prone to be
// forgotten — it lives one level deeper than the others (per-
// contract subfolder, not per-year).
seed('business-docs/quote/2026/Q-001.pdf');
seed('business-docs/contract/2026/C-001.pdf');
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
seed('business-docs/invoice/2026/INV-001.pdf');
seed('business-docs/invoice-imports/2026/scan.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'business-docs/quote/2026/Q-001.pdf',
'business-docs/contract/2026/C-001.pdf',
'business-docs/contract/signatures/42/customer-1700000000000.png',
'business-docs/invoice/2026/INV-001.pdf',
'business-docs/invoice-imports/2026/scan.pdf',
]));
});
it('walks newly-created business-docs files without needing a restart', async () => {
// The walker reads the filesystem live on every call; this guards
// against a future "cache the scan result at boot" optimisation
// that would miss freshly-written PDFs (which is exactly what
// happens during normal operation — every send writes a new file).
seed('business-docs/invoice/2027/INV-NEW.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
});
});
@@ -0,0 +1,180 @@
/**
* Pins the Stage-B refactor that lifted the file-backup walker's
* subdirectory list out of hard-coded JS into the `backup_paths`
* table seeded by migration 109.
*
* Scenarios:
* 1. Walker reads canonical seed → all 7 default subdirs walked
* 2. include_in_default=false on one row → that subdir is skipped
* 3. New row inserted at runtime → walker picks it up without restart
* 4. feature_flag gating → row only walked when the named app_settings
* boolean is truthy (mirrors historical `includeArchived` behavior)
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
* in depth — never silently scans nothing)
*
* Why not stub `db('backup_paths')`: the whole point of Stage B is
* that the walker is now data-driven, so the test has to actually
* mutate the table and observe the walker's output change. Stubs
* would re-introduce the hard-coding the refactor is meant to remove.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Restore canonical seed before every test. Tests mutate this table
// freely; the next test starts from a known state.
await db('backup_paths').del();
const {
DEFAULT_PATHS,
} = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
});
it('migration 109 seeds the canonical 7 paths', async () => {
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
expect(rows.map((r) => r.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Only events/archived is gated by a feature flag.
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
'events/archived',
]);
});
it('walks every default subdir when files are present', async () => {
seedFile('events/active/E1/a.jpg');
seedFile('thumbnails/E1/a.jpg');
seedFile('previews/E1/a.jpg');
seedFile('heroes/E1/hero.jpg');
seedFile('uploads/intake/x.bin');
seedFile('business-docs/quote/2026/Q-001.pdf');
// events/archived is gated — left out of this test; covered below.
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'events/active/E1/a.jpg',
'thumbnails/E1/a.jpg',
'previews/E1/a.jpg',
'heroes/E1/hero.jpg',
'uploads/intake/x.bin',
'business-docs/quote/2026/Q-001.pdf',
]));
});
it('skips a path when include_in_default is toggled off', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('picks up a new path inserted at runtime — no restart needed', async () => {
// Simulates a future feature shipping its own subdirectory and
// self-healing a `backup_paths` row at boot.
await db('backup_paths').insert({
path: 'plugin-store',
include_in_default: true,
feature_flag: null,
display_order: 200,
description: 'Hypothetical future feature payload',
created_at: new Date(),
updated_at: new Date(),
});
seedFile('plugin-store/cache/payload.bin');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('plugin-store/cache/payload.bin');
});
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
seedFile('events/active/E1/active.jpg');
seedFile('events/archived/E2/archived.jpg');
// backup_include_archived=false → archived/ is skipped.
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
const relsOff = filesOff.map((f) => f.relativePath);
expect(relsOff).toContain('events/active/E1/active.jpg');
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
// backup_include_archived=true → archived/ is included.
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
const relsOn = filesOn.map((f) => f.relativePath);
expect(relsOn).toContain('events/archived/E2/archived.jpg');
});
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
// Defense in depth: even if seed-and-self-heal both failed, the
// walker must still cover the historical set so "Run Backup Now"
// cannot silently degrade to no-op.
await db('backup_paths').del();
seedFile('events/active/E1/photo.jpg');
seedFile('business-docs/quote/2026/Q-002.pdf');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
});
it('legacy boolean call signature still works (backward compat)', async () => {
// Existing call sites (and the businessDocs regression test) pass
// a boolean for `includeArchived`. Refactor must not break them.
seedFile('events/archived/E3/legacy.jpg');
const filesOff = await backupService.getFilesToBackup(false);
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
});
@@ -0,0 +1,188 @@
/**
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
*
* The previous behaviour was: file-backup looked up an existing dump via
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
* when none was found. Admins clicking "Run Backup Now" got an apparent
* success that omitted every customer / quote / invoice / contract row —
* the data-loss footgun that this commit closes.
*
* Five scenarios under test:
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
* 2. Default, dump throws → run aborts, backup_runs row marked failed
* 3. Opt-out + recent DB dump available → backup proceeds
* 4. Opt-out + no DB dump available → fail loud
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
*
* Mocking strategy: the underlying `databaseBackupService.backup()` and
* the local-destination writer are stubbed so the test exercises just
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
* binaries being available in the test environment.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
const mockBackupFn = jest.fn();
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: { backup: mockBackupFn },
startScheduledBackups: jest.fn(),
stopScheduledBackups: jest.fn(),
DatabaseBackupService: class {},
}));
jest.setTimeout(30000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let dumpFileAbs;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
// Seed backup destination settings so the run can proceed past the
// "destination not configured" guard.
const dest = path.join(storagePath, 'backups');
fs.mkdirSync(dest, { recursive: true });
// getBackupConfigInternal filters by setting_type='backup', so the
// tests have to seed with that type or the resolver returns
// `{ ... }` with the keys missing — runBackup then sees
// `backup_destination_type === undefined` and bails before our
// new guard runs.
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
]).onConflict('setting_key').merge();
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
// Reused/mutated per-test via the database_backup_runs seed below.
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
// Neutralise the file-scan step: we don't care which files would
// be backed up, just whether the run reaches that stage at all.
backupService.getFilesToBackup = jest.fn(async () => []);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockBackupFn.mockReset();
// Default to "dump produced this file with this size" — the per-test
// setup overrides as needed.
mockBackupFn.mockResolvedValue({
success: true,
path: dumpFileAbs,
size: fs.statSync(dumpFileAbs).size,
duration: 1,
checksum: 'abc',
});
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
// resolves against (its query is `status='completed'` + most recent).
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: dumpFileAbs,
file_size_bytes: fs.statSync(dumpFileAbs).size,
destination_path: dumpFileAbs,
});
});
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
// Inline-dump setting is unset (undefined) — default is ON.
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
await backupService.runBackup(true);
expect(mockBackupFn).toHaveBeenCalledTimes(1);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
expect(run.error_message).toBeNull();
});
it('aborts the run when the inline dump throws', async () => {
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/pg_dump segfaulted/);
});
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
await backupService.runBackup(true);
expect(mockBackupFn).not.toHaveBeenCalled();
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
});
it('opt-out + no recent dump: fails loud with a clear error', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
await db('database_backup_runs').del();
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/No database backup available/);
});
it('opt-out + 0-byte dump file: fails loud', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
fs.writeFileSync(emptyDump, '');
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: emptyDump,
file_size_bytes: 0,
destination_path: emptyDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/is empty/);
});
});
@@ -0,0 +1,180 @@
/**
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
*
* Pins the new `computePerPathStats` logic that the Backup History
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
*
* Three scenarios:
* 1. Single file under one path — straightforward attribution
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
* (e.g. `events/active/E1/x.jpg` should attribute to
* `events/active`, not `events`)
* 3. File outside any configured path — silently dropped, doesn't
* throw or contaminate other buckets
*
* Tests exercise the EXPORTED side: write a backup_runs row via the
* service entry point and assert the statistics JSON shape. We don't
* stub `computePerPathStats` directly — the integration view is what
* the frontend actually consumes.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkFile(rel, content = 'x'.repeat(100)) {
const abs = path.join(storagePath, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Clean slate of any artefacts from prior tests
await db('backup_runs').del();
await db('app_settings').where('setting_type', 'backup').del();
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
]).onConflict('setting_key').merge();
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
// Restore canonical backup_paths from migration 109
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').del();
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
// Wipe leftover files between tests
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
const p = path.join(storagePath, dir);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
});
it('attributes files to their owning backup_paths row', async () => {
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
// Disable the inline DB dump so we don't need pg_dump in tests;
// the file walker is what produces per_path.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
expect(statsRaw.per_path).toBeDefined();
// events/active should have 2 files (3000 bytes)
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
// business-docs should have 1 file (500 bytes)
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
// thumbnails should have 1 file (50 bytes)
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
// No spurious buckets for paths that had nothing
expect(statsRaw.per_path['previews']).toBeUndefined();
expect(statsRaw.per_path['heroes']).toBeUndefined();
});
it('archived path attributed separately from active when both have files', async () => {
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
// backup_include_archived already set true in beforeEach so the
// archived walker fires; same opt-out for inline DB dump.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
// events/active and events/archived attribute separately —
// longest-prefix match prevents `events/active/...` from claiming
// an `events/archived/...` file or vice versa.
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
});
});
// NOTE on walker duplication
//
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
// another at `events/active`), the walker scans the same files twice
// — once via each path. Per-path stats then attribute the file to the
// longest-prefix-matching path BOTH times, producing inflated counts.
//
// The canonical seed in migration 109 contains no overlapping pairs,
// so this isn't exercised in practice. But an admin who hand-adds a
// broad row that overlaps an existing nested one will see double
// counts in their next backup's statistics + the destination will
// receive duplicate copies (wasting space). Worth flagging if anyone
// reports it — the fix is to de-dupe `files` in
// `getFilesToBackupInternal` before returning, OR to skip walking a
// path if a longer one has already covered it.
@@ -0,0 +1,143 @@
/**
* Smoke tests for backupService's config resolution + file-collection
* and manifest validation paths — safety net ahead of the god-file
* decomposition.
*
* Uses the same real-SQLite harness as
* backupService.configurableWalker.test.js (bootCrmDb + a temp
* STORAGE_PATH) rather than the broken deep-mock approach in
* backupService.enhanced.test.js.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let backupManifest;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
backupManifest = require('../../src/services/backupManifest');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('app_settings').del();
// Reset the storage tree so each test starts from a pristine walk.
await fs.promises.rm(storagePath, { recursive: true, force: true });
await fs.promises.mkdir(storagePath, { recursive: true });
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return abs;
}
async function insertBackupSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: value,
setting_type: 'backup',
});
}
describe('getBackupConfig', () => {
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
await insertBackupSetting('backup_enabled', 'true');
await insertBackupSetting('backup_include_archived', 'false');
await insertBackupSetting('backup_retention_days', '30');
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
// Non-backup settings must not leak into the backup config.
await db('app_settings').insert({
setting_key: 'general_site_name',
setting_value: 'PicPeak',
setting_type: 'general',
});
const config = await backupService.getBackupConfig();
expect(config.backup_enabled).toBe(true);
expect(config.backup_include_archived).toBe(false);
expect(config.backup_retention_days).toBe(30);
expect(config.backup_destination_path).toBe('/backups/picpeak');
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
expect(config).not.toHaveProperty('general_site_name');
// Raw (unparsed) values are preserved on the non-enumerable __raw.
expect(String(config.__raw.backup_retention_days)).toBe('30');
});
it('returns an empty config object (not null) when nothing is configured', async () => {
const config = await backupService.getBackupConfig();
expect(config).not.toBeNull();
expect(Object.keys(config)).toHaveLength(0);
});
});
describe('getFilesToBackup', () => {
it('returns an empty list on a pristine storage tree', async () => {
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
expect(files).toEqual([]);
});
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
const content = 'not really a jpeg';
const abs = seedFile('events/active/E9/pic.jpg', content);
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
expect(entry).toBeDefined();
expect(entry.path).toBe(abs);
expect(entry.size).toBe(Buffer.byteLength(content));
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
// realm under Jest and fails the cross-realm instanceof check.
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
});
});
describe('validateBackupManifest', () => {
it('round-trips a generated manifest as valid', async () => {
seedFile('events/active/E1/a.jpg', 'aaa');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const manifest = await backupManifest.generateManifest({
backupType: 'full',
backupPath: '/backup/run-1',
files,
});
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
await backupManifest.saveManifest(manifest, manifestPath, 'json');
const result = await backupService.validateBackupManifest(manifestPath);
expect(result.valid).toBe(true);
expect(result.manifest.backup.type).toBe('full');
expect(result.manifest.files.count).toBe(files.length);
expect(result.manifest.verification.total_checksum).toBeTruthy();
});
it('flags a manifest missing required sections as invalid', async () => {
const badPath = path.join(storagePath, 'manifest-broken.json');
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
const result = await backupService.validateBackupManifest(badPath);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/Missing required section/);
});
});
});
@@ -0,0 +1,170 @@
/**
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
* never auto-sends them before the workflow's review gate + explicit
* send_document.
*/
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
quoteService = require('../../src/services/quoteService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function acceptedQuote() {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
}
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
});
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled');
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
});
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const ev = await db('events').where({ id: res.eventId }).first();
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
// Every invoice the event scheduled is held (no auto-send before the gate).
const invs = await db('invoices').whereIn('id', res.invoiceIds);
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
// again for it (the flow's prepare_invoice adopts these ids instead).
const q = await db('quotes').where({ id: quoteId }).first();
expect(q.converted_event_id).toBe(res.eventId);
});
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
// Reproduces the booking_invoice_only flow on a quote with no explicit
// payment timing: the default installment is after_delivery, which would
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
const dealUuid = crypto.randomUUID();
const [quoteId] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
// 100% after_delivery installment.
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
});
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
const mk = async (lockOffsetMs) => {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF', issue_date: '2026-01-01',
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
responded_at: new Date().toISOString(),
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
accepted_at: new Date().toISOString(),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
};
const openId = await mk(15 * 60 * 1000); // still inside the window
const lockedId = await mk(-60 * 1000); // window already closed
const emitted = await quoteService.finalizeQuoteResponses();
expect(emitted).toBeGreaterThanOrEqual(1);
const open = await db('quotes').where({ id: openId }).first();
const locked = await db('quotes').where({ id: lockedId }).first();
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
const again = await db('quotes').where({ id: lockedId })
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
expect(again).toBe(0);
});
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(res.invoiceIds).toEqual([]);
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
});
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
const quoteId = await acceptedQuote();
const newId = await quoteService.duplicateQuote(quoteId, adminId);
expect(newId).toBeGreaterThanOrEqual(1);
expect(newId).not.toBe(quoteId);
const q = await db('quotes').where({ id: newId }).first();
expect(q.status).toBe('draft');
});
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
expect(typeof registry.getAction(a)).toBe('function');
}
});
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
const contractService = require('../../src/services/contractService');
const quoteId = await acceptedQuote();
const res = await contractService.createFromQuote(quoteId, adminId);
expect(res.contractId).toBeGreaterThanOrEqual(1);
expect(res.alreadyConverted).toBe(false);
const c = await db('contracts').where({ id: res.contractId }).first();
expect(c).toBeTruthy();
});
});
@@ -0,0 +1,116 @@
/**
* Schema-shape regression net for the CRM consolidated migration.
*
* Pins the table/column layout that the route + service layer expect
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
* forward-reference deferral added in this PR), but it doesn't notice
* if a future edit silently drops a column the service code reads —
* SQLite would just return undefined and the broken behavior would
* land on beta.
*
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
* so a rename or removal there fails the test instead of silently
* breaking the lineage card.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('CRM schema after core migrations', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('table layout', () => {
const expectedTables = [
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
'events', 'document_sequences',
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
'customer_hour_entries',
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
'event_payment_plans',
];
it.each(expectedTables)('has table %s', async (table) => {
expect(await db.schema.hasTable(table)).toBe(true);
});
});
describe('deal_uuid lineage columns', () => {
// Every document in one engagement shares a deal_uuid — the
// lineage card joins on it. Drop the column anywhere in the chain
// and the card silently returns partial data.
it.each(['quotes', 'contracts', 'invoices'])(
'%s has deal_uuid column',
async (table) => {
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
}
);
// The back-pointer FKs were the source of the schema-drift bug
// we fixed in this PR (forward references). Pin them.
it('quotes has converted_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
});
it('invoices has source_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
});
it('invoices has source_quote_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
});
});
describe('Storno discriminator columns', () => {
// kind='storno' + cancels_invoice_id + negative totals are the
// shape every aggregate filter relies on (feedback_storno_filter_
// everywhere). Pin the columns so a rename doesn't silently break
// every revenue report.
it('invoices has kind discriminator', async () => {
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
});
it('invoices has cancels_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
});
it('invoices has replaces_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
});
});
describe('Event time columns (migration 137)', () => {
// The admin calendar reads these to render timed vs. full-day
// tiles. Per the feedback_migration_preserve_visuals rule, the
// default has to be `is_full_day=true` so existing rows keep
// their pre-migration visual.
it('events has event_time_start', async () => {
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
});
it('events has event_time_end', async () => {
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
});
it('events has is_full_day', async () => {
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
});
});
describe('seed paths', () => {
it('admin + customer seed inserts cleanly', async () => {
const { adminId, customerId } = await seedMinimal(db);
expect(adminId).toBeTruthy();
expect(customerId).toBeTruthy();
const admin = await db('admin_users').where({ id: adminId }).first();
const customer = await db('customer_accounts').where({ id: customerId }).first();
expect(admin.email).toBe('tester@example.com');
expect(customer.email).toBe('customer@example.com');
});
});
});
@@ -0,0 +1,79 @@
/**
* Negative line items (Rabatt / manual discount lines) are accepted
* end-to-end as long as the resulting total stays ≥ 0. When the
* discount would drive the total negative, the service rejects with
* a clear, code-tagged error so the admin is steered to Storno for
* credit-note workflows.
*
* Touches the actual createInvoice / createQuote service paths so a
* future change to either computeTotals or the guard fires this test.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(30000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
let cleanup;
let adminId;
let customerId;
let invoiceService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
invoiceService = require('../../src/services/invoiceService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Quote-side coverage of the symmetric validator + guard is
// deliberately omitted: createQuote's init path takes ~30 s under
// this harness (something in pdfService / emailProcessor cold-
// require), which would push the suite well past CI's per-test
// budget. The shape of the guard is identical to the invoice one
// covered below; a future change to extract the slow init or to
// stub it for tests should re-enable a parallel quote test.
describe('invoices', () => {
it('accepts a negative-price line and computes the net correctly', async () => {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
],
}, adminId);
expect(Array.isArray(invoiceIds)).toBe(true);
expect(invoiceIds.length).toBe(1);
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
expect(row.net_amount_minor).toBe(15000);
expect(row.total_amount_minor).toBe(15000);
});
it('rejects when the discount drives the total negative', async () => {
await expect(invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
],
}, adminId)).rejects.toMatchObject({
code: 'INVOICE_TOTAL_NEGATIVE',
statusCode: 400,
});
});
});
});
@@ -0,0 +1,98 @@
/**
* Boot-time email-template self-heal:
* 1. Seeds the CRM / contract / event-reminder templates on an
* install that's never had them before.
* 2. Recovers email_queue rows that previously exhausted their
* retries because their template was missing.
*
* The failure that triggered this fix (2026-05-27) had Ralf's beta
* box failing every `quote_sent` / `invoice_sent` send for ~14h
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
* defined but never called. After 3 retries the rows sat in
* status='pending' forever; nothing in the admin UI signalled the
* problem. Both halves of that regression are covered here.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('email template self-heal at boot', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
// 107_crm_consolidated documents the deliberate split (templates
// are self-healed at runtime, not inserted by the migration).
const before = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(before).toEqual([]);
// Seed a stuck queue row that mirrors what we found on Ralf's box:
// quote_sent send attempted 3 times, each time failed because the
// template didn't exist, queue processor gave up.
const queueRowIds = await db('email_queue').insert({
recipient_email: 'customer@example.com',
email_type: 'quote_sent',
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
status: 'pending',
retry_count: 3,
error_message: "Email template 'quote_sent' not found",
created_at: new Date(),
}).returning('id');
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
// Also seed an UNRELATED stuck row (different template, NOT one
// we're going to insert) to confirm the recovery is targeted —
// it must not blanket-reset every retry-exhausted row.
const unrelatedIds = await db('email_queue').insert({
recipient_email: 'someone@example.com',
email_type: 'some_other_template',
email_data: JSON.stringify({}),
status: 'pending',
retry_count: 3,
error_message: 'SMTP timeout',
created_at: new Date(),
}).returning('id');
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
// The seeders use module-level caches (`_seeded = true`). When
// jest runs this test in isolation that cache starts fresh; in
// the full suite no other test currently calls these seeders, so
// the first call here also runs the real work. Reset the cache
// defensively in case a future test changes that.
jest.resetModules();
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
// Templates landed.
expect(result.seeded).toEqual(expect.arrayContaining([
'quote_sent', 'invoice_sent', 'storno_issued',
]));
const after = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
// Stuck quote_sent row was recovered.
expect(result.recovered).toBeGreaterThanOrEqual(1);
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
expect(recoveredRow.retry_count).toBe(0);
expect(recoveredRow.error_message).toBeNull();
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
// Unrelated stuck row was NOT touched.
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
expect(unrelatedRow.retry_count).toBe(3);
expect(unrelatedRow.error_message).toBe('SMTP timeout');
});
});
@@ -0,0 +1,64 @@
/**
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
* old slug, so a rename behaves like a rename rather than silently detaching
* existing events/quotes and orphaning the per-type pre-event reminder template.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(30000);
describe('event type slug rename cascade', () => {
let db;
let cleanup;
let customerId;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-points events + quotes + the reminder template from old slug to new', async () => {
// A non-system event type with slug 'party'.
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
// An authored per-type reminder template + an event + a quote, all on 'party'.
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
await db('events').insert({
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
event_name: 'A party', event_date: '2026-09-01',
});
await db('quotes').insert({
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
});
// Rename the slug.
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
// Event + quote follow the rename.
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
// The authored reminder template moved (subject/body preserved), old key gone.
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
expect(moved).toBeTruthy();
expect(moved.subject_en).toBe('Party reminder');
});
it('does not clobber an existing template for the new slug', async () => {
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
// Target already existed → left intact; source not force-merged over it.
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
.toBe('existing soiree');
});
});
@@ -0,0 +1,231 @@
/**
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
*
* Verifies the contract the public route is expected to honour:
* - Browser UA → 302 to target_path
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
* - Unknown slug → 404 Not Found
* - Hit count increments after successful resolutions (both shapes)
*
* Mirrors the production server.js wiring but doesn't load the whole
* server — the surrounding middleware (CORS, helmet, rate limiters)
* isn't part of this route's contract.
*/
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Persist a business_profile + business_name so buildOgMetadata's
// settings-based fields populate consistently.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
service = require('../../src/services/galleryShortUrlService');
const {
isSocialCrawler, buildOgMetadata, renderOgHtml,
} = require('../../src/services/galleryOgService');
app = express();
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await service.findByShortSlug(req.params.shortSlug);
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
if (isSocialCrawler(req.get('user-agent'))) {
const event = await db('events').where({ id: row.event_id }).first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
service.recordHit(row.id).catch(() => {});
return;
}
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
service.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
return res.status(500).type('text/plain').send(err.message);
}
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [eventId] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const row = await service.createShortUrl({
eventId, customSlug: shortSlug,
});
return { eventId, shortUrl: row };
}
// User-agent strings the production `isSocialCrawler` helper matches.
// Snapshot known-true samples here so the test stays in sync if the
// helper's allowlist evolves.
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
it('redirects to the snapshotted target_path with a 302', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'browser-redirect', shortSlug: 'go-here',
});
const res = await request(app)
.get('/s/go-here')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(shortUrl.target_path);
expect(res.headers.location).toMatch(/^\/gallery\//);
});
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
await seedEventAndShortUrl({
slug: 'hit-browser', shortSlug: 'hit-from-browser',
});
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-browser');
expect(row.hit_count).toBe(1);
expect(row.last_hit_at).toBeTruthy();
});
});
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
it('returns 200 with OG HTML for WhatsApp UA', async () => {
await seedEventAndShortUrl({
slug: 'whatsapp-og', shortSlug: 'wa-preview',
});
const res = await request(app)
.get('/s/wa-preview')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<meta');
expect(res.text).toMatch(/og:title/);
expect(res.text).toMatch(/og:url/);
});
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
await seedEventAndShortUrl({
slug: 'canonical-test', shortSlug: 'canonical-short',
});
const res = await request(app)
.get('/s/canonical-short')
.set('User-Agent', BOT_UA_FACEBOOK);
expect(res.status).toBe(200);
// The og:url meta tag must contain the short-URL path, not the
// /gallery/<slug> path — this is the cache-key invariant from #699.
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
expect(res.text).not.toMatch(
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
);
});
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
await seedEventAndShortUrl({
slug: 'cache-header', shortSlug: 'cache-test',
});
const res = await request(app)
.get('/s/cache-test')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.headers['cache-control']).toMatch(/public/);
expect(res.headers['cache-control']).toMatch(/max-age=300/);
});
it('increments hit_count on a crawler hit as well', async () => {
await seedEventAndShortUrl({
slug: 'hit-bot', shortSlug: 'hit-from-bot',
});
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-bot');
expect(row.hit_count).toBe(1);
});
});
describe('GET /s/:shortSlug — error states', () => {
it('404 for an unknown slug', async () => {
const res = await request(app)
.get('/s/never-existed')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'gone-test', shortSlug: 'gone-slug',
});
await service.softDelete(shortUrl.id, null);
const res = await request(app)
.get('/s/gone-slug')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(410);
});
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
const { eventId } = await seedEventAndShortUrl({
slug: 'orphan-test', shortSlug: 'orphan-slug',
});
// Hard-delete the event row (FK CASCADE would normally clean up the
// short URL too — but if CASCADE didn't fire for whatever reason
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
// resolver should still degrade safely).
// SQLite's foreign_keys pragma is OFF by default; the migration
// doesn't toggle it, so this delete leaves the short URL row.
await db('events').where({ id: eventId }).delete();
const res = await request(app)
.get('/s/orphan-slug')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(410);
});
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
const res = await request(app)
.get('/s/UPPER_CASE')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
});
describe('Regression — existing URL paths must still respond the same', () => {
// The /s/* namespace is additive: it must NOT shadow /gallery/*
// or any of the OG routes. We don't load the whole app here, but we
// can at least pin that the route param doesn't accept slashes —
// i.e. /s/foo/bar must NOT be matched by our handler.
it('the /s/:shortSlug route does not match nested paths', async () => {
const res = await request(app)
.get('/s/foo/bar')
.set('User-Agent', BROWSER_UA);
// Express returns its default 404 when no route matches the path.
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,282 @@
/**
* Integration tests for the branded short-URL service (#699).
*
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
* + recordHit against a real SQLite DB, including the contracts that
* matter for production correctness:
*
* - Custom slug + collision detection (409 with `suggested`)
* - Auto-generated slug from event slug + year
* - Soft-delete preserves the row (admin can audit)
* - target_path snapshots at create time (toggling the global
* "Use short gallery URLs" setting later doesn't change existing
* short URLs — backward-compat invariant from #699)
* - hit_count increments idempotently
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
*
* Boots one DB for the whole file (cheap on SQLite); each test seeds
* its own event row to keep scope clean.
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Minimal admin for created_by audit.
const adminInsert = await db('admin_users').insert({
username: 'shorturl-test',
email: 'shorturl@example.com',
password_hash: 'x',
must_change_password: false,
created_at: new Date(),
}).returning('id');
adminId = adminInsert[0]?.id ?? adminInsert[0];
service = require('../../src/services/galleryShortUrlService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
// Each test seeds a fresh event so collisions / counter state don't leak.
async function seedEvent(overrides = {}) {
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: overrides.event_name || 'Test Wedding',
event_date: overrides.event_date || '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const event = await db('events').where({ id }).first();
return event;
}
describe('createShortUrl — custom slug', () => {
it('creates with a custom slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-1' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'sofia-graduation-1',
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-1');
expect(row.target_path).toBe(`/gallery/${event.slug}`);
expect(row.event_id).toBe(event.id);
expect(row.hit_count).toBe(0);
});
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-2' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'Sofia-GraduAtion-2', // mixed case
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-2');
});
it('rejects an invalid slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'invalid-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'invalid slug with spaces',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a reserved slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'reserved-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'admin',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
const event1 = await seedEvent({ slug: 'dup-test-1' });
const event2 = await seedEvent({ slug: 'dup-test-2' });
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
await expect(service.createShortUrl({
eventId: event2.id, customSlug: 'collide-me',
})).rejects.toMatchObject({
code: 'SLUG_TAKEN',
suggested: expect.any(String),
});
});
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
await expect(service.createShortUrl({
eventId: 9999999, customSlug: 'no-event',
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
});
});
describe('createShortUrl — auto-generated slug', () => {
it('uses event slug + year when no custom slug provided', async () => {
const event = await seedEvent({
slug: 'autogen-wedding', event_date: '2026-06-05',
});
const row = await service.createShortUrl({
eventId: event.id,
createdBy: adminId,
});
// First-choice candidate is just the slug; takes that.
expect(row.short_slug).toBe('autogen-wedding');
});
it('falls back to slug-year when the bare slug is already taken', async () => {
// Both events SHARE the same canonical slug so the first-choice
// bare-slug candidate is burned, forcing autoGen to try the
// year-suffixed variant.
const event1 = await seedEvent({
slug: 'collide-base', event_date: '2026-07-01',
});
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base',
});
const event2 = await seedEvent({
slug: 'collide-base-2', event_date: '2026-07-01',
});
// Force the bare candidate of event2 to also collide by burning it.
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base-2',
});
const row = await service.createShortUrl({
eventId: event2.id, // No custom — auto-gen from event2.slug
});
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
expect(row.short_slug).toBe('collide-base-2-2026');
});
});
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
const event = await seedEvent({ slug: 'snapshot-off' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-off',
});
expect(row.target_path).toBe(`/gallery/${event.slug}`);
});
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
// Persist the setting.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
try {
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-on',
});
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
// CRITICAL backward-compat invariant: now flip the setting OFF.
// Existing short URLs must still resolve to the same target_path
// they were created with — operator's existing share links don't
// silently change behaviour.
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
const refetched = await service.findByShortSlug('snap-on');
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
} finally {
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
}
});
});
describe('findByShortSlug + listForEvent', () => {
it('returns null for an unknown slug', async () => {
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
});
it('returns null for a malformed slug (no DB hit)', async () => {
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
expect(await service.findByShortSlug('with spaces')).toBeNull();
expect(await service.findByShortSlug('')).toBeNull();
});
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
const event = await seedEvent({ slug: 'softdel-find' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'find-deleted',
});
await service.softDelete(created.id, adminId);
const fetched = await service.findByShortSlug('find-deleted');
expect(fetched).not.toBeNull();
expect(fetched.deleted_at).toBeTruthy();
});
it('listForEvent excludes soft-deleted rows', async () => {
const event = await seedEvent({ slug: 'list-test' });
const live = await service.createShortUrl({
eventId: event.id, customSlug: 'list-live',
});
const deleted = await service.createShortUrl({
eventId: event.id, customSlug: 'list-deleted',
});
await service.softDelete(deleted.id, adminId);
const list = await service.listForEvent(event.id);
const ids = list.map((r) => r.id);
expect(ids).toContain(live.id);
expect(ids).not.toContain(deleted.id);
});
});
describe('softDelete', () => {
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
const event = await seedEvent({ slug: 'softdel-idem' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'idem-delete',
});
expect(await service.softDelete(created.id, adminId)).toBe(true);
expect(await service.softDelete(created.id, adminId)).toBe(false);
});
it('returns false for an unknown id (caller maps to 404)', async () => {
expect(await service.softDelete(9999999, adminId)).toBe(false);
});
});
describe('createShortUrl after soft-delete — slug rotation', () => {
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
const event = await seedEvent({ slug: 'rotate' });
const first = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
await service.softDelete(first.id, adminId);
// The slug is now reclaimable for a fresh row.
const second = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
expect(second.id).not.toBe(first.id);
expect(second.short_slug).toBe('rotate-me');
});
});
describe('recordHit', () => {
it('increments hit_count + stamps last_hit_at', async () => {
const event = await seedEvent({ slug: 'hit-counter' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'count-me',
});
await service.recordHit(row.id);
await service.recordHit(row.id);
const fetched = await service.findByShortSlug('count-me');
expect(fetched.hit_count).toBe(2);
expect(fetched.last_hit_at).toBeTruthy();
});
it('is fire-and-forget — invalid id does not throw', async () => {
await expect(service.recordHit(9999999)).resolves.not.toThrow();
});
});
@@ -0,0 +1,230 @@
/**
* Test harness for CRM integration tests.
*
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
* directly (bypassing knex's Migrator — its exclusive write lock
* deadlocks 001_init's nested `initializeDatabase()` call), and
* exposes a small helper for seeding the minimal row set that the
* quote/contract/invoice services need to operate.
*
* Usage:
*
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
*
* beforeAll(async () => {
* ({ db, cleanup } = await bootCrmDb());
* ({ adminId, customerId } = await seedMinimal(db));
* });
* afterAll(async () => { await cleanup(); });
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
async function runCoreMigrations(db) {
await db.schema.createTable('migrations', (t) => {
t.increments('id').primary();
t.string('filename').unique().notNullable();
t.timestamp('applied_at').defaultTo(db.fn.now());
});
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
const files = (await fs.promises.readdir(coreDir))
.filter((f) => f.endsWith('.js'))
.sort();
for (const f of files) {
const mod = require(path.join(coreDir, f));
if (typeof mod.up === 'function') {
await mod.up(db);
}
await db('migrations').insert({ filename: f });
}
}
/**
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
* Caller must invoke cleanup() in afterAll to release the SQLite file
* and the temp directory.
*/
async function bootCrmDb() {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
// No jest.resetModules() — every service the test later requires
// must share THIS db instance. Two module copies on one SQLite file
// each open their own knex pool and the SQLite write lock deadlocks
// the second one acquiring a connection. Caller is responsible for
// setting TEST_DATABASE_PATH before the first require of db.js
// (which knexfile reads at module-init time); bootCrmDb only works
// when invoked before any service import.
const { db } = require('../../../src/database/db');
await runCoreMigrations(db);
return {
db,
tmpDir,
cleanup: async () => {
try { await db.destroy(); } catch (_) {}
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
},
};
}
/**
* Seed the minimal row set that quote/contract/invoice services
* dereference on creation: an admin user, an active customer, a
* business_profile row, and the app_settings keys the services read.
*
* Returns the ids the caller will pass into service calls.
*/
async function seedMinimal(db) {
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
const adminInsert = await db('admin_users').insert({
username: 'tester', email: 'tester@example.com',
password_hash: passwordHash, must_change_password: false,
created_at: new Date(),
}).returning('id');
const adminId = adminInsert[0]?.id ?? adminInsert[0];
// business_profile is a singleton; the row is seeded by migration 107
// for fresh installs. Defensive: insert if missing.
const profile = await db('business_profile').first();
if (!profile) {
await db('business_profile').insert({
legal_name: 'Test Studio',
default_currency: 'CHF',
default_locale: 'de',
});
}
const customerInsert = await db('customer_accounts').insert({
email: 'customer@example.com',
display_name: 'Test Customer',
password_hash: passwordHash,
preferred_language: 'de',
is_active: 1,
created_at: new Date(),
}).returning('id');
const customerId = customerInsert[0]?.id ?? customerInsert[0];
return { adminId, customerId };
}
// ---------------------------------------------------------------------
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
// tests. Kept here so every supertest suite shares the same minting +
// app-wiring shape and a refactor lands in one place.
// ---------------------------------------------------------------------
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const express = require('express');
const cookieParser = require('cookie-parser');
/**
* Promote a seeded admin into a role (default `super_admin`) so
* `requirePermission(...)` checks pass. seedMinimal creates an admin
* without a role — that's good for negative tests (expect 403) but
* happy-path tests need the role assignment.
*
* Returns the role id the admin was assigned to.
*/
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
const role = await db('roles').where({ name: roleName }).first();
if (!role) {
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
}
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
return role.id;
}
/**
* Mint an admin JWT in the same shape adminAuth middleware expects.
* The tests inject this via `Authorization: Bearer <token>`.
*/
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
return jwt.sign(
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
process.env.JWT_SECRET,
{ expiresIn, issuer: 'picpeak-auth' }
);
}
/**
* Insert a row into one of the public-token tables for testing the
* loadActionToken guard outcomes. Returns the generated 64-hex token.
*
* Usage:
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
*/
async function createPublicToken(db, tableName, opts = {}) {
const token = opts.token || crypto.randomBytes(32).toString('hex');
const expiresAt = opts.expires_at === null
? null
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
// Serialise Date → ISO string. Bare Date objects round-tripped
// inconsistently through knex+SQLite — sometimes as epoch ms,
// sometimes via .toString() → literal "[object Object]" which then
// parses back to NaN and silently defeats the expiry guard.
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
const row = {
...opts,
token,
expires_at: toStorable(expiresAt),
created_at: toStorable(new Date()),
};
await db(tableName).insert(row);
return token;
}
/**
* Build an Express app with the requested route file mounted. Mirrors
* the production app's middleware shape (json + cookies) but skips
* everything else (CORS, helmet, rate limiters) — route tests pin the
* handler's contract, not the surrounding cross-cutting concerns.
*
* Example:
* const app = buildRouteApp('/api/public/quotes',
* require('../../src/routes/publicQuotes'));
*/
function buildRouteApp(mount, router) {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(mount, router);
// Catch-all error handler. Mirrors the real middleware/errorHandler:
// AppError subclasses (ValidationError, NotFoundError, etc.) use
// `.statusCode` (NOT `.status` — getting that wrong silently maps
// every 400 / 404 / 410 to 500 in tests).
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const statusCode = err.statusCode || err.status || 500;
res.status(statusCode).json({
error: err.message || 'Internal error',
code: err.code,
...(err.details ? { details: err.details } : {}),
});
});
return app;
}
module.exports = {
bootCrmDb,
seedMinimal,
assignAdminRole,
mintAdminToken,
createPublicToken,
buildRouteApp,
};
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
expect(await storage.exists(key)).toBe(true);
});
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(key).toBe('previews/preview_preview-source.jpg');
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
// Source is 800x600 and default longEdge is 1920 with
// withoutEnlargement: true → preview must NOT be upscaled.
expect(meta.width).toBe(800);
expect(meta.height).toBe(600);
}
});
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
// 800x600 → fit:'inside' inside 400×400 → 400×300.
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
}
});
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
});
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src);
@@ -0,0 +1,211 @@
/**
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
* real SQLite schema. Covers the bits unit tests can't: the disposition state
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
* billPendingRebills actually mint / amend invoice rows correctly.
*
* No date-range comparisons are exercised here, so it's safe on SQLite (the
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
* doesn't apply to this path).
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(60000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
let cleanup;
let adminId;
let expenseService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
// second write connection deadlocks against the held write lock on
// SQLite. It's fire-and-forget audit noise, irrelevant to these
// assertions, so stub it BEFORE the services destructure it at require
// time. (Production runs Postgres, where the concurrent write is fine.)
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
async function captureDoc(overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload',
status: 'unsorted',
parse_status: 'pending',
parse_method: 'none',
supplier_name: 'ACME AG',
currency: 'CHF',
total_amount_minor: 10000,
invoice_date: '2026-06-01',
created_at: new Date(),
updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
let customerSeq = 0;
async function makeCustomer(billingCadence) {
customerSeq += 1;
const ins = await db('customer_accounts').insert({
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: billingCadence || null,
created_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.status).toBe('categorized');
expect(doc.billedInvoiceId).toBeNull();
expect(doc.customerAccountId).toBeNull();
});
it('rebill REQUIRES a customer', async () => {
const id = await captureDoc();
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
});
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc({ total_amount_minor: 10000 });
const doc = await expenseService.categorizeInbound(id, {
disposition: 'rebill', customerAccountId: customerId,
markupType: 'percent', markupPercent: 10,
}, adminId);
expect(doc.disposition).toBe('rebill');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
expect(doc.markupType).toBe('percent');
expect(Number(doc.markupPercent)).toBe(10);
});
it('passthrough never carries a markup, even if one is sent', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, {
disposition: 'durchlaufend', customerAccountId: customerId,
markupType: 'percent', markupPercent: 25, // should be ignored
}, adminId);
expect(doc.disposition).toBe('durchlaufend');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.markupType).toBe('none');
expect(doc.markupPercent).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
const customerId = await makeCustomer('monthly');
await expect(expenseService.billPendingRebills(customerId, adminId))
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
});
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
// customer's pool; monthly-customer immediate-bill onto the running draft)
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
// claims its sequence number via the global db, which DEADLOCKS against the
// held write lock on a SQLite-backed harness (a second write connection blocks
// — verified). Production runs Postgres where the concurrent write is fine, so
// this is a harness limitation, not a product bug. The line-amount math is
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
// discountLineItems.test.js. Below we test the UNWIND path against a
// hand-crafted billed state so we don't have to mint through createInvoice. ──
// Build a billed state directly: an invoice with two lines, with the inbound
// doc stamped onto the first line as a prior re-bill.
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
const invIns = await db('invoices').insert({
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
customer_account_id: customerId,
status,
scheduled_send_at: scheduledSendAt,
is_monthly_draft: isMonthlyDraft,
currency: 'CHF',
issue_date: '2026-06-01',
due_date: '2026-07-01',
vat_rate: 0,
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
vat_amount_minor: 0,
total_amount_minor: 7000,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const invoiceId = unwrapId(invIns);
const rebillLineIns = await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
}).returning('id');
const rebillLineId = unwrapId(rebillLineIns);
await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
});
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
await db('inbound_documents').where({ id }).update({
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
});
return { id, invoiceId, rebillLineId };
}
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
const customerId = await makeCustomer('per_event');
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(recat.disposition).toBe('eigener_aufwand');
expect(recat.billedInvoiceId).toBeNull();
expect(recat.customerAccountId).toBeNull();
// The re-bill line is gone; the sibling line remains and net recomputes.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
const after = await db('invoices').where({ id: invoiceId }).first();
expect(Number(after.net_amount_minor)).toBe(3000);
});
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
const customerId = await makeCustomer('per_event');
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
// Nothing was touched — the line survives.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
});
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
// passthrough → pending
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull();
// → company expense: customer cleared, still no invoice
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.customerAccountId).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
});
@@ -0,0 +1,214 @@
/**
* Install-from-backup boot hook — pins the trigger-file convention.
*
* The hook itself depends on `restoreService.restore`, which is hard
* to fully exercise in an integration test without a real PG cluster
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
* and verify the BOOT HOOK logic:
*
* - No trigger file → no-op, ran=false
* - Empty trigger file → picks newest manifest from manifests/
* - Non-empty trigger file → uses the path inside
* - DB not empty → refuses (no restore call)
* - DB not empty + FORCE env → proceeds
* - Successful restore → deletes trigger file
* - Failed restore → leaves trigger file in place
*
* These are the surfaces an admin will hit when actually using the
* feature — the docker-compose-on-real-PG end-to-end test belongs in
* the follow-up CI work captured as task #7 earlier today.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Stub the heavy lifting so the test stays fast + portable.
const mockRestore = jest.fn();
jest.mock('../../src/services/restoreService', () => ({
restoreService: {
restore: (...args) => mockRestore(...args),
},
}));
jest.setTimeout(30000);
describe('installFromBackupBoot', () => {
let db;
let cleanup;
let storagePath;
let backupRoot;
let manifestsDir;
let tryInstallFromBackup;
let originalBackupRootEnv;
let originalForceEnv;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupRoot = path.join(storagePath, 'backup');
manifestsDir = path.join(backupRoot, 'manifests');
fs.mkdirSync(manifestsDir, { recursive: true });
originalBackupRootEnv = process.env.BACKUP_ROOT;
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
process.env.BACKUP_ROOT = backupRoot;
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
}, 120000);
afterAll(async () => {
if (originalBackupRootEnv === undefined) {
delete process.env.BACKUP_ROOT;
} else {
process.env.BACKUP_ROOT = originalBackupRootEnv;
}
if (originalForceEnv === undefined) {
delete process.env.INSTALL_FROM_BACKUP_FORCE;
} else {
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
}
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockRestore.mockReset();
mockRestore.mockResolvedValue({ success: true });
delete process.env.INSTALL_FROM_BACKUP_FORCE;
// Clean trigger files + manifests between tests
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
const p = path.join(backupRoot, name);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
for (const f of fs.readdirSync(manifestsDir)) {
fs.unlinkSync(path.join(manifestsDir, f));
}
// Reset DB to fresh-install state
await db('events').del();
// Leave admin_users alone — fresh-install state has 1 row.
});
it('no trigger file → no-op', async () => {
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(mockRestore).not.toHaveBeenCalled();
});
it('empty trigger file picks the newest manifest from manifests/', async () => {
const older = path.join(manifestsDir, 'backup-manifest-001.json');
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
fs.writeFileSync(older, '{}');
// Set the newer file's mtime slightly later so it wins the sort
const past = new Date(Date.now() - 60_000);
fs.utimesSync(older, past, past);
fs.writeFileSync(newer, '{}');
// Empty trigger
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(newer);
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
source: 'local',
manifestPath: newer,
restoreType: 'full',
force: true,
skipPreBackup: true,
}));
});
it('non-empty trigger file uses the path inside', async () => {
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
fs.writeFileSync(specific, '{}');
// Relative to backupRoot
fs.writeFileSync(
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
'manifests/backup-manifest-specific.json\n',
);
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(specific);
});
it('deletes the trigger file after a successful restore', async () => {
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
await tryInstallFromBackup(db);
expect(fs.existsSync(triggerPath)).toBe(false);
});
it('leaves the trigger file in place when restore throws', async () => {
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/restore exploded/);
expect(fs.existsSync(triggerPath)).toBe(true);
});
it('refuses to run when the install already has events', async () => {
// Simulate an install with existing data
await db('events').insert({
slug: 'existing-event',
event_name: 'Existing Event',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-token',
password_hash: 'dummy-hash-for-test',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/Database not empty/);
expect(mockRestore).not.toHaveBeenCalled();
// Trigger file should NOT be deleted — admin needs to fix + retry
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
});
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
await db('events').insert({
slug: 'existing-event-2',
event_name: 'Existing Event 2',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-2-token',
password_hash: 'dummy-hash-for-test-2',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(mockRestore).toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
/**
* Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning
* rework. Covers the fee math (flat / percent), the VAT toggle gating
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
* changes the issued invoice total), and the 3-reminder cap.
*
* The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and
* is verified manually; here we assert the data/immutability behaviour.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let invoiceService;
let ids;
async function setSetting(key, value) {
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting(key, JSON.stringify(value), 'crm');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
ids = await seedMinimal(db);
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
invoiceService = require('../../src/services/invoiceService');
// Stub the (flaky) PDF render so applyReminder exercises its data path.
// eslint-disable-next-line global-require
const pdfService = require('../../src/services/pdfService');
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
});
afterAll(async () => { await cleanup(); });
describe('dunning fee resolvers', () => {
test('flat fee, no VAT', async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const inv = { total_amount_minor: 100000 };
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
});
test('percent fee = % of the invoice gross', async () => {
await setSetting('crm_invoices_late_fee_type', 'percent');
await setSetting('crm_invoices_late_fee_percent', 5);
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
});
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', true);
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
});
});
describe('applyReminder — dunning-document model', () => {
let invoiceId;
let originalTotal;
beforeAll(async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const res = await invoiceService.createInvoice({
customerAccountId: ids.customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
}, ids.adminId);
invoiceId = res.invoiceIds[0];
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
});
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(inv.reminder_level).toBe(2);
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
});
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
});
test('sendReminder refuses to exceed level 3', async () => {
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
});
});
@@ -0,0 +1,259 @@
/**
* Pins the fix for the PR #596 review blocker.
*
* **The bug**
*
* `preservedMeta` was declared with `let` INSIDE the PostgreSQL
* `else` branch of `performDatabaseRestore`, then read AFTER the
* `else` block closed at the shared replay site (~L1030). On every
* real PG restore:
*
* ReferenceError: preservedMeta is not defined
*
* would fire — psql had already completed the data restore, but
* the operator-meta replay never ran, the trigger file was left
* in place by `_installFromBackupBoot.js` because the restore
* "failed", and `combined.log` got a loud FAILED line even though
* the data was back. Caught on PR #596 review by the maintainer.
*
* **Why CI missed it**
*
* The integration tests around `performFullRestore` only exercise
* the SQLite branch via `this.dbType === 'sqlite'`. The PG branch
* (~L827-984) requires a real PG connection + real `psql` binary,
* neither of which are in the test environment. So the scope leak
* sat untested until the maintainer ran a real DR cycle.
*
* **What this test does**
*
* Reads the source of `restoreService.js` and asserts the scope
* contract: the `preservedMeta` declaration sits ABOVE the
* SQLite/PG branch split, so the replay block at the bottom of the
* try{} can read it on either branch.
*
* Source-inspection is uglier than a runtime test but it has two
* advantages here: (a) it doesn't require a real PG cluster + psql
* binary in CI, (b) it pins the EXACT contract — "the declaration
* must be visible to the replay block" — which is the property
* that broke, more directly than a runtime test would.
*
* The follow-up "real-PG integration test in CI" (separate task)
* would replace this with an end-to-end exercise, at which point
* this can be deleted.
*/
const fs = require('fs');
const path = require('path');
describe('restoreService — PG branch scope contract (PR #596 review)', () => {
let src;
let lines;
beforeAll(() => {
src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'services', 'restoreService.js'),
'utf8',
);
lines = src.split(/\r?\n/);
});
/** Return the 1-based line number of the FIRST line matching `re`. */
function findFirst(re) {
const idx = lines.findIndex((l) => re.test(l));
return idx >= 0 ? idx + 1 : -1;
}
/** Return the 1-based line number of the LAST line matching `re`. */
function findLast(re) {
let last = -1;
lines.forEach((l, i) => { if (re.test(l)) last = i + 1; });
return last;
}
it('preservedMetaSnapshot lives on `this` and is initialised in the constructor', () => {
// PR #596 round 3 moved the snapshot from a block-scoped local to
// an instance variable so the replay can happen in `restore()`
// AFTER post-restore verification — preventing the replay row
// from inflating the row-count check.
//
// Contract:
// 1. The constructor initialises `this.preservedMetaSnapshot = []`
// 2. The `restore()` entry point resets it per call (no leak
// across consecutive runs in the singleton service instance)
// 3. `performDatabaseRestore` assigns to `this.preservedMetaSnapshot`
// inside the PG branch (must run before DROP)
// 4. The replay reads `this.preservedMetaSnapshot` — NOT a bare
// `preservedMeta` local — so a future refactor can't
// accidentally drop the snapshot half on the floor again.
const constructorInit = lines.some((l) =>
/this\.preservedMetaSnapshot\s*=\s*\[\s*\]/.test(l)
);
expect(constructorInit).toBe(true);
const assignmentSites = lines.filter((l) =>
/this\.preservedMetaSnapshot\s*=\s*(\[\s*\]|await\s+db)/.test(l)
);
// Constructor init + restore() per-run reset + the PG-branch
// assignment from db query. Three writes.
expect(assignmentSites.length).toBeGreaterThanOrEqual(3);
// No stray bare `preservedMeta` local-scoped declaration in
// performDatabaseRestore — would indicate someone re-introduced
// the round-1 footgun.
const dangerousLocalDecl = lines.filter((l) =>
/^\s*(let|const)\s+preservedMeta\s*=/.test(l)
);
expect(dangerousLocalDecl).toEqual([]);
});
it('every .count() result is coerced to Number before comparison', () => {
// PR #596 review caught a second PG-only landmine: pg-driver
// returns COUNT(*) as a string ("16" not 16) to preserve bigint
// precision. The original code compared `result.count !==
// expected.rowCount` and every match flagged as a mismatch on PG.
//
// The fix coerces with `Number(...)` at every comparison +
// interpolation site. This test catches a future regression where
// a refactor uses `.count` directly in a `===` / `!==` / `>` /
// `<` comparison without coercing.
//
// Heuristic: find every `.count` access in the file and make sure
// the line either:
// (a) wraps it in `Number(...)`, or
// (b) is purely an interpolation that already coerced upstream
// (e.g. `validation.warnings.push(`... ${eventCountN} ...`)`
// where eventCountN is the coerced local), or
// (c) is the docstring/comment line (filtered separately).
//
// We approximate this by listing every `.count` reference site
// and asserting that lines doing comparisons (`===`/`!==`/`>`/
// `<`/`>=`/`<=`) on a raw `.count` access without `Number(...)`
// around it are zero.
const dangerousLines = lines
.map((l, i) => ({ line: i + 1, text: l }))
// Filter to lines that compare a .count result
.filter(({ text }) => {
// Skip comments
if (/^\s*(\/\/|\*)/.test(text)) return false;
// Detect a `.count` (followed by `)` for `?.count` or by space/operator)
// being directly compared via ===/!==/>/<.
// Match the BAD pattern: `<something>.count <op> <something>`
// where <op> is === / !== / > / < / >= / <=
const bareCountInComparison = /\w+\??\.count\s*(?:!==|===|>=?|<=?)\s+/;
// ALLOW if the .count is preceded by `Number(` in the same line
const wrappedInNumber = /Number\(\s*\w+\??\.count/;
return bareCountInComparison.test(text) && !wrappedInNumber.test(text);
});
expect(dangerousLines).toEqual([]);
});
it('the completed-restore update sets was_successful=true', () => {
// Without this, every successful restore ends up with
// status='completed', was_successful=false — the dashboard's
// "last successful restore" widget then filters out the row +
// any future audit query gating on was_successful misses it.
// Caught locally + maintainer PR #596 review.
//
// Contract: the update payload that writes status='completed' on
// the SUCCESS branch ALSO includes was_successful: true. We pin
// it by source inspection so any future refactor of the success
// payload keeps both fields together.
// The success-branch update lives AFTER performPostRestoreVerification.
// There's also a `status: 'completed'` in the dry-run / early-return
// path (failure handling has its own block too) — we want the
// SUCCESS-branch one specifically.
const verifyLine = findFirst(/performPostRestoreVerification\s*\(/);
expect(verifyLine).toBeGreaterThan(0);
const completedStatusLineIdx = lines
.map((l, i) => ({ line: i + 1, text: l }))
.find(({ line, text }) =>
line > verifyLine && /status:\s*['"]completed['"]/.test(text)
);
expect(completedStatusLineIdx).toBeDefined();
// Look in the next ~10 lines for was_successful: true. The actual
// payload is small (no nested objects between status and the
// closing })), so a fixed-window search is reliable.
const window = lines.slice(
completedStatusLineIdx.line - 1,
completedStatusLineIdx.line + 10,
).join('\n');
expect(window).toMatch(/was_successful:\s*true/);
});
it('npm run migrate:safe is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart).
//
// Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
expect(replayLine).toBeGreaterThan(0);
expect(migrateLine).toBeGreaterThan(replayLine);
// Must NOT live inside performDatabaseRestore (same scope as the
// replay check above).
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
let dbRestoreEnd = -1;
for (let i = dbRestoreStart; i < lines.length; i++) {
if (/^ \}\s*$/.test(lines[i])) {
dbRestoreEnd = i + 1;
break;
}
}
expect(migrateLine < dbRestoreStart || migrateLine > dbRestoreEnd).toBe(true);
});
it('the replay site lives in restore() AFTER performPostRestoreVerification', () => {
// PR #596 round 3 moved the replay out of performDatabaseRestore
// and into the parent restore() method, sequenced AFTER the
// post-restore verification. Otherwise the replay's upserted row
// count was being flagged as a verification mismatch (e.g.
// "expected 190, got 191" because the fresh-install seeded
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
//
// Contract: the line that drains `this.preservedMetaSnapshot`
// must come AFTER `performPostRestoreVerification` AND must NOT
// sit inside `performDatabaseRestore`.
const verificationLine = findFirst(/performPostRestoreVerification\s*\(/);
expect(verificationLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
expect(replayLine).toBeGreaterThan(0);
expect(replayLine).toBeGreaterThan(verificationLine);
// `performDatabaseRestore` must not contain the replay drain.
// Find the function bounds + assert no drain line falls inside.
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
expect(dbRestoreStart).toBeGreaterThan(0);
// Find the closing brace of performDatabaseRestore. Lazy heuristic:
// the first `^ \}\s*$` (two-space indent + }) after the function
// start. Brittle to indent changes but unambiguous in this codebase.
let dbRestoreEnd = -1;
for (let i = dbRestoreStart; i < lines.length; i++) {
if (/^ \}\s*$/.test(lines[i])) {
dbRestoreEnd = i + 1;
break;
}
}
expect(dbRestoreEnd).toBeGreaterThan(dbRestoreStart);
// The replay drain line must be OUTSIDE [dbRestoreStart, dbRestoreEnd].
expect(replayLine < dbRestoreStart || replayLine > dbRestoreEnd).toBe(true);
});
});
@@ -0,0 +1,196 @@
'use strict';
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
// so setupService shares this test's db instance (see crmDb.js note).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let setupService;
let getAppSetting;
let upsertAppSetting;
let app;
const VALID_PW = 'Str0ng-Passw0rd!';
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
// service/util), or db.js binds to the default path instead of the temp one.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
afterAll(async () => {
await cleanup();
});
beforeEach(async () => {
await db('admin_users').del();
await db('app_settings').where({ setting_key: 'setup_token' }).del();
});
describe('setupService (first-run bootstrap)', () => {
it('reports needsAdmin while no admin exists', async () => {
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('generates and persists a one-time token while no admin exists', async () => {
const token = await setupService.ensureSetupToken();
expect(token).toEqual(expect.any(String));
expect(token.length).toBeGreaterThan(20);
expect(await getAppSetting('setup_token')).toBe(token);
// Idempotent — a second call returns the same token, not a fresh one.
expect(await setupService.ensureSetupToken()).toBe(token);
});
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
// Regression guard for the SQLite-only miss: a bare token string is rejected
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
// value must be JSON-parseable and round-trip back to the token.
const token = await setupService.ensureSetupToken();
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
expect(() => JSON.parse(row.setting_value)).not.toThrow();
expect(JSON.parse(row.setting_value)).toBe(token);
});
it('rejects a wrong token', async () => {
await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 400 });
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('rejects a weak password', async () => {
const token = await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
).rejects.toMatchObject({ statusCode: 400 });
});
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
const token = await setupService.ensureSetupToken();
const result = await setupService.createInitialAdmin({
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
});
expect(result.user.email).toBe('owner@example.com'); // normalised
expect(result.user.role.name).toBe('super_admin');
expect(result.token).toEqual(expect.any(String));
const row = await db('admin_users').first();
const role = await db('roles').where({ name: 'super_admin' }).first();
expect(row.role_id).toBe(role.id);
expect(row.password_hash).not.toBe(VALID_PW); // hashed
// One-time: token burned, status now complete.
expect(await getAppSetting('setup_token')).toBeFalsy();
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
await expect(
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 409 });
});
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
const token = await setupService.ensureSetupToken();
const results = await Promise.allSettled([
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
const count = await db('admin_users').count({ c: '*' }).first();
expect(Number(count.c)).toBe(1);
});
it('ensureSetupToken clears any stale token once an admin exists', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
// Simulate a stale token left in settings, then re-run the boot hook.
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
});
});
describe('setup routes', () => {
it('GET /api/setup/status reports needsAdmin', async () => {
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsAdmin: true, complete: false });
});
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(200);
expect(res.body).toEqual({ valid: true });
// Token is NOT consumed — it still works for the actual create.
expect(await getAppSetting('setup_token')).toBe(token);
});
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
expect(res.status).toBe(400);
expect(res.body.field).toBe('token');
});
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(409);
});
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
expect(res.status).toBe(400);
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
});
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'owner@example.com', password: VALID_PW });
expect(res.status).toBe(201);
expect(res.body.user.role.name).toBe('super_admin');
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'second@example.com', password: VALID_PW });
expect(res.status).toBe(409);
});
});
@@ -0,0 +1,661 @@
/**
* Workflow engine — graph execution integration tests.
*
* Exercises the engine against a real (temp SQLite) DB with migration 142
* applied: branching, bounded loops, wait pauses + scheduler-style resume,
* gate pauses + confirm/deny resume, dedup idempotency, and step recording.
*/
const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let engine;
async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) {
const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled });
const workflowId = ins[0];
for (const n of nodes) {
await db('workflow_nodes').insert({
workflow_id: workflowId, version: 1, node_key: n.key, type: n.type,
config: JSON.stringify(n.config || {}),
});
}
for (const e of edges) {
await db('workflow_edges').insert({
workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to,
loop_back: e.loopBack || false,
});
}
return workflowId;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
engine = require('../../src/services/workflows');
// Enable the workflows flag so emitWorkflowEvent doesn't fail closed.
await db('feature_flags').insert({ key: 'workflows', value: true });
});
afterAll(async () => { await cleanup(); });
describe('workflow engine', () => {
test('condition + bounded loop + wait pauses, resumes to completion', async () => {
// trigger → set paid=false → condition(paid?) --no--> loop(max2)
// loop --loop--> reminder(noop) → wait → (back to condition)
// loop --exit--> lateFee(noop) → end
// condition --yes--> lateFee (paid path, not taken here)
const wfId = await makeWorkflow({
nodes: [
{ key: 'n1', type: 'trigger' },
{ key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } },
{ key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } },
{ key: 'n4', type: 'loop', config: { maxIterations: 2 } },
{ key: 'n5', type: 'action', config: { action: 'noop' } },
{ key: 'n6', type: 'wait', config: { delayMinutes: 0 } },
{ key: 'n7', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'n1', to: 'n2' },
{ from: 'n2', to: 'n3' },
{ from: 'n3', handle: 'no', to: 'n4' },
{ from: 'n3', handle: 'yes', to: 'n7' },
{ from: 'n4', handle: 'loop', to: 'n5' },
{ from: 'n4', handle: 'exit', to: 'n7' },
{ from: 'n5', to: 'n6' },
{ from: 'n6', to: 'n3', loopBack: true },
],
});
const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 });
expect(runIds.length).toBe(1);
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1)
expect(run.current_node).toBe('n6');
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused again (loop iter 2)
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // loop exhausted → exit → end
const ctx = JSON.parse(run.context);
expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap
void wfId;
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.length).toBeGreaterThan(0);
});
test('emit is idempotent on dedup_key', async () => {
await makeWorkflow({
trigger: 'dedup.event',
nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'n1', to: 'n2' }],
});
const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
expect(first.length).toBe(1);
expect(second.length).toBe(0); // same entity → no duplicate run
});
test('gate pauses and resumes via the confirm edge', async () => {
const wfId = await makeWorkflow({
trigger: 'gate.event',
nodes: [
{ key: 'g1', type: 'trigger' },
{ key: 'g2', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
{ key: 'g4', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g1', to: 'g2' },
{ from: 'g2', handle: 'confirm', to: 'g3' },
{ from: 'g2', handle: 'deny', to: 'g4' },
],
});
// create + start a run directly
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending',
context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test',
});
const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first();
await engine.startRun(run0.id);
let run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2');
await engine.resumeRun(run0.id, { decisionHandle: 'confirm' });
run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('runDueWaits resumes only elapsed wait nodes', async () => {
await makeWorkflow({
trigger: 'wait.event',
nodes: [
{ key: 'w1', type: 'trigger' },
{ key: 'w2', type: 'wait', config: { delayMinutes: 60 } },
{ key: 'w3', type: 'action', config: { action: 'noop' } },
],
edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }],
});
const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 });
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
const resumed = await engine.runDueWaits();
expect(resumed).toBeGreaterThanOrEqual(1);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('send_email queues a customer mail with business-hours routing', async () => {
await makeWorkflow({
trigger: 'mail.event',
nodes: [
{ key: 'm1', type: 'trigger' },
{ key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } },
],
edges: [{ from: 'm1', to: 'm2' }],
});
const runIds = await engine.emitWorkflowEvent('mail.event', {
entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' },
});
const run = await db('workflow_runs').where({ id: runIds[0] }).first();
expect(run.status).toBe('done');
const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first();
expect(queued).toBeTruthy();
const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first();
expect(JSON.parse(step.result).respectBusinessHours).toBe(true);
});
test('invoice_paid condition reads the entity', async () => {
const registry = require('../../src/services/workflows/registry');
const cond = registry.getCondition('invoice_paid');
const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) });
expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
});
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
await makeWorkflow({
trigger: 'approval.event',
nodes: [
{ key: 'a1', type: 'trigger' },
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
],
edges: [
{ from: 'a1', to: 'a2' },
{ from: 'a2', handle: 'confirm', to: 'a3' },
{ from: 'a2', handle: 'deny', to: 'a4' },
],
});
const runIds = await engine.emitWorkflowEvent('approval.event', {
entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' },
});
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('a2');
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
expect(approval).toBeTruthy();
expect(approval.status).toBe('pending');
const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first();
expect(adminMail).toBeTruthy();
// Extract the raw token from the emailed confirm link and act on it.
const data = JSON.parse(adminMail.email_data);
const rawToken = data.confirm_url.split('/').slice(-2)[0];
const res = await engine.actByToken(rawToken, 'confirm');
expect(res.ok).toBe(true);
expect(res.status).toBe('confirmed');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
// A second click is idempotent (already recorded).
const again = await engine.actByToken(rawToken, 'confirm');
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1);
});
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
// Simulate an older, never-touched seed (v1, with a legacy gate node).
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
const before = await db('workflows').where({ id: wf.id }).first();
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged
expect(!!after.enabled).toBe(true); // admin's choice preserved
});
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// First beta: cutover flows ship DISABLED (legacy paths run until enabled);
// they delegate to the proven send functions once turned on.
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
expect(expiring).toBeTruthy();
expect(!!expiring.enabled).toBe(false);
expect(expiring.trigger_type).toBe('gallery.expiring');
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
expect(expired).toBeTruthy();
expect(!!expired.enabled).toBe(false);
expect(expired.trigger_type).toBe('gallery.expired');
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
// Invoice-only booking variant (quote → invoice, no gallery).
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
expect(invoiceOnly).toBeTruthy();
expect(!!invoiceOnly.enabled).toBe(false);
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
// Admin review gate guards BOTH document sends (adjust line items, then OK).
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
// dispatch is held until the event date after the admin's early OK.
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
expect(bookingSimple).toBeTruthy();
expect(bookingSimple.trigger_type).toBe('quote.accepted');
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled
expect(preEvent.trigger_type).toBe('event.date_approaching');
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
});
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
const wfId = await makeWorkflow({
trigger: 'event.date_approaching',
enabled: true,
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'pe1', to: 'pe2' }],
});
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
const emitted = await engine.emitDueEventReminders();
expect(emitted).toBeGreaterThanOrEqual(1);
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs.length).toBe(1); // only the in-window event, not the far one
// Idempotent: a second pass dedups (no duplicate run for the same event).
await engine.emitDueEventReminders();
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs2.length).toBe(1);
});
test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => {
// Regression: the reminder query used events.customer_account_id, which does
// not exist — so an event with only customer_email/host_email got no mail.
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture,
is_active: true, is_archived: false,
slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
customer_email: 'direct@x.test', // event-level email, NOT a customer_account
});
const ev = await db('events').where({ slug: 'rem-direct' }).first();
const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ event_id: ev.id }).first();
expect(mail).toBeTruthy();
expect(mail.recipient_email).toBe('direct@x.test');
// Idempotent: sent_at stamped → a second call is a no-op.
const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(again.sent).toBe(0);
expect(again.reason).toBe('already_sent');
});
test('reminder template resolves per event type within the chosen group, else group default', async () => {
const { _internal } = require('../../src/services/eventReminderService');
// Per-type template exists within a custom group → used.
await db('email_templates').insert({ template_key: 'promo_wedding' });
expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding');
// A type with no authored template (in any group) → the group's default.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default');
// Blank group → the default event_reminder group.
expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default');
// Trailing underscore on the group is tolerated.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
});
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
const { _internal } = require('../../src/services/eventReminderService');
const p = _internal.composePayload({
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
});
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
expect(p.event_date).not.toMatch(/invalid/i);
});
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
const webhook = engine.registry.getAction('webhook');
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
const ctx = (config, vars = {}) => ({
run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 },
node: { config }, vars, db, logger: { warn() {} },
});
// No webhook selected → observable skip, not a crash.
expect(await webhook(ctx({}))).toMatchObject({ skipped: true });
// A configured, active webhook subscription.
const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' });
const [whId] = await db('webhooks').insert({
name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test',
events: JSON.stringify([]), active: true, created_by: adminId,
});
// Dry run does not enqueue.
expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' });
expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 });
// Real run → a pending delivery is enqueued for the worker (which does the
// signing + SSRF re-validation + retries).
const res = await webhook(ctx({ webhookId: whId }));
expect(res.webhook_enqueued).toBe(whId);
const del = await db('webhook_deliveries').where({ webhook_id: whId }).first();
expect(del).toBeTruthy();
expect(del.status).toBe('pending');
expect(del.event_type).toBe('workflow.invoice.sent');
// Inactive / missing subscription → skip.
await db('webhooks').where({ id: whId }).update({ active: false });
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
});
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
const eventReminderService = require('../../src/services/eventReminderService');
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [custId] = await db('customer_accounts').insert({
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
});
// Event with NO inline customer_email / host_email.
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
});
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
const res = await eventReminderService.sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
expect(mail).toBeTruthy();
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
expect(mail.event_id == null).toBe(true);
});
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// All built-ins ship disabled → inactive until the admin enables one.
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
// Enable one → now active.
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true });
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore
});
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
// crm_event_reminders_enabled must be on to reach the mutex guard.
await db('app_settings')
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
.onConflict('setting_key').merge();
const eventReminderService = require('../../src/services/eventReminderService');
// Flow disabled → guard does NOT fire (legacy pass owns reminders).
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false);
// Flow enabled → the pass stands down before doing any work (byWorkflow).
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true });
const after = await eventReminderService.runEventReminderPass();
expect(after.byWorkflow).toBe(true);
expect(after.sent).toBe(0);
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore
});
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
// Two enabled flows on the same trigger — the quote picks one.
const chosen = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
const other = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'o1', to: 'o2' }],
});
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
expect(runIds.length).toBe(1);
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
expect(chosenRuns.length).toBe(1); // only the selected flow ran
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
});
test('gate decision with no matching edge FAILS the run (not a silent done)', async () => {
// Gate has a confirm edge but the deny edge was lost (e.g. a bad import).
const wfId = await makeWorkflow({
trigger: 'noedge.event', enabled: true,
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: {} },
{ key: 'g2', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge
],
});
const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 });
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'deny'); // deny has no edge
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('failed'); // loud failure, not a green 'done'
expect(run.error).toMatch(/deny.*no matching edge/i);
});
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
// admin can approve at the gate whenever; the run then parks at the wait and
// the scheduler dispatches when the date arrives.
const wfId = await makeWorkflow({
trigger: 'gatewait.event',
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' },
{ from: 'g2', to: 'g3' },
],
});
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g1'); // parked at the review gate
// Admin confirms EARLY (before the wait date).
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'confirm');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
// Date arrives → scheduler dispatches.
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
await engine.runDueWaits();
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'r1', to: 'r2' }],
});
// Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
const n = await engine.recoverStaleRuns({ staleMs: 1000 });
expect(n).toBeGreaterThanOrEqual(1);
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
const wfId = await makeWorkflow({
trigger: 'crashloop.event',
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
await engine.recoverStaleRuns({ staleMs: 1000 });
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('failed');
});
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
const wfId = await makeWorkflow({
trigger: 'testfire.event',
nodes: [
{ key: 't', type: 'trigger' },
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
{ key: 'end', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 't', to: 'w' },
{ from: 'w', to: 'g' },
{ from: 'g', handle: 'confirm', to: 'a' },
{ from: 'g', handle: 'deny', to: 'end' },
{ from: 'a', to: 'end' },
],
});
const runId = await engine.testRun(wfId, { dryRun: true });
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
const emailStep = steps.find((s) => s.node_key === 'a');
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
});
});
@@ -0,0 +1,147 @@
/**
* Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals).
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let app;
let token;
let noPermToken;
const sampleGraph = {
name: 'Test flow',
trigger_type: 'invoice.sent',
enabled: false,
nodes: [
{ node_key: 'n1', type: 'trigger' },
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
],
edges: [{ from_node: 'n1', to_node: 'n2' }],
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
const ins = await db('admin_users').insert({
username: 'norole', email: 'nr@example.com', password_hash: 'x',
must_change_password: false, created_at: new Date(),
}).returning('id');
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
await db('feature_flags').insert({ key: 'workflows', value: true });
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
});
afterAll(async () => { await cleanup(); });
const auth = (t) => ({ Authorization: `Bearer ${t}` });
describe('admin workflows API', () => {
let createdId;
test('create → 201 with id', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
expect(res.status).toBe(201);
expect(res.body.id).toBeGreaterThan(0);
createdId = res.body.id;
});
test('rejects a graph without exactly one trigger', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
expect(res.status).toBe(400);
});
test('rejects an unknown node type', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/unknown node type/i);
});
test('refuses to enable a flow that uses an unregistered action', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }],
edges: [{ from_node: 't', to_node: 'a' }],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i);
});
test('allows enabling a flow using the now-implemented booking invoice actions', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false,
nodes: [
{ node_key: 't', type: 'trigger' },
{ node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } },
{ node_key: 'g', type: 'gate', config: {} },
{ node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } },
],
edges: [
{ from_node: 't', to_node: 'p' },
{ from_node: 'p', to_node: 'g' },
{ from_node: 'g', from_handle: 'confirm', to_node: 's' },
],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('get one returns the graph', async () => {
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(res.status).toBe(200);
expect(res.body.nodes).toHaveLength(2);
expect(res.body.edges).toHaveLength(1);
expect(res.body.version).toBe(1);
});
test('list includes it', async () => {
const res = await request(app).get('/api/admin/workflows').set(auth(token));
expect(res.status).toBe(200);
expect(res.body.some((w) => w.id === createdId)).toBe(true);
});
test('update bumps the version', async () => {
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
.send({ ...sampleGraph, name: 'Renamed' });
expect(res.status).toBe(200);
expect(res.body.version).toBe(2);
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(get.body.name).toBe('Renamed');
expect(get.body.version).toBe(2);
});
test('enable toggle', async () => {
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('approvals inbox returns an array', async () => {
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
test('a role without workflows.manage is forbidden from writing', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
expect(res.status).toBe(403);
});
});
@@ -0,0 +1,159 @@
/**
* HTTP route auth-gate tests for the CRM admin surface (P1 / P2 — #570).
*
* Bundled into one file rather than nine because the contract is the
* same for every CRM admin route:
* - No token → 401 (adminAuth at the router level)
* - Valid token, missing permission → 403 (requirePermission middleware)
* - Valid token + super_admin role → 2xx / 404 (resource-based)
*
* Deeper service-layer behaviour (PDF generation, send, Storno,
* countersign, integrity hash) is covered by the existing service
* unit tests in __tests__/services/. This file pins the contract
* between the HTTP layer and the auth+permission middleware so a
* misconfigured route ("forgot requirePermission") can never ship
* unnoticed.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admincrm-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole,
mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
// One row per admin CRM route. `mount` matches server.js's app.use,
// `loader` is the require()'d router, `getPath` is one path on the
// router we'll exercise. The path should be a GET-shaped read where
// possible — listing endpoints (`/`) are safest because they don't
// require pre-seeded resource ids.
const ROUTES = [
{ name: 'adminQuotes', mount: '/api/admin/quotes', loader: () => require('../../src/routes/adminQuotes'), getPath: '/' },
{ name: 'adminContracts', mount: '/api/admin/contracts', loader: () => require('../../src/routes/adminContracts'), getPath: '/' },
{ name: 'adminInvoices', mount: '/api/admin/invoices', loader: () => require('../../src/routes/adminInvoices'), getPath: '/' },
{ name: 'adminCalendar', mount: '/api/admin/calendar', loader: () => require('../../src/routes/adminCalendar'), getPath: '/items?from=2026-01-01&to=2026-12-31' },
{ name: 'adminDeals', mount: '/api/admin/deals', loader: () => require('../../src/routes/adminDeals'), getPath: '/' },
{ name: 'adminTaxReport', mount: '/api/admin/tax-report', loader: () => require('../../src/routes/adminTaxReport'), getPath: '/?period=2026-Q1' },
{ name: 'adminBusinessProfile', mount: '/api/admin/business-profile', loader: () => require('../../src/routes/adminBusinessProfile'), getPath: '/' },
];
describe('admin CRM routes — auth + permission gate', () => {
let db;
let cleanup;
let adminId;
let customerId;
let superAdminToken;
let invalidToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
// Super-admin: assign the seeded super_admin role (created by
// migration 057). requirePermission lookups short-circuit because
// super_admin role inherits every permission via role_permissions
// rows seeded by mig 107 and earlier.
await assignAdminRole(db, adminId, 'super_admin');
superAdminToken = mintAdminToken(adminId);
// CRM routes have a feature-flag gate that runs INSIDE the route
// handler — even a super-admin gets 403 (`QUOTES_DISABLED` /
// similar) when the flag is off. The flag check is independent
// of permissions, so for happy-path tests we flip every CRM flag
// on. Negative tests (no-token, bad-signature) hit adminAuth
// first and never reach the flag check, so they're unaffected.
// `accounting` is the master flag the tax-report route now requires
// (tax export moved out of CRM into Accounting, independent of bills).
const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting'];
for (const key of crmFlags) {
// eslint-disable-next-line no-await-in-loop
await db('feature_flags').where({ key }).update({ value: 1 });
}
// Invalid: signed with a different secret. adminAuth must reject.
const jwt = require('jsonwebtoken');
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe.each(ROUTES)('$name', ({ mount, loader, getPath }) => {
let app;
beforeAll(() => {
app = buildRouteApp(mount, loader());
});
it('returns 401 with no Authorization header', async () => {
const res = await request(app).get(`${mount}${getPath}`);
expect(res.status).toBe(401);
});
it('returns 401 with an invalid JWT signature', async () => {
const res = await request(app)
.get(`${mount}${getPath}`)
.set('Authorization', `Bearer ${invalidToken}`);
expect(res.status).toBe(401);
});
it('returns 2xx (or resource-shaped 4xx) with a valid super-admin token', async () => {
const res = await request(app)
.get(`${mount}${getPath}`)
.set('Authorization', `Bearer ${superAdminToken}`);
// 200 if listing succeeds (likely empty list), 400 if a
// validator complains about query shape, 404 if the route
// doesn't have a list endpoint at `/`. What MUST NOT happen:
// 401 (auth gate failed) or 403 (permission gate failed).
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
describe('adminCustomers — CRM additions (hour-entries / bill / trigger-monthly-bill)', () => {
let app;
beforeAll(() => {
app = buildRouteApp('/api/admin/customers', require('../../src/routes/adminCustomers'));
});
it('GET /:id/hour-entries — 401 without token', async () => {
const res = await request(app).get(`/api/admin/customers/${customerId}/hour-entries`);
expect(res.status).toBe(401);
});
it('GET /:id/hour-entries — 2xx with super-admin token', async () => {
const res = await request(app)
.get(`/api/admin/customers/${customerId}/hour-entries`)
.set('Authorization', `Bearer ${superAdminToken}`);
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
it('POST /:id/hour-entries/bill — 401 without token', async () => {
const res = await request(app)
.post(`/api/admin/customers/${customerId}/hour-entries/bill`)
.send({});
expect(res.status).toBe(401);
});
it('POST /:id/trigger-monthly-bill — 401 without token', async () => {
const res = await request(app)
.post(`/api/admin/customers/${customerId}/trigger-monthly-bill`)
.send({});
expect(res.status).toBe(401);
});
});
});
@@ -0,0 +1,200 @@
/**
* HTTP smoke tests for the core admin event CRUD endpoints:
* POST /api/admin/events (create)
* GET /api/admin/events (list + pagination)
* GET /api/admin/events/:id (detail + stats)
* PUT /api/admin/events/:id (update)
* DELETE /api/admin/events/:id (cascade delete)
*
* Safety net ahead of the adminEvents.js god-file decomposition —
* pins the request/response contracts of the main CRUD paths using
* the same real-SQLite harness as slideshowAdmin.test.js.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin events CRUD endpoints (smoke)', () => {
let db; let cleanup; let app; let adminId; let token;
// bootCrmDb's full migration run intermittently exceeds Jest's default
// 5s beforeAll timeout on slower CI runners; raise it.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('email_queue').del();
await db('events').del();
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
it('401s without an admin token', async () => {
const res = await request(app).get('/api/admin/events');
expect(res.status).toBe(401);
});
describe('POST /', () => {
it('creates an event, mints slug + share link and persists the row', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'wedding',
event_name: 'Smoke Wedding',
event_date: '2026-09-01',
// Field requirements default to ON (getEventFieldRequirements)
// so customer + admin contact data must be supplied.
customer_name: 'Client Person',
customer_email: 'client@example.com',
admin_email: 'admin@example.com',
require_password: false,
is_draft: true,
});
expect(res.status).toBe(200);
expect(res.body.id).toBeDefined();
expect(res.body.slug).toContain('wedding-smoke-wedding');
expect(typeof res.body.share_link).toBe('string');
expect(res.body.is_draft).toBe(true);
const row = await db('events').where({ id: res.body.id }).first();
expect(row).toBeDefined();
expect(row.event_name).toBe('Smoke Wedding');
expect(row.created_by).toBe(adminId);
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
// Draft creates must NOT queue the gallery_created email.
const queued = await db('email_queue').where({ event_id: res.body.id });
expect(queued).toHaveLength(0);
});
it('400s on an invalid event type', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'not-a-real-type',
event_name: 'Broken',
require_password: false,
});
expect(res.status).toBe(400);
expect(Array.isArray(res.body.errors)).toBe(true);
});
});
describe('GET /', () => {
it('lists events with pagination metadata and photo counts', async () => {
await insertEvent(db, adminId, { event_name: 'Alpha' });
await insertEvent(db, adminId, { event_name: 'Beta' });
const res = await auth(request(app).get('/api/admin/events'));
expect(res.status).toBe(200);
expect(res.body.events).toHaveLength(2);
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
for (const ev of res.body.events) {
expect(ev.photo_count).toBe(0);
}
});
});
describe('GET /:id', () => {
it('returns the event with photo/view stats', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
const res = await auth(request(app).get(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.event_name).toBe('Detail Event');
expect(res.body.photo_count).toBe(0);
expect(res.body.total_views).toBe(0);
expect(res.body.total_downloads).toBe(0);
expect(Array.isArray(res.body.recent_photos)).toBe(true);
});
it('404s for an unknown event id', async () => {
const res = await auth(request(app).get('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
describe('PUT /:id', () => {
it('updates mutable fields and persists them', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Before' });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
event_name: 'After',
welcome_message: 'Hello guests',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('After');
expect(row.welcome_message).toBe('Hello guests');
});
it('404s when updating a missing event', async () => {
const res = await auth(request(app).put('/api/admin/events/999999')).send({
event_name: 'Ghost',
});
expect(res.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('cascade-deletes the event row', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.message).toMatch(/deleted/i);
const row = await db('events').where({ id }).first();
expect(row).toBeUndefined();
});
it('404s when deleting a missing event', async () => {
const res = await auth(request(app).delete('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,123 @@
/**
* Pin the date-field normalisation in adminUsers transformer (#485).
*
* The Users page crashed on native/SQLite installs because Postgres
* returned ISO strings while SQLite returned epoch-millisecond
* integers, and the frontend `parseISO()` blew up on numbers with
* "e.split is not a function". The transformer now coerces every
* shape to an ISO 8601 string before serialising.
*
* These tests guard the contract so a future refactor can't quietly
* regress and re-break the same page on the same DB.
*/
const adminUsersRoute = require('../../src/routes/adminUsers');
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
describe('toIso', () => {
it('passes null and undefined through unchanged', () => {
expect(toIso(null)).toBeNull();
expect(toIso(undefined)).toBeUndefined();
// Empty string also short-circuits — important so an unset
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
expect(toIso('')).toBe('');
});
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
// 2026-05-14T10:00:00.000Z, in epoch ms.
const epochMs = 1778752800000;
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
});
it('coerces a stringified large integer to an ISO 8601 string', () => {
// Some SQLite drivers stringify large integers because they
// overflow JS safe-integer in the driver's serialiser. Re-coerce
// so the frontend doesn't try to parseISO('1778752800000').
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
});
it('coerces a Date instance via toISOString', () => {
const d = new Date('2026-01-01T12:34:56.000Z');
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
});
it('passes an existing ISO string through unchanged', () => {
const iso = '2026-05-14T10:00:00.000Z';
expect(toIso(iso)).toBe(iso);
});
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
// Defensive: anything that isn't a 10+ digit integer string is
// treated as already-stringified — the date library will surface
// the failure cleanly if it's malformed, rather than the
// transformer silently rewriting it.
expect(toIso('2026-05-14')).toBe('2026-05-14');
});
});
describe('transformUser', () => {
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
const sqliteRow = {
id: 1,
username: 'admin',
email: 'admin@example.com',
is_active: 1,
last_login: 1778752800000, // epoch ms
last_login_ip: '127.0.0.1',
created_at: 1778751144600, // epoch ms
updated_at: 1778751242320, // epoch ms
role_id: 1,
role_name: 'super_admin',
role_display_name: 'Super Admin',
created_by_username: null,
};
const out = transformUser(sqliteRow);
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
// Other fields untouched.
expect(out.username).toBe('admin');
expect(out.lastLoginIp).toBe('127.0.0.1');
});
it('leaves Postgres ISO strings intact', () => {
const pgRow = {
id: 2,
username: 'second',
email: 'second@example.com',
is_active: true,
last_login: '2026-05-14T10:00:00.000Z',
created_at: '2026-05-13T08:00:00.000Z',
updated_at: '2026-05-14T09:00:00.000Z',
};
const out = transformUser(pgRow);
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
});
it('keeps last_login null when the user has never logged in', () => {
const out = transformUser({
id: 3, username: 'fresh', email: 'fresh@example.com',
is_active: 1, last_login: null,
});
expect(out.lastLogin).toBeNull();
});
});
describe('transformInvitation', () => {
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
const out = transformInvitation({
id: 9,
email: 'invitee@example.com',
expires_at: 1779357600000,
created_at: 1778752800000,
role_name: 'admin',
invited_by: 'admin',
});
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
});
});
@@ -0,0 +1,153 @@
/**
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
*
* Four endpoints on the customer-facing surface:
* GET /:token — load contract for signing
* POST /:token/sign — in-browser canvas signature submission
* POST /:token/upload-signed-pdf — wet-signed PDF upload
* GET /:token/pdf — download the contract PDF
*
* Tests pin the publicTokenGuards.loadActionToken contract per
* endpoint and a few endpoint-specific shape assertions. Deeper
* service-layer behaviour (PDF generation, signature attachment,
* integrity-hash compute) is covered by the contractService unit
* tests; here we only assert the HTTP contract.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
describe('publicContracts routes', () => {
let db;
let cleanup;
let app;
let customerId;
let contractId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('contracts').insert({
contract_number: 'K-TEST-0001',
customer_account_id: customerId,
title: 'Test Booking Confirmation',
issue_date: new Date().toISOString().slice(0, 10),
status: 'sent',
language: 'de',
created_at: new Date().toISOString(),
}).returning('id');
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
});
describe('GET /:token', () => {
it('returns 404 for an unknown well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
expect(res.status).toBe(404);
});
it('rejects malformed tokens with 400 before reaching the guard', async () => {
const res = await request(app).get('/api/public/contracts/short');
expect(res.status).toBe(400);
});
it('returns 410 for an expired token', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
it('returns 200 with the contract payload for a valid token', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(200);
expect(res.body.contract).toBeDefined();
});
});
describe('POST /:token/sign', () => {
it('rejects missing required fields (name, accepted) with 400', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/sign`)
.send({}); // missing name + accepted
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on sign', async () => {
const fakeToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/sign`)
.send({ name: 'Jane Doe', accepted: true });
// Either 404 (token not found) or service-level error mapped to
// 4xx — what matters is the request didn't slip past validation.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
describe('POST /:token/upload-signed-pdf', () => {
it('rejects malformed tokens with 400 before multer runs', async () => {
const res = await request(app)
.post('/api/public/contracts/bad-token/upload-signed-pdf')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
});
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(404);
});
});
describe('GET /:token/pdf', () => {
it('returns 404 for an unknown token on PDF download', async () => {
const fakeToken = 'd'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
expect(res.status).toBe(404);
});
it('returns 410 for an expired token on PDF download', async () => {
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
});
});
@@ -0,0 +1,106 @@
/**
* HTTP route tests for backend/src/routes/publicPaymentCheck (P0 — #570).
*
* Two endpoints:
* GET /:token — load invoice payment-check view
* POST /:token — record customer's "paid / unpaid / partial" claim
*
* Unlike the quote / contract public routes, payment-check goes
* through invoiceService rather than the shared publicTokenGuards.
* Tests focus on the validator gates and the unknown-token edge.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paymentcheck-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
describe('publicPaymentCheck routes', () => {
let cleanup;
let app;
beforeAll(async () => {
let db;
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('GET /:token', () => {
it('rejects malformed tokens with 400', async () => {
const res = await request(app).get('/api/public/payment-check/short');
expect(res.status).toBe(400);
});
it('returns a service-level error for an unknown well-formed token (4xx, not 500)', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/payment-check/${fakeToken}`);
// Service throws NotFound or similar — what matters is the
// request reaches the service AND isn't an unhandled 500.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
});
});
describe('POST /:token', () => {
it('rejects malformed tokens with 400', async () => {
const res = await request(app)
.post('/api/public/payment-check/short')
.send({ action: 'paid_full' });
expect(res.status).toBe(400);
});
it('rejects an invalid action with 400', async () => {
const validToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('accepts the canonical four actions through the validator', async () => {
// Each action passes validator (token is well-formed); service
// then rejects unknown token with a 4xx — what we're pinning is
// the validator doesn't reject any of the canonical actions.
const validToken = 'c'.repeat(64);
for (const action of ['paid_full', 'paid_with_skonto', 'partial', 'unpaid']) {
// eslint-disable-next-line no-await-in-loop
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action });
// Either succeeds (rare — no real invoice) or service-level
// 4xx for unknown token. Must NOT be 400 (which would mean
// the validator rejected the action).
expect(res.status).not.toBe(400);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
}
});
it('rejects negative amountMinor with 400', async () => {
// Validator chain: optional({ values: 'falsy' }) means
// amountMinor=0 / null / undefined gets skipped (allowed). For
// any actually-supplied integer, isInt({ min: 1 }) takes over —
// pin the negative-rejection so a future refactor can't loosen
// the lower bound silently.
const validToken = 'd'.repeat(64);
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action: 'partial', amountMinor: -100 });
expect(res.status).toBe(400);
});
});
});
@@ -0,0 +1,171 @@
/**
* HTTP route tests for backend/src/routes/publicQuotes (P0 — #570).
*
* Public token guards (publicTokenGuards.loadActionToken) are the most
* security-sensitive surface in the CRM module — these are the routes
* a customer hits via the link in the quote email, reachable from any
* IP with the raw token. A regression here means leaked tokens become
* permanently usable, or worse, an expired token starts working again.
*
* Tests pin the contract documented in publicTokenGuards.js:
* - 404 on unknown token (and IP bad-attempt counter ticks)
* - 410 on expired token
* - 410 on NULL expiry (defensive — historical bug)
* - 429 after 20 invalid attempts from one IP
* - 200 + sanitised payload on valid token
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// MUST set the test DB env BEFORE the first require of anything that
// pulls in db.js — knexfile reads TEST_DATABASE_PATH at module-init
// time. The helper's bootCrmDb also has to be called once per file
// because the db module is cached; calling it from a second describe
// would silently reuse (or kill) the first instance's connection pool.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubquotes-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
describe('publicQuotes routes', () => {
let db;
let cleanup;
let app;
let customerId;
let quoteId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('quotes').insert({
quote_number: 'Q-TEST-0001',
customer_account_id: customerId,
currency: 'CHF',
issue_date: new Date().toISOString().slice(0, 10),
net_amount_minor: 10000,
vat_amount_minor: 0,
total_amount_minor: 10000,
status: 'sent',
language: 'de',
created_at: new Date(),
}).returning('id');
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Clear the in-memory IP bad-attempts map between scenarios so the
// lockout test starts from a known state — and so it doesn't bleed
// 429s into the unrelated tests that follow.
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) {
tokenGuards._internal.badAttempts.clear();
}
});
describe('GET /:token', () => {
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/quotes/${fakeToken}`);
expect(res.status).toBe(404);
expect(res.body.error).toBeTruthy();
});
it('rejects malformed (non-64-hex) tokens with 400', async () => {
const res = await request(app).get('/api/public/quotes/not-a-real-token');
expect(res.status).toBe(400);
});
it('returns 410 for a token whose expires_at is in the past', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
// The NULL-expiry guard in loadActionToken is intentionally
// defensive but the current schema declares
// quote_action_tokens.expires_at NOT NULL — so the defensive
// branch is unreachable at the route level. Test it directly
// against loadActionToken in a unit suite if you want coverage.
it('returns 200 with a sanitised quote payload for a valid token', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(200);
expect(res.body.quote).toBeDefined();
// API uses camelCase on the public view (see publicQuoteView in
// the route handler).
expect(res.body.quote.quoteNumber).toBe('Q-TEST-0001');
// Internal IDs / admin metadata must NOT appear on the public payload
expect(res.body.quote.customer_account_id).toBeUndefined();
expect(res.body.quote.customerAccountId).toBeUndefined();
expect(res.body.quote.createdByAdminId).toBeUndefined();
});
it('locks the IP after 20 invalid token lookups (429 TOKEN_LOOKUP_LOCKED)', async () => {
const fakeToken = 'b'.repeat(64);
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
const r = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(r.status).toBe(404);
}
const locked = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(locked.status).toBe(429);
expect(locked.body.code).toBe('TOKEN_LOOKUP_LOCKED');
}, 30000);
});
describe('POST /:token/respond', () => {
it('rejects an invalid action (must be accept|decline) with 400', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', { quote_id: quoteId });
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on respond', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/quotes/${fakeToken}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(404);
});
it('returns 410 when the token has expired (service-side check)', async () => {
// The POST path goes through quoteService.recordResponse rather
// than loadActionToken, so the error shape can differ from the
// GET expiry response — what matters is the HTTP status.
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(410);
});
});
});
@@ -0,0 +1,203 @@
/**
* HTTP route tests for the ADMIN Live Slideshow endpoints:
* POST /api/admin/events/:id/slideshow/generate
* POST /api/admin/events/:id/slideshow/disable
* PATCH /api/admin/events/:id/slideshow
* PUT /api/admin/settings/slideshow (global preset + watermark + fit)
*
* Pins the contracts + the two regressions hit during the build:
* - the events table has NO `updated_at` column, so these writes must NOT set
* it (else every call 500s — that was the original "Generate" failure);
* - the `slideshow` feature flag gates these endpoints (403 when off);
* - PUT /admin/settings/slideshow validates + clamps every key.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-admin-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin Live Slideshow endpoints', () => {
let db; let cleanup; let app; let adminId; let token;
// Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently
// exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise
// it so this doesn't block PRs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await setFlag(db, 'slideshow', true);
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
describe('generate / disable', () => {
it('mints a share token (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(typeof res.body.show_share_token).toBe('string');
expect(res.body.show_share_token).toHaveLength(64);
expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`);
const row = await db('events').where({ id }).first();
expect(row.show_share_token).toBe(res.body.show_share_token);
});
it('regenerate rotates the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'old-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(res.body.show_share_token).not.toBe('old-token');
});
it('disable nulls the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'live-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`));
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_share_token == null).toBe(true);
});
it('403 when the slideshow feature is off', async () => {
const id = await insertEvent(db, adminId);
await setFlag(db, 'slideshow', false);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(403);
});
it('401 without an admin token', async () => {
const id = await insertEvent(db, adminId);
const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`);
expect(res.status).toBe(401);
});
});
describe('PATCH /:id/slideshow', () => {
it('persists display + watermark mode (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({
show_interval_ms: 9000,
show_transition: 'cut',
show_transition_ms: 300,
show_watermark: true,
show_colorfilter: 'bw',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_interval_ms).toBe(9000);
expect(row.show_transition).toBe('cut');
expect(row.show_transition_ms).toBe(300);
expect(row.show_colorfilter).toBe('bw');
expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true);
});
it('show_watermark=null sets the column to NULL (inherit global)', async () => {
const id = await insertEvent(db, adminId, { show_watermark: 1 });
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_watermark == null).toBe(true);
});
it('400 on an invalid transition', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' });
expect(res.status).toBe(400);
});
});
describe('PUT /api/admin/settings/slideshow', () => {
const getSetting = async (key) => {
const row = await db('app_settings').where({ setting_key: key }).first();
return row ? JSON.parse(row.setting_value) : undefined;
};
it('persists the global preset + watermark + fit, clamping out-of-range values', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'contain',
slideshow_interval_ms: 9000,
slideshow_transition: 'slide',
slideshow_transition_ms: 250,
slideshow_colorfilter: 'sepia',
slideshow_watermark_enabled: true,
slideshow_watermark_opacity: 999, // clamp -> 100
slideshow_watermark_size: 99, // clamp -> 40
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('contain');
expect(await getSetting('slideshow_interval_ms')).toBe(9000);
expect(await getSetting('slideshow_transition')).toBe('slide');
expect(await getSetting('slideshow_transition_ms')).toBe(250);
expect(await getSetting('slideshow_colorfilter')).toBe('sepia');
expect(await getSetting('slideshow_watermark_enabled')).toBe(true);
expect(await getSetting('slideshow_watermark_opacity')).toBe(100);
expect(await getSetting('slideshow_watermark_size')).toBe(40);
});
it('coerces an invalid fit / transition to the safe default', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'banana',
slideshow_transition: 'wormhole',
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('cover');
expect(await getSetting('slideshow_transition')).toBe('crossfade');
});
});
});
@@ -0,0 +1,286 @@
/**
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
*
* These pin the two pieces of logic where real bugs lived during the build:
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
* (404 when off), plus token / expiry / draft / archived / inactive guards.
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
* image fit, and the fact that globals are read from `app_settings`
* (regression for the getSetting→nonexistent-`settings`-table bug).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-pub-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
const SLUG = 'wedding-test';
const TOKEN = 'show-tok-abcdef';
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function setSetting(db, key, value, type = 'slideshow') {
await db('app_settings').where({ setting_key: key }).del();
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
}
async function insertEvent(db, over = {}) {
const base = {
slug: SLUG,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
show_share_token: TOKEN,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = express();
app.use(express.json());
app.use(cookieParser());
// Both routers mount under /api/gallery in production; the display-only
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await db('feature_flags').del();
invalidateFeatureFlagCache();
invalidateSlideshowGlobals();
await setFlag(db, 'slideshow', true);
});
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
describe('resolveSlideshow guards', () => {
it('200 + per-event display settings on a live link', async () => {
await insertEvent(db, {
show_interval_ms: 8000,
show_transition: 'kenburns',
show_transition_ms: 1200,
show_colorfilter: 'sepia',
});
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
interval_ms: 8000,
transition: 'kenburns',
transition_ms: 1200,
colorfilter: 'sepia',
fit: 'cover',
photo_count: 0,
watermark: null,
});
});
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 on an unknown token', async () => {
await insertEvent(db);
const res = await request(app).get(stateUrl('not-the-token'));
expect(res.status).toBe(404);
});
it('404 when the share token is null (link never minted / disabled)', async () => {
await insertEvent(db, { show_share_token: null });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event has expired', async () => {
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is a draft', async () => {
await insertEvent(db, { is_draft: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is archived', async () => {
await insertEvent(db, { is_archived: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
});
describe('slideshowSettings — image fit (global, live)', () => {
it('reflects the global slideshow_fit setting', async () => {
await insertEvent(db);
await setSetting(db, 'slideshow_fit', 'contain');
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body.fit).toBe('contain');
});
});
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
async function enableGlobalWatermark() {
await setSetting(db, 'slideshow_watermark_enabled', true);
await setSetting(db, 'slideshow_watermark_source', 'logo');
await setSetting(db, 'slideshow_watermark_position', 'top-left');
await setSetting(db, 'slideshow_watermark_opacity', 40);
await setSetting(db, 'slideshow_watermark_style', 'original');
await setSetting(db, 'slideshow_watermark_size', 9);
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
}
it('inherits the global watermark when show_watermark is NULL', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toEqual({
url: '/uploads/logos/light.svg',
position: 'top-left',
opacity: 40,
style: 'original',
size: 9,
});
});
it('resolves the dark logo / favicon sources', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_source', 'favicon');
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
const res = await request(app).get(stateUrl());
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
});
it('per-event OFF override hides the watermark even when the global is on', async () => {
await insertEvent(db, { show_watermark: 0 });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
it('per-event ON override shows the watermark even when the global is off', async () => {
await insertEvent(db, { show_watermark: 1 });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_enabled', false);
const res = await request(app).get(stateUrl());
expect(res.body.watermark).not.toBeNull();
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
});
it('null when enabled but no logo URL is configured', async () => {
await insertEvent(db, { show_watermark: null });
await setSetting(db, 'slideshow_watermark_enabled', true);
// no branding_logo_url set
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
});
describe('display-only token guards (#646 review concern 1)', () => {
// Mint a real slideshow JWT, then prove it is denied on the
// download / upload / feedback routes (display-only contract).
async function slideshowJwt() {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
return res.body.token;
}
it('403 on whole-gallery download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on single-photo download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on bulk download-selected', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
expect(res.status).toBe(403);
});
it('403 on feedback POST', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
expect(res.status).toBe(403);
});
});
describe('GET /session', () => {
it('mints a token + sets the gallery cookie on a valid link', async () => {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(20);
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
expect(res.body).toHaveProperty('settings');
expect(res.body).toHaveProperty('photo_count', 0);
expect(res.headers['set-cookie']).toBeDefined();
});
it('404 when the feature is off', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,219 @@
/**
* Verifies the backup-integrity check covers every CRM document
* artefact column and correctly buckets each row into:
* - verifiedOk — file exists AND hash matches (when hash is stored)
* - missing — `*_path` set but file is not on disk
* - hashMismatches — file exists but bytes don't hash to `*_sha256`
* - existsButNoHash — file exists, no `*_sha256` column for this row
*
* Uses the CRM integration harness (bootCrmDb) so the schema +
* STORAGE_PATH wiring exactly mirrors production behaviour.
*
* Background: this service is the diagnostic for the
* `storage/business-docs/` gap fixed in the same PR — without it,
* a restored install would have audit-trail columns referencing
* files that no longer exist, but admins would have no way to see
* the breakage until a customer asked for their contract back.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
jest.setTimeout(30000);
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
let db;
let cleanup;
let customerId;
let storagePath;
let backupIntegrityService;
function seedFile(relPath, content) {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return { abs, relPath, sha: sha256(content) };
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
backupIntegrityService = require('../../src/services/backupIntegrityService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
// Wipe CRM rows between tests so each scenario sees a clean slate.
// Order matters: child tables before parents.
await db('invoice_line_items').del().catch(() => {});
await db('invoice_payment_log').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quote_line_items').del().catch(() => {});
await db('quotes').del().catch(() => {});
await db('contracts').del().catch(() => {});
});
it('returns an empty report when no documents reference any path', async () => {
const report = await backupIntegrityService.verifyDocumentArtefacts();
expect(report.summary.totalRows).toBe(0);
expect(report.summary.verifiedOk).toBe(0);
expect(report.missing).toEqual([]);
expect(report.hashMismatches).toEqual([]);
expect(report.existsButNoHash).toEqual([]);
expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice']));
});
it('flags a contract whose signed_pdf_path file is missing', async () => {
// Reference a file that we deliberately never create on disk.
// knex's `.returning('id')` returns `[{ id: N }]` on Postgres and
// newer SQLite, but `[N]` (plain int) on some SQLite versions —
// unwrap both shapes the same way the crmDb test harness does.
const inserted = await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf',
created_at: new Date(),
}).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
const hit = report.missing.find((m) => m.rowId === contractId);
expect(hit).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf',
});
expect(report.summary.missingFiles).toBe(1);
});
it('verifies a contract whose file exists AND hash matches', async () => {
const { relPath, sha } = seedFile(
'business-docs/contract/2026/C-2026-OK.pdf',
'this is the signed contract content',
);
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-OK',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_pdf_path: relPath,
signed_pdf_sha256: sha,
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1);
expect(report.summary.missingFiles).toBe(0);
expect(report.summary.hashMismatches).toBe(0);
});
it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => {
const { relPath } = seedFile(
'business-docs/contract/2026/C-2026-TAMPER.pdf',
'tampered bytes on disk',
);
const inserted = await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-TAMPER',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_pdf_path: relPath,
// Hash for completely different content — simulates tampering or
// bit-rot between sign-time and now.
signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'),
created_at: new Date(),
}).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
const hit = report.hashMismatches.find((m) => m.rowId === contractId);
expect(hit).toBeDefined();
expect(hit.expectedSha).not.toBe(hit.actualSha);
expect(hit.column).toBe('signed_pdf_path');
});
it('buckets signature PNGs into existsButNoHash (no hash column)', async () => {
const { relPath } = seedFile(
'business-docs/contract/signatures/99/customer-1700000000000.png',
'\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes
);
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-SIG',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_customer_signature_path: relPath,
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({
scope: ['contract-signature'],
});
expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1);
expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok"
expect(report.summary.missingFiles).toBe(0);
const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path');
expect(hit).toBeDefined();
});
it('respects the scope filter — contract scope skips quote/invoice tables', async () => {
// Seed an invoice with a missing pdf_path AND a contract with a
// missing signed_pdf_path. Scoping to contract should only flag
// the contract.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-2026-SCOPE',
status: 'sent',
pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf',
issue_date: '2026-01-01',
due_date: '2026-01-31',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf',
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
expect(report.scopes).toEqual(['contract']);
expect(report.missing.every((m) => m.table === 'contracts')).toBe(true);
expect(report.missing.some((m) => m.table === 'invoices')).toBe(false);
});
it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => {
// Imported invoices are the most catastrophic case — there's no
// renderer that can reproduce them. Verifier must check this column
// alongside invoices.pdf_path.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'IMP-2025-001',
status: 'sent',
imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf',
issue_date: '2025-06-01',
due_date: '2025-07-01',
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] });
const hit = report.missing.find((m) => m.column === 'imported_pdf_path');
expect(hit).toBeDefined();
});
});
@@ -0,0 +1,105 @@
/**
* Unit tests for the recipient resolver that routes invoice / Storno /
* reminder emails to a bookkeeper address when one is configured,
* while keeping the decision-maker (primary email) on CC.
*
* Pure helper, no DB, no side effects.
*/
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
describe('resolveBillingRecipients', () => {
it('routes to the primary email when no billing_email is set', () => {
expect(resolveBillingRecipients({ email: 'bride@example.com' }, null))
.toEqual({ to: 'bride@example.com', cc: undefined });
});
it('routes to billing_email and CCs the primary when both are set', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
billing_email: 'books@example.com',
}, null)).toEqual({
to: 'books@example.com',
cc: ['bride@example.com'],
});
});
it('folds the per-document cc_pdf_email into the CC list', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
billing_email: 'books@example.com',
}, 'advisor@example.com')).toEqual({
to: 'books@example.com',
cc: ['bride@example.com', 'advisor@example.com'],
});
});
it('uses cc_pdf_email alone when there is no billing_email', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
}, 'advisor@example.com')).toEqual({
to: 'bride@example.com',
cc: ['advisor@example.com'],
});
});
it('does not CC the primary onto itself when billing_email equals email', () => {
expect(resolveBillingRecipients({
email: 'same@example.com',
billing_email: 'same@example.com',
}, null)).toEqual({
to: 'same@example.com',
cc: undefined,
});
});
it('is case-insensitive when deduping addresses', () => {
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
// practice every mail server treats them as insensitive — and the
// admin entering "BRIDE@example.com" in one field and
// "bride@example.com" in another should not produce two copies.
expect(resolveBillingRecipients({
email: 'BRIDE@example.com',
billing_email: 'books@example.com',
}, 'bride@example.com')).toEqual({
to: 'books@example.com',
cc: ['BRIDE@example.com'],
});
});
it('trims whitespace around the addresses', () => {
expect(resolveBillingRecipients({
email: ' bride@example.com ',
billing_email: ' books@example.com\n',
}, '\tadvisor@example.com ')).toEqual({
to: 'books@example.com',
cc: ['bride@example.com', 'advisor@example.com'],
});
});
it('treats empty-string billing_email as not set', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
billing_email: '',
}, null)).toEqual({
to: 'bride@example.com',
cc: undefined,
});
});
it('returns an empty To when neither email nor billing_email is set', () => {
// Caller is responsible for surfacing this — emailProcessor's own
// validation will reject the empty recipient. The helper just
// refuses to crash.
expect(resolveBillingRecipients({}, null))
.toEqual({ to: '', cc: undefined });
});
it('tolerates a null customer without throwing', () => {
// Per-doc cc alone is never promoted to To: — it stays
// supplemental. A missing customer is a caller bug; we just refuse
// to crash and let emailProcessor reject the empty recipient.
expect(resolveBillingRecipients(null, 'a@b.com'))
.toEqual({ to: '', cc: undefined });
});
});
@@ -0,0 +1,109 @@
/**
* Unit tests for the pure helpers in contractService (migration 130).
*
* The DB-bound CRUD paths (createContract / sendContract /
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
* manual QA via the admin + public routes. This file covers the
* deterministic helpers so regressions in placeholder substitution or
* section ordering surface before they leak into a rendered contract.
*
* The service pulls in DB-bound peers (businessProfileService,
* pdfService, emailProcessor) at the top level. We stub the DB layer
* + the side-effect peers so the require chain doesn't try to connect
* to anything; the helpers under test are pure.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(),
}));
jest.mock('../../src/services/pdfService', () => ({
renderContractToBuffer: jest.fn(),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(),
}));
const { _internal } = require(servicePath);
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
describe('renderTemplatedBody', () => {
it('substitutes simple {{var}} placeholders', () => {
expect(renderTemplatedBody(
'Hello {{name}}, due in {{net_days}} days.',
{ name: 'Alice', net_days: 30 },
)).toBe('Hello Alice, due in 30 days.');
});
it('preserves unknown placeholders literally so admins notice missing fields', () => {
expect(renderTemplatedBody(
'Bill from {{issuer}} to {{customer_name}}',
{ issuer: 'PicPeak GmbH' },
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
});
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
expect(renderTemplatedBody(
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
{ has_skonto: true, pct: 2 },
)).toBe('Skonto: 2 % on early payment');
});
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
expect(renderTemplatedBody(
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
{ net_days: 30, has_skonto: false, pct: 2 },
)).toBe('Net 30 d.');
});
it('treats missing variables in {{#if}} as falsy', () => {
expect(renderTemplatedBody(
'A{{#if missing}}B{{/if}}C',
{ unrelated: 'foo' },
)).toBe('AC');
});
it('handles empty strings and missing variables map gracefully', () => {
expect(renderTemplatedBody('', { x: 1 })).toBe('');
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
});
it('passes through non-string input unchanged', () => {
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
});
it('substitutes numeric and falsy variable values as strings', () => {
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
});
});
describe('SECTIONS_ORDER', () => {
it('matches the canonical six-section order locked in the spec', () => {
expect(SECTIONS_ORDER).toEqual([
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
]);
});
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
const blocksService = require('../../src/services/contractBlocksService');
expect([...SECTIONS_ORDER].sort()).toEqual(
[...blocksService.ALLOWED_SECTIONS].sort(),
);
});
});
@@ -0,0 +1,100 @@
/**
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
*
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
* tuned for tracker scripts — defence-in-depth, even though the field is
* admin-only.
*/
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
describe('sanitizeTrackerSnippet (#663)', () => {
test('returns empty string for non-string / empty / whitespace input', () => {
expect(sanitizeTrackerSnippet(null)).toBe('');
expect(sanitizeTrackerSnippet(undefined)).toBe('');
expect(sanitizeTrackerSnippet(42)).toBe('');
expect(sanitizeTrackerSnippet('')).toBe('');
expect(sanitizeTrackerSnippet(' ')).toBe('');
});
test('passes through a Plausible-style script tag with data-domain', () => {
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://plausible.io/js/script.js"');
expect(out).toContain('data-domain="example.com"');
expect(out).toContain('defer');
});
test('passes through a Umami-style script with data-website-id', () => {
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://analytics.example.com/script.js"');
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
});
test('passes through inline script body unchanged', () => {
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('window.GA = "x"');
expect(out).toContain('console.log("init")');
});
test('allows <noscript> fallback', () => {
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('<noscript>');
});
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
const out = sanitizeTrackerSnippet(
'<link rel="preconnect" href="https://t.example.com">'
+ '<link rel="dns-prefetch" href="https://t.example.com">',
);
expect(out).toContain('rel="preconnect"');
expect(out).toContain('rel="dns-prefetch"');
expect(out).toContain('href="https://t.example.com"');
});
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
expect(out).not.toContain('stylesheet');
expect(out).not.toContain('href');
});
test('strips disallowed tags entirely', () => {
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('iframe');
expect(out).not.toContain('<div');
expect(out).not.toContain('<h1');
});
test('strips javascript: URLs from script src', () => {
const input = '<script src="javascript:alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('javascript:');
});
test('strips data: URLs from script src', () => {
const input = '<script src="data:text/javascript,alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('data:text/javascript');
});
test('strips on* event-handler attributes (defence-in-depth)', () => {
// event-handler attrs are not in our allowlist; sanitize-html strips them.
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('onload');
expect(out).toContain('src="https://t.example/x.js"');
});
test('returns empty string on unparseable input rather than throwing', () => {
// sanitize-html is fault-tolerant — pass deliberately malformed and
// confirm we don't blow up.
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
});
});
@@ -0,0 +1,213 @@
/**
* Tests for the passive-customer surface:
*
* - createDirect inserts a customer with password_hash=null,
* queueEmail is never called, race-guard rejects duplicates
* - createInvitation allows passing through when the existing
* customer is passive (promotion path); still rejects when the
* existing customer is active (real duplicate)
* - acceptInvitation upserts into an existing passive customer
* row (preserving id) when one exists; inserts a fresh row
* otherwise; still rejects when the existing customer is active
*
* Pure unit tests — db is mocked via a thenable chain so we can
* inspect every insert / update payload without spinning up SQLite.
*/
// ----- mock db chain --------------------------------------------------
//
// We need fine-grained control over which row each table-name returns
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
// what `.update(...)` resolves to. The chain is a thenable proxy that
// terminates on the call we care about.
const tableSeeds = {}; // table → first-row return value
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
const updateCalls = []; // [{ table, where, updates }]
let nextInsertId = 1000;
function resetMockDb() {
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
for (const k of Object.keys(insertResults)) delete insertResults[k];
updateCalls.length = 0;
nextInsertId = 1000;
}
function makeChain(tableName) {
const chain = {
_whereClauses: [],
where(...args) { this._whereClauses.push(args); return this; },
whereNull() { return this; },
whereNot() { return this; },
andWhere() { return this; },
orderBy() { return this; },
leftJoin() { return this; },
groupBy() { return this; },
select(...args) {
// listCustomers / search → return seeded array
const seeded = tableSeeds[`${tableName}__select`];
return Promise.resolve(seeded || []);
},
first() {
const seeded = tableSeeds[tableName];
return Promise.resolve(seeded);
},
insert(payload) {
const id = nextInsertId++;
insertResults[tableName] = insertResults[tableName] || [];
insertResults[tableName].push({ ...payload, id });
const result = { id };
return {
returning() { return Promise.resolve([result]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
update(updates) {
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
return Promise.resolve(1);
},
del() { return Promise.resolve(1); },
raw() { return this; },
};
return chain;
}
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
mockDbFn.raw = jest.fn();
mockDbFn.transaction = async (cb) => cb(mockDbFn);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
const mockQueueEmail = jest.fn(async () => {});
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: mockQueueEmail,
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: { default_locale: 'de' },
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const customerAccountsService = require('../../src/services/customerAccountsService');
beforeEach(() => {
resetMockDb();
mockQueueEmail.mockClear();
});
// --------------------------------------------------------------------
// createDirect
// --------------------------------------------------------------------
describe('createDirect', () => {
it('inserts a customer with password_hash=null, is_active=true', async () => {
tableSeeds.customer_accounts = undefined; // no duplicate
const result = await customerAccountsService.createDirect({
email: 'test@example.com',
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
createdByAdminId: 5,
});
expect(result.id).toBeDefined();
const inserted = insertResults.customer_accounts[0];
expect(inserted.email).toBe('test@example.com');
expect(inserted.password_hash).toBeNull();
expect(inserted.created_by_admin_id).toBe(5);
expect(inserted.first_name).toBe('Anna');
expect(inserted.company_name).toBe('ACME GmbH');
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
expect([true, 1, '1']).toContain(inserted.is_active);
});
it('defaults preferred_language from the business profile', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: 'de@example.com',
prefill: {},
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
});
it('honours preferred_language when the admin pre-fills it', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: 'fr@example.com',
prefill: { preferred_language: 'fr' },
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
});
it('rejects when a customer with the email already exists', async () => {
tableSeeds.customer_accounts = { id: 7, email: 'dup@example.com', password_hash: 'whatever' };
await expect(customerAccountsService.createDirect({
email: 'dup@example.com',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/already exists/);
});
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
tableSeeds.customer_accounts = undefined;
await expect(customerAccountsService.createDirect({
email: '',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/Email is required/);
});
it('NEVER queues an invitation email (regression guard)', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: 'silent@example.com',
prefill: {},
createdByAdminId: 1,
});
expect(mockQueueEmail).not.toHaveBeenCalled();
});
});
// --------------------------------------------------------------------
// createInvitation passive-allowance behaviour
// --------------------------------------------------------------------
describe('createInvitation — duplicate-email guard', () => {
it('still rejects when the existing customer has a password (real duplicate)', async () => {
tableSeeds.customer_accounts = { id: 1, email: 'active@example.com', password_hash: 'hash' };
await expect(customerAccountsService.createInvitation({
email: 'active@example.com',
invitedById: 5,
prefill: null,
})).rejects.toThrow(/already exists/);
expect(mockQueueEmail).not.toHaveBeenCalled();
});
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
tableSeeds.customer_accounts = { id: 7, email: 'passive@example.com', password_hash: null };
// no pending invitation
// The chain returns `tableSeeds.customer_invitations` for .first()
// and we haven't seeded one, so it's undefined → allowed through.
const out = await customerAccountsService.createInvitation({
email: 'passive@example.com',
invitedById: 9,
prefill: { first_name: 'Anna' },
});
expect(out.id).toBeDefined();
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,182 @@
/**
* Unit tests for the pure helpers in customerHoursService (migration
* 129). The CRUD paths themselves are exercised end-to-end via the
* admin/customers routes during manual QA; this file covers the
* deterministic logic so regressions in the rate / duration / lock
* resolution show up before they hit a real invoice.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
// The service imports invoiceService which pulls in the DB. We don't
// need either for the pure helpers — stub the DB layer so the
// require chain doesn't try to connect to anything.
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/invoiceService', () => ({}));
const { _internal } = require(servicePath);
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
describe('computeDurationMinutes', () => {
it('returns minute count for a basic window', () => {
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
});
it('handles single-minute precision', () => {
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
});
it('rejects malformed input', () => {
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
});
it('rejects zero or negative duration', () => {
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
});
});
describe('resolveEffectiveRate', () => {
it('prefers the per-entry override when set', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: 20000 },
{ hourly_rate_minor: 15000 },
)).toBe(20000);
});
it('falls back to the customer default when no override', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
)).toBe(15000);
});
it('throws when override, customer rate, AND install default are all unset', () => {
expect(() => resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
null,
)).toThrow(/No hourly rate/);
});
it('falls back to the install-wide default when override + customer rate are unset', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
12000,
)).toBe(12000);
});
it('customer rate wins over the install-wide default', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
12000,
)).toBe(15000);
});
it('treats override=0 as "explicitly zero" (not null)', () => {
// Override === 0 is unusual but legal — pro bono blocks, internal
// tracking. Must NOT fall through to the customer default.
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: 0 },
{ hourly_rate_minor: 15000 },
)).toBe(0);
});
});
describe('isEntryLocked', () => {
it('unbilled entry → not locked', () => {
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
});
it('monthly draft → not locked (still accumulating)', () => {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
)).toBe(false);
});
it('standalone draft with no send time → not locked', () => {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
)).toBe(false);
});
it('future-scheduled draft → not locked', () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
)).toBe(false);
});
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
)).toBe(true);
});
it('sent / paid / overdue / cancelled → locked', () => {
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
)).toBe(true);
}
});
it('entry references a deleted invoice (null) → treat as unbilled', () => {
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
});
});
describe('buildLineItemFromEntry', () => {
const baseEntry = {
entry_date: '2026-05-20',
start_time: '09:00',
end_time: '11:30',
duration_minutes: 150,
description: 'Editing wedding photos',
};
it('formats the description per spec', () => {
const li = buildLineItemFromEntry(baseEntry, 15000);
expect(li.description).toBe('2026-05-20 09:0011:30 (2.50h): Editing wedding photos');
});
it('omits the colon when no description', () => {
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
expect(li.description).toBe('2026-05-20 09:0011:30 (2.50h)');
});
it('quantity is decimal hours with 2 places', () => {
const li = buildLineItemFromEntry(baseEntry, 15000);
expect(li.quantity).toBeCloseTo(2.5, 5);
});
it('line_total rounds correctly for non-clean durations', () => {
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
const li = buildLineItemFromEntry(
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
10000,
);
expect(li.line_total_minor).toBe(2500);
});
it('zero-rate line items produce a zero total without exploding', () => {
const li = buildLineItemFromEntry(baseEntry, 0);
expect(li.line_total_minor).toBe(0);
expect(li.unit_price_minor).toBe(0);
});
});
@@ -0,0 +1,76 @@
/**
* Pure-logic tests for the #493 download-filename helpers that don't depend
* on the DB (those are covered by the route integration suite).
*/
const {
pickRawDownloadName,
getZipEntryNames,
} = require('../../src/services/downloadFilenameService');
describe('pickRawDownloadName', () => {
it('returns the storage filename when the toggle is off', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, false)
).toBe('slug_001.jpg');
});
it('returns original_filename when the toggle is on', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, true)
).toBe('DSC_1.jpg');
});
it('falls back to storage filename when original_filename is missing', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: null }, true)
).toBe('slug_001.jpg');
});
it('produces a stable last-resort name when both are missing', () => {
expect(pickRawDownloadName({ id: 42 }, true)).toBe('photo-42.jpg');
});
});
describe('getZipEntryNames', () => {
it('uses original filenames with deterministic suffixes on collision', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1234.jpg' },
{ id: 2, filename: 'slug_002.jpg', original_filename: 'DSC_1234.jpg' },
{ id: 3, filename: 'slug_003.jpg', original_filename: 'DSC_1235.jpg' },
];
expect(getZipEntryNames(photos, true)).toEqual([
'DSC_1234.jpg',
'DSC_1234_1.jpg',
'DSC_1235.jpg',
]);
});
it('falls back to storage filename per-photo when original is missing', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' },
{ id: 2, filename: 'slug_002.jpg', original_filename: null },
];
expect(getZipEntryNames(photos, true)).toEqual([
'DSC_1.jpg',
'slug_002.jpg',
]);
});
it('returns storage filenames when the toggle is off, dedup still applies', () => {
const photos = [
{ id: 1, filename: 'a.jpg', original_filename: 'DSC_1.jpg' },
{ id: 2, filename: 'a.jpg', original_filename: 'DSC_2.jpg' },
];
expect(getZipEntryNames(photos, false)).toEqual(['a.jpg', 'a_1.jpg']);
});
it('sanitizes path-traversal attempts that sneak into original_filename', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: '../etc/passwd' },
];
const [name] = getZipEntryNames(photos, true);
expect(name).not.toContain('..');
expect(name).not.toContain('/');
});
});
@@ -0,0 +1,119 @@
/**
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
* gates the migration-137 calendar time columns on events.
*
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
* helper and write through hasColumnCached guards; those are
* exercised in manual QA. This file pins the contract so a future
* tweak to the validation rules doesn't silently break it.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
// Stub every DB-bound peer so the require chain doesn't try to open
// a knex connection. The helper under test is pure.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
const { normaliseEventTimeTriple } = require(servicePath);
describe('normaliseEventTimeTriple', () => {
it('defaults to full-day when is_full_day is undefined', () => {
expect(normaliseEventTimeTriple({})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('forces times to null when is_full_day is true even if times are supplied', () => {
expect(normaliseEventTimeTriple({
is_full_day: true,
event_time_start: '10:00',
event_time_end: '12:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('accepts a valid timed range when is_full_day is false', () => {
expect(normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '09:30',
event_time_end: '17:00',
})).toEqual({
event_time_start: '09:30',
event_time_end: '17:00',
is_full_day: false,
});
});
it('throws when is_full_day is false and start is missing/malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '25:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '9:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
});
it('throws when end is missing or malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '12:99',
})).toThrow(/HH:MM/);
});
it('throws when end is at or before start', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '10:00',
})).toThrow(/after/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '15:00',
event_time_end: '10:00',
})).toThrow(/after/);
});
it('parses string boolean flag', () => {
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
// the helper consumes them transparently.
expect(normaliseEventTimeTriple({
is_full_day: 'false',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: '08:00',
event_time_end: '09:00',
is_full_day: false,
});
expect(normaliseEventTimeTriple({
is_full_day: '1',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
});
@@ -0,0 +1,160 @@
/**
* Unit tests for the accounting money logic — re-bill markup (incoming
* invoices) and internal-expense amount/build. Pure functions via _internal.
*/
const expenseService = require('../../src/services/expenseService');
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
describe('computeMarkupMinor', () => {
it('percent of base, rounded', () => {
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
});
it('flat / none', () => {
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
});
});
describe('resolveMarkup precedence (no contract / no DB)', () => {
it('override > source clause', async () => {
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
});
it("source clause when no override", async () => {
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
});
it('none when nothing set', async () => {
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
});
});
describe('computeExpenseAmount', () => {
it('mileage / per-diem = quantity x rate, rounded', () => {
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
});
it('amount = the entered minor amount', () => {
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
});
it('null when quantity or rate missing', () => {
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
});
});
describe('buildExpenseInsert (internal expense)', () => {
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
expect(row.kind).toBe('amount');
expect(row.disposition).toBe('eigener_aufwand');
expect(row.tax_treatment).toBe('domestic');
expect(row.status).toBe('open');
expect(row.chf_amount_minor).toBe(5000);
expect(row.created_by_admin_id).toBe(7);
expect(row.inbound_document_id).toBeNull();
});
it('mileage uses the override rate, else the settings km rate', () => {
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
expect(withDefault.rate_minor).toBe(70);
expect(withDefault.chf_amount_minor).toBe(2940);
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
expect(withOverride.rate_minor).toBe(100);
expect(withOverride.chf_amount_minor).toBe(4200);
});
it('per_diem uses days x per-diem rate', () => {
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
expect(row.rate_minor).toBe(8000);
expect(row.chf_amount_minor).toBe(16000);
});
it('event_id null = booked to company; proof path carried', () => {
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
expect(company.event_id).toBeNull();
expect(company.receipt_path).toBe('/p/x.pdf');
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
expect(evt.event_id).toBe(9);
});
});
describe('buildInboundLineItem (re-bill line)', () => {
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
expect(li.unit_price_minor).toBe(11000);
expect(li.line_total_minor).toBe(11000);
expect(li.quantity).toBe(1);
expect(li.description).toBe('ACME (Weiterverrechnung)');
});
it('passthrough: distinct suffix, no markup passes through at cost', () => {
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
expect(li.unit_price_minor).toBe(5000);
expect(li.description).toBe('SBB (Durchlaufende Position)');
});
it('falls back to net amount + generic label when total/supplier missing', () => {
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
expect(li.unit_price_minor).toBe(7300);
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
});
it('throws when there is no amount to re-bill', () => {
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
.toThrow(/no amount/i);
});
});
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
const reclaim = ['CH', 'LI'];
it('explicit valid treatment always wins', () => {
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
});
it('country in the reclaim list → domestic', () => {
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
});
it('country outside the reclaim list → foreign non-reclaimable', () => {
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
});
it('unknown / empty country falls back to domestic', () => {
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
});
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
});
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
});
});
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
const future = new Date(Date.now() + 86400000).toISOString();
const past = new Date(Date.now() - 86400000).toISOString();
it('monthly draft and not-yet-armed scheduled are mutable', () => {
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
});
it('armed / issued invoices are locked', () => {
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
});
});
@@ -0,0 +1,122 @@
/**
* Tests for the migration-119 hierarchy support in invoiceService —
* the shared helpers come from quoteService._internal (validated in
* quoteService.hierarchy.test.js), so we focus here on the
* invoice-specific seams:
*
* - quote → invoice cloner preserves parent_position + details_text
* across the conversion
* - the cloner's installment "adjustment" line only reconciles
* against TOP-LEVEL cloned items (sub-items don't contribute to
* net so they can't appear in the sum)
*
* Pure helper, no DB.
*/
const quoteService = require('../../src/services/quoteService');
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
describe('quote → invoice cloner shape', () => {
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
// take source quote line items (with parent_position) and produce the
// `cloned` array that's passed into insertLineItemsHierarchical.
function modelCloner(sourceLines) {
return sourceLines.map((li) => ({
position: parseInt(li.position, 10),
quantity: Number(li.quantity || 1),
description: li.description,
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
discount_percent: Number(li.discount_percent || 0),
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
details_text: li.details_text || null,
}));
}
it('preserves parent_position so the hierarchy carries across conversion', () => {
const source = [
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
];
const cloned = modelCloner(source);
expect(cloned[0].parent_position).toBeNull();
expect(cloned[1].parent_position).toBe(1);
expect(cloned[2].parent_position).toBe(1);
// The cloned shape passes hierarchy validation — same positions
// means the same parent links work without any remap.
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
});
it('preserves details_text verbatim', () => {
const source = [
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
details_text: 'Includes online gallery + 100 high-res downloads.' },
];
const cloned = modelCloner(source);
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
});
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
// Recreate the inner math from scheduleInvoicesForEvent: sum
// only line_total_minor where parent_position is null. Sub-items
// would otherwise double-count and skew the adjustment.
//
// Note: the cloner stores raw line_total_minor on each row from
// the source quote. By the time this sum runs, the parent's
// line_total_minor has already been resolved upstream (via
// computeTotals on the quote at save time) — so iterating
// top-level only sums the resolved parent totals + standalone
// top-level items. Sub-items never contribute here regardless of
// whether their parent's total was auto-resolved or not.
const cloned = modelCloner([
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
// Sub-items €150 + €200 + €100 — shown for transparency, must
// NOT enter the reconciliation sum.
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
// Another top-level €100
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
]);
const clonedSum = cloned
.filter((x) => x.parent_position == null)
.reduce((s, x) => s + x.line_total_minor, 0);
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
expect(clonedSum).toBe(55000);
});
});
describe('insertLineItemsHierarchical for invoices', () => {
function makeTrxMock() {
let nextId = 200;
const inserts = [];
const trx = (tableName) => ({
insert(row) {
const id = nextId++;
inserts.push({ table: tableName, row: { ...row, id } });
return {
returning() { return Promise.resolve([{ id }]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
});
return { trx, inserts };
}
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
]);
expect(inserts).toHaveLength(2);
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
// Parent inserted first, sub-item second with parent_line_item_id
// matching the parent's synthesised id.
expect(inserts[0].row.parent_line_item_id).toBeNull();
expect(inserts[1].row.parent_line_item_id).toBe(200);
});
});
@@ -0,0 +1,329 @@
/**
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
*
* Validation tests run against the pure validator directly. Orchestration
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
* — chains are queued per table and assertions probe insert/update/delete
* call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('validateInstallmentPlanInput', () => {
const { validateInstallmentPlanInput } = invoiceService;
it('throws on empty array', () => {
expect(() => validateInstallmentPlanInput([]))
.toThrow(/non-empty array/);
});
it('throws on non-array', () => {
expect(() => validateInstallmentPlanInput(null))
.toThrow(/non-empty array/);
});
it('throws on out-of-range percent', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
expect(() => validateInstallmentPlanInput([
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
});
it('throws on unknown trigger', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
])).toThrow(/invalid trigger/);
});
it('throws when percents do not sum to 100', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 50, trigger: 'before_event', offset_days: -7 },
])).toThrow(/must sum to 100/);
});
it('accepts a valid three-row plan with mixed triggers', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
])).not.toThrow();
});
it('tolerates 0.001 rounding drift in the sum', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
])).not.toThrow();
});
});
describe('updateInstallmentPlan — guards', () => {
beforeEach(() => resetChains());
const goodPlan = [
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
];
it('rejects when dealUuid is missing', async () => {
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the deal has no invoices', async () => {
pickChainFor('invoices')._selectResult = [];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 404 });
});
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
});
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
});
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
});
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
const badPlan = [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 30, trigger: 'before_event', offset_days: -7 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
});
});
describe('updateInstallmentPlan — reshape (smoke)', () => {
beforeEach(() => resetChains());
const sibling = (overrides) => ({
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
shipping_amount_minor: 0, vat_rate: 7.7,
customer_account_id: 5, source_quote_id: null, event_id: null,
event_name: 'Wedding', event_date: '2026-08-15',
language: 'de', currency: 'CHF',
issue_date: '2026-05-25', due_date: '2026-06-24',
cc_pdf_email: null,
payment_net_days_template_id: null, payment_timing_template_id: null,
payment_term_snapshot: null,
...overrides,
});
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2, 3]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([]);
// Sequence helper never touched on a same-count reshape.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('invoices')._insertResult = [{ id: 99 }];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created.length).toBe(1);
expect(result.deleted).toEqual([]);
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
});
it('shrinks 3→2 by deleting the third row + its line items', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([3]);
// Line items + invoice rows deleted on the trimmed sibling.
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
expect(pickChainFor('invoices').del).toHaveBeenCalled();
});
});
@@ -0,0 +1,374 @@
/**
* Tests for invoiceService lock + state-transition guards.
*
* Focuses on the rules that protect tax/audit integrity:
* - reissueInvoice refuses to act on `scheduled` (use Edit)
* - reissueInvoice cancels + clones any other status
* - releaseForDelivery refuses to act on non-pending_delivery
* - recordPaymentCheckAction refuses already-used / expired tokens
*
* db is deep-mocked so the tests are deterministic and fast.
*/
// Mock db chain: each table call returns a builder whose methods
// chain (return `this`) until a terminal method (.first / .update /
// .insert / .returning) resolves with the queued value.
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
_allRows: [],
// knex chains are thenable — awaiting them runs the query and
// resolves with the row set. We mirror that so callers can
// `await trx('t').where(...).orderBy(...)` and get an array.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
// select is both chainable (`.select('col').first()`) and awaitable
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
// db.transaction(cb) runs the callback with a "trx" — for our
// purposes the same chain factory works as trx.
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('invoiceService.reissueInvoice', () => {
beforeEach(() => resetChains());
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
await expect(invoiceService.reissueInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('throws when the source invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.reissueInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('cancels the original and creates a new row when status is sent', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'sent', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
shipping_amount_minor: 0, cc_pdf_email: null,
business_bank_account_id: null, qr_format: null,
payment_term_template_id: null, event_id: null,
source_quote_id: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, is_active: 1, feature_bills: 1,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const result = await invoiceService.reissueInvoice(1, 42);
expect(result.id).toBeDefined();
expect(result.replaces).toBe(1);
});
});
describe('invoiceService.createStorno', () => {
beforeEach(() => resetChains());
it('rejects when the source invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.createStorno(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('rejects when the source is already cancelled (no double-Storno)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects when asked to Storno a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('inserts a Storno row and flips the original on a sent invoice', async () => {
// Original is `sent`, no line items, no event.
const invoicesChain = pickChainFor('invoices');
invoicesChain._firstValue = {
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
net_amount_minor: 30000, vat_amount_minor: 2310,
total_amount_minor: 32310, shipping_amount_minor: 0,
cc_pdf_email: null, event_id: null,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const stornoId = await invoiceService.createStorno(1, 42);
expect(stornoId).toBeDefined();
// The mock chain's .update() is called twice on `invoices`:
// 1) `.insert(...).returning('id')` for the Storno row
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
// We just verify the helpers were exercised on the right table.
expect(invoicesChain.insert).toHaveBeenCalled();
expect(invoicesChain.update).toHaveBeenCalled();
// The Storno insert payload should carry kind='storno' and
// negated row-level totals. Inspect the first insert call's
// payload to confirm.
const insertedRow = invoicesChain.insert.mock.calls[0][0];
expect(insertedRow.kind).toBe('storno');
expect(insertedRow.net_amount_minor).toBe(-30000);
expect(insertedRow.vat_amount_minor).toBe(-2310);
expect(insertedRow.total_amount_minor).toBe(-32310);
expect(insertedRow.cancels_invoice_id).toBe(1);
expect(insertedRow.status).toBe('scheduled');
// No payment instrument on a Storno.
expect(insertedRow.business_bank_account_id).toBeNull();
expect(insertedRow.qr_format).toBeNull();
expect(insertedRow.payment_term_template_id).toBeNull();
// Storni have no real payment due, but the schema's NOT NULL
// constraint on due_date forces a value — we mirror issue_date.
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
});
});
describe('invoiceService.cancelInvoice', () => {
beforeEach(() => resetChains());
it('rejects when the invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.cancelInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
const result = await invoiceService.cancelInvoice(1, 42);
expect(result).toEqual({ cancelled: true, stornoId: null });
});
});
describe('invoiceService.releaseForDelivery', () => {
beforeEach(() => resetChains());
it('refuses when status is not pending_delivery', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
await expect(invoiceService.releaseForDelivery(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
it('404s when the invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.releaseForDelivery(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe('invoiceService.recordPaymentCheckAction', () => {
beforeEach(() => resetChains());
it('rejects invalid actions', async () => {
await expect(invoiceService.recordPaymentCheckAction({
token: 'abc', action: 'foo',
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the token is not on file', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 404 });
});
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: new Date(),
expires_at: new Date(Date.now() + 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
});
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() - 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
});
it('rejects partial with amount <= 0', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects partial with amount > outstanding', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
})).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('invoiceService.queuePaymentCheckEmail', () => {
beforeEach(() => resetChains());
it('skips when invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'not_found' });
});
it('skips when status is not sent/overdue', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'paid',
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res.sent).toBe(false);
expect(res.reason).toMatch(/wrong_status_paid/);
});
it('respects the 24h throttle', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
});
it('bypasses the throttle when skipThrottle=true', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
customer_account_id: 5,
created_by_admin_id: 42,
total_amount_minor: 10000,
currency: 'CHF',
language: 'de',
reminder_level: 0,
due_date: '2026-05-01',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
expect(res.sent).toBe(true);
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
});
});
@@ -0,0 +1,259 @@
/**
* Smoke tests for invoiceService's primary flows ahead of the god-file
* decomposition — createInvoice happy path (incl. the line-item
* totals/VAT math), list/get reads, and the status-transition guards
* on cancelInvoice / releaseForDelivery.
*
* Uses the same deep-mocked db pattern as
* invoiceService.installmentPlan.test.js — chains are queued per table
* and assertions probe insert/update call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
jest.clearAllMocks();
}
const activeCustomer = {
id: 5, is_active: 1, feature_bills: 1,
billing_cadence: 'per_event', preferred_language: 'de',
};
describe('createInvoice — happy path + totals', () => {
beforeEach(() => resetChains());
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
pickChainFor('invoices')._insertResult = [{ id: 777 }];
const result = await invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 8.1,
lineItems: [
// 2 × 100.00 = 200.00
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
// 50.00 with 10% discount = 45.00
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
// Parent header — total auto-resolves from priced sub-items (350.00)
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
],
}, 1);
expect(result.invoiceIds).toEqual([777]);
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
// double-count. VAT = round(59500 × 8.1%) = 4820.
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
invoice_number: 'R-2026-0042',
customer_account_id: 5,
currency: 'CHF',
status: 'scheduled',
net_amount_minor: 59500,
vat_rate: 8.1,
vat_amount_minor: 4820,
shipping_amount_minor: 0,
total_amount_minor: 64320,
installment_total: 1,
}));
// Exactly one sequence number claimed for a single-row create.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
// Line items landed in invoice_line_items.
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
});
it('409s on a deactivated customer before touching the sequence', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
await expect(invoiceService.createInvoice({
customerAccountId: 5, vatRate: 0, lineItems: [],
}, 1)).rejects.toMatchObject({ statusCode: 409 });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
await expect(invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 7.7,
lineItems: [
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
],
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
});
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
beforeEach(() => resetChains());
it('lists invoices with total + pagination echo', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, invoice_number: 'R-2026-0001' },
{ id: 2, invoice_number: 'R-2026-0002' },
];
pickChainFor('invoices')._firstValue = { total: 7 };
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
expect(result.rows).toHaveLength(2);
expect(result.total).toBe(7);
expect(result.page).toBe(2);
expect(result.pageSize).toBe(10);
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
});
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
pickChainFor('invoice_line_items as li')._selectResult = [
{ id: 30, position: 1, description: 'Shoot' },
];
pickChainFor('invoice_payment_log')._selectResult = [];
const result = await invoiceService.getInvoiceById(3);
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
expect(result.lineItems).toHaveLength(1);
expect(result.payments).toEqual([]);
});
it('getInvoiceById returns null for an unknown id', async () => {
pickChainFor('invoices')._firstValue = undefined;
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
});
});
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
beforeEach(() => resetChains());
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
};
const result = await invoiceService.cancelInvoice(9, 1);
expect(result).toEqual({ cancelled: true, stornoId: null });
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'cancelled' })
);
});
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'cancelled', kind: 'invoice',
};
await expect(invoiceService.cancelInvoice(9, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
pickChainFor('invoices')._firstValue = {
id: 10, status: 'sent', kind: 'storno',
};
await expect(invoiceService.cancelInvoice(10, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
pickChainFor('invoices')._firstValue = {
id: 11, status: 'sent', kind: 'invoice',
};
await expect(invoiceService.releaseForDelivery(11, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
});
@@ -0,0 +1,249 @@
/**
* Tests for ledgerService (Accounting Layer A).
*
* Two layers:
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
* the per-tool CSV shapes.
*/
// ----- canned data per table ------------------------------------------
let accountsRows = [];
let vatRows = [];
let invoiceRows = [];
let inboundRows = [];
let expenseRows = [];
function makeChain(rows) {
const c = {
_rows: rows,
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
leftJoin() { return this; },
where() { return this; },
whereNot() { return this; },
whereIn() { return this; },
whereNotIn() { return this; },
whereBetween() { return this; },
whereRaw() { return this; },
orderBy() { return this; },
orderByRaw() { return this; },
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
select() { return Promise.resolve(this._rows); },
first() { return Promise.resolve(this._rows[0]); },
};
return c;
}
const mockDbFn = jest.fn((table) => {
switch (table) {
case 'ledger_accounts': return makeChain(accountsRows);
case 'vat_codes': return makeChain(vatRows);
case 'invoices': return makeChain(invoiceRows);
case 'inbound_documents': return makeChain(inboundRows);
case 'expenses': return makeChain(expenseRows);
default: return makeChain([]);
}
});
mockDbFn.raw = (s) => s;
mockDbFn.schema = {
hasTable: jest.fn(async () => true),
hasColumn: jest.fn(async () => true),
};
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
const SETTINGS = {
ledger_account_debitoren: '1100',
ledger_account_kreditoren: '2000',
ledger_account_default_revenue: '3400',
ledger_account_default_expense: '6700',
ledger_account_mileage: '6200',
ledger_account_per_diem: '6640',
ledger_account_rebilled_revenue: '3940',
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
};
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
}));
const ledgerService = require('../../src/services/ledgerService');
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
beforeEach(() => {
accountsRows = [
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
];
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
invoiceRows = [];
inboundRows = [];
expenseRows = [];
});
// ----- pure helpers ----------------------------------------------------
describe('rateKey', () => {
it('normalises rate to the output-map key', () => {
expect(rateKey(8.1)).toBe('8.1');
expect(rateKey(8.10)).toBe('8.1');
expect(rateKey('2.60')).toBe('2.6');
expect(rateKey(0)).toBe('0');
expect(rateKey(null)).toBe('0');
});
});
describe('csvEscape / minorToDecimal', () => {
it('quotes + doubles inner quotes', () => {
expect(csvEscape('a,b')).toBe('"a,b"');
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
expect(csvEscape(null)).toBe('""');
});
it('renders minor units as 2dp', () => {
expect(minorToDecimal(10810)).toBe('108.10');
expect(minorToDecimal(0)).toBe('0.00');
expect(minorToDecimal(null)).toBe('0.00');
});
});
// ----- buildPostings ---------------------------------------------------
describe('buildPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '1100', debitName: 'Debitoren',
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
});
});
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
inboundRows = [{
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
supplier_name: 'Lab AG', tax_treatment: 'domestic',
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6570', creditAccount: '2000',
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
});
});
it('falls back to the kind default account for a category-less mileage expense', async () => {
expenseRows = [{
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6200', creditAccount: '2000',
grossMinor: 5000, vatMinor: 0,
vatCode: 'VST00', source: 'expense', eventName: '',
});
});
it('sorts the combined journal chronologically across all sources', async () => {
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
const { postings } = await ledgerService.buildPostings(period);
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
});
it('requires from/to/currency', async () => {
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
});
});
// ----- exportPostings --------------------------------------------------
describe('exportPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
beforeEach(() => {
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
});
it('generic format carries all human-friendly columns', async () => {
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
const [header, row] = content.trim().split('\r\n');
expect(count).toBe(1);
expect(header).toContain('DebitAccountName');
expect(header).toContain('NetAmount');
expect(header).toContain('VatCode');
expect(row).toContain('1100');
expect(row).toContain('108.10'); // gross 2dp
expect(filename).toMatch(/_generic\.csv$/);
});
it('banana format is a TAB-separated .txt with Banana column names', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
const header = content.split('\r\n')[0];
// Banana's "Text file with column headers" import wants TAB-separated,
// unquoted values in a .txt — not a comma CSV.
expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode');
expect(content.split('\r\n')[1]).toContain('\t');
expect(content).not.toContain('"');
expect(filename).toMatch(/_banana\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
const [header, row] = content.trim().split('\r\n');
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tCategory\tVatCode');
// The mock period holds one revenue posting (gross 108.10) → Income filled,
// Expenses empty, Category = the revenue account.
const cells = row.split('\t');
expect(cells[3]).toBe('108.10'); // Income
expect(cells[4]).toBe(''); // Expenses
expect(cells[5]).not.toBe(''); // Category (revenue account)
expect(filename).toMatch(/_banana_ie\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('formats a Postgres Date object as yyyy-mm-dd (not "Thu Jan ...")', async () => {
// PG returns DATE columns as JS Date objects (SQLite returns strings); the
// export must still emit an ISO date, or Banana rejects it and the Date
// column imports empty.
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: new Date(2026, 0, 10),
vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME',
}];
const { content } = await ledgerService.exportPostings({ ...period, format: 'banana' });
const dateCell = content.split('\r\n')[1].split('\t')[0];
expect(dateCell).toBe('2026-01-10');
});
it('bexio format includes tax_code + currency', async () => {
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
const header = content.split('\r\n')[0];
expect(header).toContain('tax_code');
expect(header).toContain('currency');
});
it('unknown format falls back to generic', async () => {
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
expect(filename).toMatch(/_generic\.csv$/);
});
});
@@ -0,0 +1,236 @@
/**
* Tests for the createBaseDocument + getPageMetrics helpers — the
* shared PDF factory used by quote/invoice rendering AND by the
* upcoming tax-report renderer. These verify orientation handling
* and font defaults without touching DB or filesystem.
*/
const pdfService = require('../../src/services/pdfService');
describe('getPageMetrics', () => {
it('returns portrait A4 metrics by default', () => {
const p = pdfService.getPageMetrics();
expect(p.width).toBeCloseTo(595.28, 1);
expect(p.height).toBeCloseTo(841.89, 1);
expect(p.contentWidth).toBeCloseTo(515.28, 1);
});
it('returns portrait when orientation is "portrait"', () => {
const p = pdfService.getPageMetrics('portrait');
expect(p.width).toBeLessThan(p.height);
});
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
const p = pdfService.getPageMetrics('landscape');
expect(p.width).toBeCloseTo(841.89, 1);
expect(p.height).toBeCloseTo(595.28, 1);
expect(p.contentWidth).toBeCloseTo(761.89, 1);
expect(p.width).toBeGreaterThan(p.height);
});
it('ignores unknown orientation values (falls back to portrait)', () => {
const p = pdfService.getPageMetrics('upside-down');
expect(p.width).toBeLessThan(p.height);
});
});
describe('createBaseDocument', () => {
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
const { doc, page, fonts } = pdfService.createBaseDocument();
expect(doc).toBeDefined();
expect(typeof doc.on).toBe('function');
expect(typeof doc.font).toBe('function');
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
});
it('produces a landscape document when orientation is "landscape"', () => {
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
expect(page.width).toBeGreaterThan(page.height);
// PDFKit stores the active page dims on doc.page.
expect(doc.page.width).toBeCloseTo(841.89, 1);
expect(doc.page.height).toBeCloseTo(595.28, 1);
});
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
const chunks = [];
doc.on('data', (c) => chunks.push(c));
const ended = new Promise((resolve) => doc.on('end', resolve));
doc.text('hello', 40, 40);
doc.end();
await ended;
const buf = Buffer.concat(chunks);
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: null },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('falls back to Helvetica when the custom TTF path does not exist', () => {
// No exception, no logger.error blow-up — just silent fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('registers a bundled font family when pdfFontFamily is set', () => {
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
// backend/assets/fonts/Inter/, so the resolver should pick 400
// for body and 700 for bold.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'Inter' },
});
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'NotARealFamily' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('strips path-traversal characters from pdfFontFamily', () => {
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: '../../etc/passwd' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
// The explicit upload is the priority-1 override. When the upload
// path is unusable (file missing) the family is consulted next.
// Here we set BOTH to invalid values and confirm Helvetica fallback
// — what matters is that the family DIDN'T get registered while a
// (failed) explicit path was being evaluated.
const { fonts } = pdfService.createBaseDocument({
issuer: {
pdfFontTtfPath: '/nonexistent/path/font.ttf',
pdfFontFamily: 'Inter',
},
});
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
// registers successfully. crm-body / crm-bold confirm a custom
// font won.
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('forwards PDF info metadata (Title, Author) to the document', () => {
const { doc } = pdfService.createBaseDocument({
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
});
// PDFKit copies these onto doc.info during construction.
expect(doc.info.Title).toBe('Tax Report 2026');
expect(doc.info.Author).toBe('picpeak');
});
});
describe('exported letterhead helper', () => {
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
expect(typeof pdfService.drawIssuerBlock).toBe('function');
});
});
// Storno rendering — smoke-tests that exercise the kind='storno'
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
// directly for German strings because PDFKit Flate-compresses
// content streams, but we CAN verify the renderer:
// - completes without throwing on a Storno-shaped context,
// - produces a valid %PDF magic header,
// - produces a SMALLER document than its invoice counterpart
// (no payment block, no QR slip → fewer bytes), proving the
// suppression branches actually fire.
//
// Visual correctness (title swap, reference line, signed totals) is
// validated by manual review of a real Storno PDF; the renderer's
// branch logic is unit-tested in service tests where the inputs
// can be asserted directly.
describe('renderInvoiceToBuffer — Storno branch', () => {
function buildContext(overrides = {}) {
return {
locale: 'de',
currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
shippingAmountMinor: 0, totalAmountMinor: 32310,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
// Bank + payment term are part of the baseline invoice so the
// payment block renders a real IBAN + Zahlungsbedingungen
// section. The Storno branch suppresses this entirely, which
// produces a visible byte-size delta.
bank: {
accountHolder: 'AcmeCo',
iban: 'CH9300762011623852957',
bic: 'POFICHBE',
currency: 'CHF',
},
qrFormat: 'none',
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
...overrides,
};
}
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
// Baseline: normal invoice with a payment block.
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
// Storno: same context but kind='storno' → payment block + QR
// both suppressed. Payment block alone is ~80pt tall in the
// PDF; its absence is reliably detectable as a byte-size delta.
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(stornoBuf.length).toBeGreaterThan(0);
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
@@ -0,0 +1,95 @@
/**
* Pure-function tests for the PDF rendering helpers. These are the
* functions that DON'T touch PDFKit / DB — formatting, salutation
* routing, EPC payload construction.
*
* The helpers aren't directly exported from pdfService.js (it
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
* them via `_internal` which the module already exposes for tests.
*/
const pdfService = require('../../src/services/pdfService');
const { formatMinor, formatDate, t } = pdfService._internal;
describe('formatMinor', () => {
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
// de-CH uses (U+2019) as the thousands separator.
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[',\u2019]?234\.56/);
});
it('formats large amounts with thousands separators', () => {
// 12345600 minor units = 123,456.00 major; the separator
// varies by locale (de-CH = U+2019, en-GB = ',').
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[',\u2019]456\.00/);
});
it('returns 0,00 for zero or null', () => {
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
});
it('returns 2-decimal output regardless of locale', () => {
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
});
});
describe('formatDate', () => {
// formatDate now respects ctx.dateFormat (object with `format`
// key) — when omitted defaults to DD.MM.YYYY.
it('defaults to DD.MM.YYYY when no format passed', () => {
expect(formatDate('2026-04-19')).toBe('19.04.2026');
});
it('honors the configured DD/MM/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
});
it('honors the configured MM/DD/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
});
it('honors ISO YYYY-MM-DD', () => {
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
});
it('returns empty string on empty input', () => {
expect(formatDate('')).toBe('');
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
});
it('returns empty string on invalid input rather than throwing', () => {
expect(formatDate('not-a-date')).toBe('');
});
it('accepts Date objects', () => {
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
});
});
describe('t (i18n lookup)', () => {
it('returns the EN value for an EN-only locale', () => {
expect(t('en', 'invoice_title')).toBe('Invoice');
expect(t('en', 'quote_title')).toBe('Quote');
});
it('returns the DE value for de locale', () => {
expect(t('de', 'invoice_title')).toBe('Rechnung');
expect(t('de', 'quote_title')).toBe('Angebot');
});
it('falls back to EN for unknown locales', () => {
expect(t('xx', 'invoice_title')).toBe('Invoice');
});
it('substitutes named tokens like {percent}', () => {
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
expect(out).toMatch(/3% discount if paid within 5 working days\./);
});
it('falls back to EN when the key is missing on the requested locale', () => {
// page_of is seeded on all locales — pick something that
// exists on EN with a substitution.
const out = t('zz', 'page_of', { current: 1, total: 3 });
expect(out).toBe('Page 1 of 3');
});
});
@@ -0,0 +1,71 @@
/**
* exportAsTxt — issue #623 regression test.
*
* The admin UI labels the TXT export "for Lightroom search". Lightroom's
* filename search wants ONE comma-separated line WITHOUT file extensions
* (the gallery JPEGs may map to RAW files in the catalog). The frontend
* now passes separator='comma' + include_extension=false for the TXT
* format; this test pins the resulting shape so a future refactor can't
* silently regress it back to the newline-separated form the bug reported.
*
* Also pins backward compatibility: a direct API caller passing no options
* still gets the original newline-with-extension behaviour, so existing
* integrations don't break.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} }));
const { PhotoExportService } = require('../../src/services/photoExportService');
const service = new PhotoExportService();
const PHOTOS = [
{ original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' },
{ original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' },
{ original_filename: 'shoot.final.tif', filename: 'ghi789.tif' },
{ original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename
];
describe('exportAsTxt (issue #623)', () => {
it('Lightroom mode: comma-joined, no extension, no space', () => {
const result = service.exportAsTxt(PHOTOS, {
separator: 'comma',
include_extension: false,
});
expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback');
expect(result.contentType).toBe('text/plain');
});
it('backward compatible: no options → newline-joined with extensions', () => {
const result = service.exportAsTxt(PHOTOS);
expect(result.content).toBe(
'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png',
);
});
it('semicolon separator joins without a trailing space', () => {
const result = service.exportAsTxt(PHOTOS, {
separator: 'semicolon',
include_extension: false,
});
expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback');
});
it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => {
const result = service.exportAsTxt(PHOTOS, {
filename_format: 'picpeak',
separator: 'comma',
include_extension: false,
});
expect(result.content).toBe('abc123,def456,ghi789,fallback');
});
it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => {
// path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom
// catalogs that store basenames like "shoot.final" still match.
const result = service.exportAsTxt(
[{ original_filename: 'shoot.final.tif', filename: 'x.tif' }],
{ separator: 'comma', include_extension: false },
);
expect(result.content).toBe('shoot.final');
});
});
@@ -0,0 +1,217 @@
/**
* Tests for the migration-119 hierarchy support in quoteService:
* computeTotals, validateLineItemHierarchy, and the two-phase
* insertLineItemsHierarchical helper. All pure / db-mocked so the
* suite runs fast and is deterministic.
*/
const quoteService = require('../../src/services/quoteService');
const {
computeTotals,
validateLineItemHierarchy,
insertLineItemsHierarchical,
} = quoteService._internal;
describe('computeTotals — hierarchy + parent auto-resolve rule', () => {
it('parent total auto-resolves to sum of priced sub-items (parent unit_price ignored)', () => {
const items = [
// Parent with its own price €500 — should be IGNORED because
// sub-items have prices. Parent's effective line_total becomes
// sum of priced sub-items.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
// Priced sub-items €150 + €200 = €350
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
{ position: 3, quantity: 1, unit_price_minor: 20000, discount_percent: 0, parent_position: 1 },
// Another top-level item: €100
{ position: 4, quantity: 2, unit_price_minor: 5000, discount_percent: 0 },
];
const out = computeTotals(items, 0, 0);
// Net = 35000 (parent 1, auto-resolved) + 10000 (row 4) = 45000.
// Parent's own €500 is silently overridden.
expect(out.netAmountMinor).toBe(45000);
// Parent's stored line_total_minor reflects the resolved sum.
expect(out.lineItems[0].line_total_minor).toBe(35000);
});
it('priceless sub-items leave the parent\'s own line_total intact', () => {
const items = [
// Parent €500 with three priceless transparency-bullets — the
// €500 stands.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
];
const out = computeTotals(items, 0, 0);
expect(out.netAmountMinor).toBe(50000);
expect(out.lineItems[0].line_total_minor).toBe(50000);
});
it('mixed priced + priceless sub-items: only priced contribute, parent\'s own price still overridden', () => {
const items = [
// Parent €500 → overridden because at least one sub-item is priced.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
// Priceless bullet — doesn't add anything
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
];
const out = computeTotals(items, 0, 0);
// Parent resolves to €150 (only priced sub-item).
expect(out.netAmountMinor).toBe(15000);
expect(out.lineItems[0].line_total_minor).toBe(15000);
});
it('still computes line_total_minor on sub-items so the renderer can show it', () => {
const out = computeTotals([
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 2, unit_price_minor: 15000, discount_percent: 10, parent_position: 1 },
], 0);
expect(out.lineItems[1].line_total_minor).toBe(27000); // 2 × 150.00 × 0.9 = 270.00
});
it('applies VAT to the resolved parent total', () => {
const out = computeTotals([
// Parent €1000 overridden by priced €800 sub-item
{ position: 1, quantity: 1, unit_price_minor: 100000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 80000, discount_percent: 0, parent_position: 1 },
], 7.7);
// Resolved net = 80000, VAT 7.7% = 6160.
expect(out.netAmountMinor).toBe(80000);
expect(out.vatAmountMinor).toBe(6160);
expect(out.totalAmountMinor).toBe(86160);
});
it('treats empty-string parent_position as top-level (frontend may send "")', () => {
const out = computeTotals([
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: '' },
{ position: 2, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: null },
], 0);
expect(out.netAmountMinor).toBe(100000);
});
});
describe('validateLineItemHierarchy', () => {
it('accepts a flat list of top-level items', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2 },
{ position: 3 },
])).not.toThrow();
});
it('accepts one level of sub-items under valid parents', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2, parent_position: 1 },
{ position: 3, parent_position: 1 },
{ position: 4 },
{ position: 5, parent_position: 4 },
])).not.toThrow();
});
it('rejects duplicate positions', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 1 },
])).toThrow(/Duplicate line item position/);
});
it('rejects a sub-item pointing at a missing parent', () => {
expect(() => validateLineItemHierarchy([
{ position: 1, parent_position: 99 },
])).toThrow(/missing parent position/);
});
it('rejects a sub-item under another sub-item (max 1 level deep)', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2, parent_position: 1 },
{ position: 3, parent_position: 2 },
])).toThrow(/max one level deep/);
});
it('rejects an item whose parent is itself', () => {
expect(() => validateLineItemHierarchy([
{ position: 5, parent_position: 5 },
])).toThrow(/cannot be its own parent/);
});
it('rejects an item without a positive position', () => {
expect(() => validateLineItemHierarchy([
{ position: 0 },
])).toThrow(/positive position/);
});
it('is a no-op on empty / non-array input', () => {
expect(() => validateLineItemHierarchy([])).not.toThrow();
expect(() => validateLineItemHierarchy(null)).not.toThrow();
expect(() => validateLineItemHierarchy(undefined)).not.toThrow();
});
});
describe('insertLineItemsHierarchical', () => {
// Tiny trx mock — captures insert calls so we can verify the
// two-phase ordering and the parent-id remap. `.returning('id')`
// returns a synthesised id matching the call order.
function makeTrxMock() {
let nextId = 100;
const inserts = []; // [{ table, row }]
const trx = (tableName) => ({
insert(row) {
const id = nextId++;
inserts.push({ table: tableName, row: { ...row, id } });
return {
returning() { return Promise.resolve([{ id }]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); }, // bare await: no returning() call
};
},
});
return { trx, inserts };
}
it('inserts top-level items first, then sub-items with remapped parent_line_item_id', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, discount_percent: 0, line_total_minor: 20000, parent_position: 1 },
{ position: 4, description: 'Travel', quantity: 1, unit_price_minor: 10000, discount_percent: 0, line_total_minor: 10000, parent_position: null },
]);
// 4 inserts, all into quote_line_items.
expect(inserts).toHaveLength(4);
expect(inserts.every((i) => i.table === 'quote_line_items')).toBe(true);
// Order: top-level first (positions 1 and 4), then sub-items 2 and 3.
expect(inserts.map((i) => i.row.position)).toEqual([1, 4, 2, 3]);
// Top-level items have parent_line_item_id = null.
expect(inserts[0].row.parent_line_item_id).toBeNull();
expect(inserts[1].row.parent_line_item_id).toBeNull();
// Sub-items reference the id returned for position-1 parent (100).
expect(inserts[2].row.parent_line_item_id).toBe(100);
expect(inserts[3].row.parent_line_item_id).toBe(100);
// parent_position is stripped (wire-only field, not a DB column).
expect(inserts[0].row).not.toHaveProperty('parent_position');
expect(inserts[2].row).not.toHaveProperty('parent_position');
});
it('copies details_text through to the inserted row', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
{ position: 1, description: 'P', unit_price_minor: 0, details_text: 'Includes online gallery.', parent_position: null },
]);
expect(inserts[0].row.details_text).toBe('Includes online gallery.');
});
it('is a no-op on empty items array', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, []);
expect(inserts).toHaveLength(0);
});
it('uses the supplied ownerColumn so the same helper handles invoice_line_items', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 42, [
{ position: 1, description: 'X', unit_price_minor: 100, parent_position: null },
]);
expect(inserts[0].table).toBe('invoice_line_items');
expect(inserts[0].row.invoice_id).toBe(42);
expect(inserts[0].row).not.toHaveProperty('quote_id');
});
});
@@ -0,0 +1,179 @@
/**
* Tests for quoteService lock + state-transition guards:
* - updateQuote refuses on accepted / declined / converted
* - adminAcceptQuote refuses on already-terminal states + atomic
* update path
*
* db deep-mocked, same chain pattern as invoiceService tests.
*/
const tableChains = {};
function makeChain() {
return {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
// knex chains are thenable; mirror that so `await trx('t')...`
// resolves to an array of rows.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._selectResult); }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
leftJoin: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
};
}
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const quoteService = require('../../src/services/quoteService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('quoteService.updateQuote — lock guards', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.updateQuote(99, {}, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('locks accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('allows edits on draft + sent + expired (no QUOTE_LOCKED throw)', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, vat_rate: 0, shipping_amount_minor: 0,
};
// The lock check sits at the TOP of updateQuote. The
// observable behavior we care about is "no QUOTE_LOCKED
// 409 thrown on these statuses". The full transaction
// path may resolve to anything (incl. undefined) since
// the test mocks the trx callback — that's fine.
let err = null;
try { await quoteService.updateQuote(1, { lineItems: [] }, 1); }
catch (e) { err = e; }
if (err) {
// Any error other than the QUOTE_LOCKED guard is allowed
// (we're not exercising the full path here).
expect(err.code).not.toBe('QUOTE_LOCKED');
}
resetChains();
}
});
});
describe('quoteService.adminAcceptQuote', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.adminAcceptQuote(99, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('refuses already-accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_ALREADY_ACCEPTED' });
});
it('refuses declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_DECLINED' });
});
it('refuses converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_CONVERTED' });
});
it('accepts draft / sent / expired and returns lockedAt', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, customer_account_id: 5,
currency: 'CHF', language: 'de',
quote_number: 'Q-2026-0001',
total_amount_minor: 10000,
event_name: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, email: 'c@example.com', display_name: 'Test',
};
pickChainFor('quote_line_items')._selectResult = [];
pickChainFor('business_profile')._firstValue = null;
const result = await quoteService.adminAcceptQuote(1, 42);
expect(result.status).toBe('accepted');
expect(result.lockedAt).toBeInstanceOf(Date);
resetChains();
}
});
});
@@ -0,0 +1,10 @@
const rasterizeService = require('../../src/services/rasterizeService');
// The page-range guard runs BEFORE any fs/pdftoppm work, so these reject
// without touching the binary or disk (PR #622 concern 6).
describe('getRenderedPagePath page-range guard', () => {
it.each([0, -1, 201, 1000, 1.5, NaN])('rejects out-of-range page %p', async (page) => {
await expect(rasterizeService.getRenderedPagePath(1, '/tmp/does-not-exist.pdf', page))
.rejects.toMatchObject({ statusCode: 400, code: 'PAGE_OUT_OF_RANGE' });
});
});
@@ -0,0 +1,115 @@
/**
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
* `umamiAdapter` test contract: missing config / URL shape / encoding /
* normalisation / unknown-bucket drop / failure modes.
*
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
* envelopes since their docs hint at minor v0 → v1 shape variation.
*/
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete', async () => {
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends Bearer auth', async () => {
mockJson([{ device: 'desktop', sessions: 10 }]);
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
expect(init.headers.Authorization).toBe('Bearer rkey');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ device: 'desktop', sessions: 1 }]);
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
});
test('normalises a typical {device, sessions} payload into percentages', async () => {
mockJson([
{ device: 'desktop', sessions: 60 },
{ device: 'mobile', sessions: 30 },
{ device: 'tablet', sessions: 10 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('accepts the {data: [...]} envelope variant', async () => {
mockJson({ data: [
{ device: 'desktop', sessions: 1 },
{ device: 'mobile', sessions: 3 },
] });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
});
test('falls back to `visitors` when `sessions` is absent', async () => {
mockJson([
{ device: 'desktop', visitors: 80 },
{ device: 'mobile', visitors: 20 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('tolerates a `dimension` key as the bucket label', async () => {
mockJson([
{ dimension: 'desktop', sessions: 50 },
{ dimension: 'mobile', sessions: 50 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
});
test('drops unknown buckets', async () => {
mockJson([
{ device: 'desktop', sessions: 80 },
{ device: 'mobile', sessions: 20 },
{ device: 'fridge', sessions: 100 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => ({
ok: true, status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,263 @@
/**
* Smoke tests for taxReportService.renderTaxReportPdf and
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
* businessProfileService (canned issuer) and assert that the
* rendered output meets a few hard requirements:
*
* - PDF starts with the %PDF magic bytes, is non-empty
* - CSV header contains the localised column names
* - CSV body contains the invoice numbers in order
* - CSV totals row contains the grand totals
*/
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let callCount = 0;
function makeChain(initialRows) {
return {
_rows: initialRows,
then(onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
}
const mockDbFn = jest.fn((tableName) => {
// Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; the #4 cost side
// queries `inbound_documents` + `expenses`; everything else (main
// listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]);
if (tableName === 'inbound_documents') return makeChain([]);
if (tableName === 'expenses') return makeChain([]);
callCount += 1;
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments so the raw() return value just needs to exist.
mockDbFn.raw = jest.fn((sql) => sql);
// #4 loadCosts schema-guards each cost table; default the PDF/CSV
// fixtures to "no accounting tables" so these renderers exercise the
// revenue path unchanged.
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: {
company_name: 'ACME Test GmbH',
address_line1: 'Teststrasse 1',
postal_code: '8000',
city: 'Zürich',
country_code: 'CH',
email: 'hello@example.com',
default_locale: 'de',
default_currency: 'CHF',
pdf_show_logo: 1,
pdf_show_company_name: 1,
pdf_logo_height: 56,
pdf_company_name_inline: 0,
pdf_folding_marks: 'none',
logo_path: null,
pdf_font_ttf_path: null,
},
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
}));
const taxReportService = require('../../src/services/taxReportService');
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
callCount = 0;
mockDbFn.mockClear();
});
const SAMPLE_ROW = (override = {}) => ({
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
customer_last_name: null, customer_display_name: null, customer_email: null,
event_name: 'Hochzeit Müller',
...override,
});
describe('renderTaxReportPdf', () => {
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders a header even when no invoices are in the period', async () => {
invoiceRowsForRun = [];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders successfully when cancelled rows are present', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
];
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders without throwing when a row has long text that must wrap', async () => {
// Customer + event labels long enough to force multi-line wrap
// in their narrow columns. The dynamic row-height logic should
// grow the row to fit rather than overlapping the next one.
invoiceRowsForRun = [
SAMPLE_ROW({
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
}),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('honours the locale parameter (en) without throwing', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// Regression: the page-number footer used to place its baseline
// inside the bottom margin, which made PDFKit auto-paginate one
// empty page per existing page (so a 1-page report ended up as 2,
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
// is the cheapest way to detect a recurrence without parsing the
// PDF — every page object in the xref table carries that marker
// exactly once.
it('does not duplicate pages when stamping the page-number footer', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
// Small single-row report should fit on a single page. The
// previous buggy renderer produced 2 (1 content + 1 footer-only).
expect(pageMarkers.length).toBe(1);
});
});
describe('renderTaxReportCsv', () => {
it('returns a CSV blob with the de localised header row', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(contentType).toMatch(/text\/csv/);
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
const lines = content.split('\r\n');
// Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
// old Rechnung/Kunde split.
expect(lines[0]).toContain('Typ'); // de header for tax_col_type
expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference
expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant"
expect(lines[0]).toContain('Netto');
});
it('lists each invoice on its own row in order', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
const idxA = content.indexOf('R-2026-0001');
const idxB = content.indexOf('R-2026-0002');
const idxC = content.indexOf('R-2026-0003');
expect(idxA).toBeGreaterThan(0);
expect(idxB).toBeGreaterThan(idxA);
expect(idxC).toBeGreaterThan(idxB);
});
it('appends a trailing totals row with the grand totals', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
}),
SAMPLE_ROW({
id: 2, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
}),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
expect(content).toMatch(/"150\.00"/);
expect(content).toMatch(/"11\.55"/);
expect(content).toMatch(/"161\.55"/);
});
it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ status: 'cancelled' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Unified ledger CSV has no separate cancelled column — a cancelled row is
// flagged by appending the localised "(Cancelled)" tag to its Reference.
const dataRow = content.split('\r\n')[1];
expect(dataRow).toContain('R-2026-0001 (Cancelled)');
});
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(content).toContain('\r\n');
// The route wraps the BOM around the content; the service output
// itself is BOM-free so callers (tests) get a clean string.
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
});
});
@@ -0,0 +1,572 @@
/**
* Tests for taxReportService.
*
* Two layers:
* 1. Pure helpers (grossUpLateFee, computeReportedAmounts,
* buildCustomerLabel) — no db mock needed.
* 2. getTaxReport — db chain deep-mocked so we can feed canned
* invoice rows and assert the filter/bucket/total math.
*/
// ----- mock db chain ---------------------------------------------------
//
// taxReportService builds a single chain:
// db('invoices').leftJoin(...).leftJoin(...).whereBetween(...)
// .where(...).whereIn(...).orderBy(...).select(...)
// and then for cancelled ids:
// db('invoices').whereIn('replaces_invoice_id', ids).select(...)
//
// We use one shared chain factory that returns canned rows from
// `_selectResult` for the main query, and lets us swap the result
// for the replacements lookup via a "second-call" hook.
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let inboundRowsForRun = [];
let expenseRowsForRun = [];
let costTablesPresent = false;
let callCount = 0;
function makeChain(initialRows) {
const c = {
_rows: initialRows,
then: function (onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
return c;
}
const mockDbFn = jest.fn((tableName) => {
// Migration 126 added a Skonto aggregate that hits
// `invoice_payment_log` — route those explicitly to an empty list so
// the test surface stays focused on the invoices/replacements flow.
if (tableName === 'invoice_payment_log') return makeChain([]);
// Cost side (#4): incoming invoices + internal expenses.
if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun);
if (tableName === 'expenses') return makeChain(expenseRowsForRun);
// `invoices` is queried for the main listing (call 1) and, when there
// are cancelled rows, the replacements lookup (call 2).
callCount += 1;
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// loadCosts (#4) schema-guards each cost table. Default off so the
// revenue-only tests are unaffected; cost-side tests flip it on.
mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) };
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments and returns the mocked rows, so the raw() return value
// just needs to exist — a string is fine.
mockDbFn.raw = jest.fn((sql) => sql);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
const taxReportService = require('../../src/services/taxReportService');
const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReportService._internal;
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
inboundRowsForRun = [];
expenseRowsForRun = [];
costTablesPresent = false;
callCount = 0;
mockDbFn.mockClear();
});
// ----- pure helpers ----------------------------------------------------
describe('grossUpLateFee', () => {
it('returns zeros for a zero or negative fee', () => {
expect(grossUpLateFee(0, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(-100, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(null, 7.7)).toEqual({ net: 0, vat: 0 });
});
it('returns the whole fee as net when VAT rate is 0', () => {
expect(grossUpLateFee(2500, 0)).toEqual({ net: 2500, vat: 0 });
// Missing/invalid rate is treated the same.
expect(grossUpLateFee(2500, null)).toEqual({ net: 2500, vat: 0 });
});
it('splits a 25.00 CHF fee at 7.7% into net 23.21 + VAT 1.79', () => {
// 2500 / 1.077 = 2321.265… → rounds to 2321; 2500 - 2321 = 179.
expect(grossUpLateFee(2500, 7.7)).toEqual({ net: 2321, vat: 179 });
});
it('guarantees net + vat === gross input (no rounding drift)', () => {
for (const fee of [1, 2500, 9999, 12345, 250000]) {
for (const rate of [7.7, 8.1, 19, 20.5]) {
const { net, vat } = grossUpLateFee(fee, rate);
expect(net + vat).toBe(fee);
}
}
});
});
describe('computeReportedAmounts', () => {
it('returns stored amounts unchanged when late fee is zero', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 10770,
late_fee_amount_minor: 0,
vat_rate: 7.7,
});
expect(r).toEqual({ netMinor: 10000, vatMinor: 770, totalMinor: 10770 });
});
it('adds the late-fee net/vat split onto the stored net + vat', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 late fee
late_fee_amount_minor: 2500,
vat_rate: 7.7,
});
expect(r.netMinor).toBe(10000 + 2321);
expect(r.vatMinor).toBe(770 + 179);
expect(r.totalMinor).toBe(13270);
});
it('keeps total at the stored total even when late fee is present', () => {
// The stored total already includes the late fee — we never
// recompute it from net + vat in the report.
const r = computeReportedAmounts({
net_amount_minor: 50000,
vat_amount_minor: 4050,
total_amount_minor: 56550,
late_fee_amount_minor: 2500,
vat_rate: 8.1,
});
expect(r.totalMinor).toBe(56550);
});
});
describe('buildCustomerLabel', () => {
it('prefers company_name when present', () => {
expect(buildCustomerLabel({
customer_company_name: 'ACME GmbH',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
customer_email: 'anna@example.com',
})).toBe('ACME GmbH');
});
it('falls back to first + last name', () => {
expect(buildCustomerLabel({
customer_company_name: '',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
})).toBe('Anna Beispiel');
});
it('falls back to display_name when no name parts', () => {
expect(buildCustomerLabel({
customer_display_name: 'Anna B.',
})).toBe('Anna B.');
});
it('falls back to email as a last resort', () => {
expect(buildCustomerLabel({ customer_email: 'anna@example.com' })).toBe('anna@example.com');
});
it('returns empty string when nothing usable is present', () => {
expect(buildCustomerLabel({})).toBe('');
});
});
// ----- getTaxReport ----------------------------------------------------
describe('getTaxReport', () => {
it('throws when from/to or currency are missing', async () => {
await expect(taxReportService.getTaxReport({})).rejects.toThrow(/from.+to/);
await expect(taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31' }))
.rejects.toThrow(/currency/);
});
it('returns rows + totals for a clean period with one paid invoice', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'chf', // lowercase → coerced
});
expect(out.currency).toBe('CHF');
expect(out.rows).toHaveLength(1);
expect(out.rows[0]).toMatchObject({
invoiceNumber: 'R-2026-0001',
isCancelled: false,
customerLabel: 'ACME GmbH',
eventName: 'Wedding A',
netMinor: 10000,
vatMinor: 770,
totalMinor: 10770,
});
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(0);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
expect(out.period).toEqual({ from: '2026-01-01', to: '2026-03-31' });
});
it('keeps cancelled rows visible but excludes them from totals', async () => {
invoiceRowsForRun = [
{
id: 10, invoice_number: 'R-2026-0010', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
{
id: 11, invoice_number: 'R-2026-0011', issue_date: '2026-02-02',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 10,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
// The supersedes lookup query: row 11 supersedes row 10.
replacementsRowsForRun = [{ replaces_invoice_id: 10, invoice_number: 'R-2026-0011' }];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toHaveLength(2);
const cancelled = out.rows.find((r) => r.invoiceNumber === 'R-2026-0010');
const replacement = out.rows.find((r) => r.invoiceNumber === 'R-2026-0011');
expect(cancelled.isCancelled).toBe(true);
expect(cancelled.replacedByInvoiceNumber).toBe('R-2026-0011');
expect(replacement.isCancelled).toBe(false);
// Totals: only the replacement counts.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(1);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
// The real cancel-and-reissue flow produces THREE rows in the period:
// the cancelled original, its negative Storno (kind='storno', status='sent'),
// and the reissue. Totals must read the reissued amount, not 0.
invoiceRowsForRun = [
{
id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 20,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
];
replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
// The negative storno must NOT net against the totals (the cancelled
// original is already excluded) — the reissued revenue stands.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-01',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'A', event_name: 'X',
},
{
id: 2, invoice_number: 'R-2026-0002', issue_date: '2026-01-02',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'B', event_name: 'Y',
},
{
id: 3, invoice_number: 'R-2026-0003', issue_date: '2026-01-03',
currency: 'CHF', status: 'sent', vat_rate: 8.1,
net_amount_minor: 5000, vat_amount_minor: 405, total_amount_minor: 5405,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'C', event_name: 'Z',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.totalsByVatRate).toHaveLength(2);
// Sorted ascending by rate.
expect(out.totalsByVatRate[0]).toEqual({
vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770,
});
expect(out.totalsByVatRate[1]).toEqual({
vatRate: 8.1, netMinor: 25000, vatMinor: 2025, totalMinor: 27025,
});
expect(out.grandTotalNet).toBe(35000);
expect(out.grandTotalVat).toBe(2795);
expect(out.grandTotal).toBe(37795);
});
it('folds late fees into the reporting net + vat (gross-up per VAT rate)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'overdue', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 fee
late_fee_amount_minor: 2500, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
// Late fee 2500 @ 7.7% → net 2321 + vat 179.
expect(out.rows[0].netMinor).toBe(12321);
expect(out.rows[0].vatMinor).toBe(949);
expect(out.rows[0].totalMinor).toBe(13270);
// Grand totals reflect the same gross-up math.
expect(out.grandTotalNet).toBe(12321);
expect(out.grandTotalVat).toBe(949);
expect(out.grandTotal).toBe(13270);
});
it('returns empty rows + zero totals when no invoices match the period', async () => {
invoiceRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toEqual([]);
expect(out.grandTotalNet).toBe(0);
expect(out.grandTotalVat).toBe(0);
expect(out.grandTotal).toBe(0);
expect(out.totalsByVatRate).toEqual([]);
expect(out.cancelledCount).toBe(0);
});
it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'X',
},
];
costTablesPresent = false; // no accounting migrations on this DB
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 });
expect(out.summary).toMatchObject({
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
resultNetMinor: 10000, resultGrossMinor: 10770,
// VAT registration unconfigured in the test DB → refuse to compute payable.
vatRegistrationConfigured: false, vatPayableMinor: null,
});
});
it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
costTablesPresent = true;
// Incoming supplier invoice: net 20000 + vat 1540 = 21540.
inboundRowsForRun = [
{
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540,
event_name: 'Wedding A',
},
];
// Internal expense (mileage, no VAT split): only a CHF base amount.
expenseRowsForRun = [
{
id: 9, created_at: '2026-02-01 12:00:00',
supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'open', event_id: null,
original_currency: null, original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null,
event_name: null,
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs.rows).toHaveLength(2);
// Incoming invoice mapped + booked to the event.
const incoming = out.costs.rows.find((r) => r.source === 'incoming');
expect(incoming).toMatchObject({
supplierLabel: 'Lab AG', eventName: 'Wedding A',
netMinor: 20000, vatMinor: 1540, totalMinor: 21540,
});
// Expense: no net/vat/gross → falls back to the CHF base as total,
// and (company-booked) event name blank.
const expense = out.costs.rows.find((r) => r.source === 'expense');
expect(expense).toMatchObject({
eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000,
});
expect(out.costs.totalNet).toBe(25000);
expect(out.costs.totalVat).toBe(1540);
expect(out.costs.totalGross).toBe(26540);
// Summary nets income against costs.
expect(out.summary).toMatchObject({
incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700,
costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540,
resultNetMinor: 75000, resultGrossMinor: 81160,
vatRegistrationConfigured: false, vatPayableMinor: null,
});
});
it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => {
costTablesPresent = true;
inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed
expenseRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs.totalGross).toBe(0);
expect(out.summary.costGrossMinor).toBe(0);
});
});
// ----- export scope (income/cost split) --------------------------------
describe('export scope helpers', () => {
const { scopeLedger, normalizeScope } = taxReportService._internal;
const ledger = [
{ type: 'outgoing', reference: 'R-1' },
{ type: 'incoming', reference: 'IN-1' },
{ type: 'expense', reference: 'EXP-1' },
];
it('normalizeScope defaults unknown/empty to "all"', () => {
expect(normalizeScope('all')).toBe('all');
expect(normalizeScope('income')).toBe('income');
expect(normalizeScope('cost')).toBe('cost');
expect(normalizeScope('bogus')).toBe('all');
expect(normalizeScope(undefined)).toBe('all');
});
it('scopeLedger income keeps only outgoing rows', () => {
expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']);
});
it('scopeLedger cost keeps incoming + expense rows', () => {
expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']);
});
it('scopeLedger all keeps everything; null-safe', () => {
expect(scopeLedger(ledger, 'all')).toHaveLength(3);
expect(scopeLedger(null, 'income')).toEqual([]);
});
});
describe('renderTaxReportCsv scope', () => {
beforeEach(() => {
costTablesPresent = true;
invoiceRowsForRun = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
}];
inboundRowsForRun = [{
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
event_name: 'Wedding A',
}];
});
it('income scope keeps the invoice row, drops the supplier cost row', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income',
});
expect(content).toContain('R-2026-0001');
expect(content).not.toContain('Lab AG');
expect(filename).toContain('income_');
});
it('cost scope keeps the supplier row, drops the invoice row', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost',
});
expect(content).toContain('Lab AG');
expect(content).not.toContain('R-2026-0001');
expect(filename).toContain('cost_');
});
it('all scope (default) keeps both', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(content).toContain('R-2026-0001');
expect(content).toContain('Lab AG');
expect(filename).not.toMatch(/income_|cost_/);
});
});
@@ -0,0 +1,91 @@
/**
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
*
* Pins the contract that drives `adminDashboard.js` analytics route:
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
* - Returns an Umami adapter shape for provider='umami'.
* - Returns a Rybbit adapter shape for provider='rybbit'.
* - Back-compat: when `analytics_tracker_provider` is unset, infers
* 'umami' from the legacy `analytics_umami_enabled` flag.
* - Invalid provider strings fall through to the legacy back-compat path
* rather than crashing (defensive).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tracker-fact-')), 'db.sqlite',
);
const { bootCrmDb } = require('../integration/helpers/crmDb');
const trackers = require('../../src/services/trackers');
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('app_settings').del();
});
async function setSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date(),
});
}
describe('resolveAdapter (#663)', () => {
test('returns null when provider=\'none\'', async () => {
await setSetting('analytics_tracker_provider', 'none');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
await setSetting('analytics_tracker_provider', 'custom');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
await setSetting('analytics_umami_enabled', true);
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter).not.toBeNull();
expect(adapter.provider).toBe('umami');
});
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'umami');
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('umami');
});
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'rybbit');
await setSetting('analytics_rybbit_url', 'https://r.example');
await setSetting('analytics_rybbit_website_id', 'r-1');
await setSetting('analytics_rybbit_api_key', 'rk-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('rybbit');
});
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
// No legacy umami_enabled → resolves to null (= 'none')
expect(await trackers.resolveAdapter()).toBeNull();
});
});
@@ -0,0 +1,112 @@
/**
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
* the old `umamiClient.test.js` from #662 — same contract, new shape).
*
* Pins the same 10 cases that protected the original implementation: missing
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
*/
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete (back-compat path)', async () => {
const a = buildAdapter({});
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
const b = buildAdapter({ baseUrl: 'https://u' });
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toBe(
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
);
expect(init.headers['x-umami-api-key']).toBe('secret');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const a = buildAdapter(valid);
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets (no silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on non-2xx', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on invalid JSON', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on network error', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,101 @@
/**
* Coverage for the changelog aggregation introduced for #567.
*
* `getReleasesSince` is what feeds the update-available modal so the
* admin can see release notes for every version between their current
* version and latest. The cases below pin:
*
* - Strictly-newer filtering (the running version itself never
* appears in the list).
* - Channel filtering (a stable user does not see beta releases,
* and vice versa).
* - Empty-result fallback when the GitHub fetch returns nothing
* (cached null from `fetchAvailableVersions`).
*
* We mock axios's GitHub response — these tests are pure logic, no
* network. Cache is cleared between cases via the service's exported
* `clearCache()` to avoid bleed.
*/
jest.mock('axios');
const axios = require('axios');
const { getReleasesSince, clearCache } = require('../../src/services/updateCheckService');
function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
return {
tag_name: tag,
name: tag,
body,
published_at: publishedAt,
html_url: `https://github.com/PicPeak/picpeak/releases/tag/${tag}`,
};
}
describe('updateCheckService.getReleasesSince', () => {
beforeEach(() => {
clearCache();
axios.get.mockReset();
});
it('returns only releases strictly newer than current, for the requested channel', async () => {
axios.get.mockResolvedValue({
data: [
release('v3.55.0', 'stable notes 3.55.0'),
release('v3.54.0', 'stable notes 3.54.0'),
release('v3.43.1', 'stable notes 3.43.1 — the running version, should be excluded'),
release('v3.43.0'),
release('v3.55.0-beta.0', 'beta notes — wrong channel, excluded'),
release('v3.54.0-beta.5'),
],
});
const result = await getReleasesSince('3.43.1', 'stable');
expect(result.map((r) => r.version)).toEqual(['3.55.0', '3.54.0']);
// Body + html_url + publishedAt are preserved so the modal can render them
expect(result[0]).toMatchObject({
version: '3.55.0',
tag: 'v3.55.0',
name: 'v3.55.0',
body: 'stable notes 3.55.0',
htmlUrl: 'https://github.com/PicPeak/picpeak/releases/tag/v3.55.0',
});
});
it('returns only beta releases for a beta user', async () => {
axios.get.mockResolvedValue({
data: [
release('v3.55.0-beta.0'),
release('v3.54.7-beta.0'),
release('v3.55.0'), // stable — wrong channel for a beta user
release('v3.54.6-beta.0'),
],
});
const result = await getReleasesSince('3.54.6-beta.0', 'beta');
// Strictly newer beta-channel only — does not include 3.55.0 stable
// even though it's a newer release, because the beta channel user
// wants to see beta releases (which can include releases that
// landed on the beta line after the stable cut).
expect(result.map((r) => r.version)).toEqual(['3.55.0-beta.0', '3.54.7-beta.0']);
});
it('returns empty array when GitHub fetch fails (e.g. rate-limited)', async () => {
axios.get.mockRejectedValue(new Error('API rate limit exceeded'));
const result = await getReleasesSince('3.43.1', 'stable');
expect(result).toEqual([]);
});
it('returns empty array when the user is already on the latest version', async () => {
axios.get.mockResolvedValue({
data: [release('v3.55.0'), release('v3.54.0')],
});
const result = await getReleasesSince('3.55.0', 'stable');
expect(result).toEqual([]);
});
});
@@ -0,0 +1,146 @@
/**
* Coverage for the activate + delete admin-user actions introduced as
* the #574 UI follow-up.
*
* Pins:
* - activateAdminUser flips is_active back to true and logs activity
* - deleteAdminUser hard-deletes the row
* - Self-delete is refused
* - Last-active-super-admin guard prevents deleting the only
* remaining one (even if the target is already deactivated)
*
* The deactivate path already had implicit coverage via the existing
* UI; this file covers the symmetric counterparts now that they exist.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-user-act-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'user-act-test-secret';
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const userManagementService = require('../../src/services/userManagementService');
describe('userManagementService — activate + delete (#574 follow-up)', () => {
let db;
let cleanup;
let actorId; // The admin performing the actions (must be active + super_admin)
let targetId; // The admin we'll deactivate / reactivate / delete
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: actorId } = await seedMinimal(db));
await assignAdminRole(db, actorId, 'super_admin');
// Second admin to be the target of our actions. Role: editor
// (any non-super-admin role works) so the last-super-admin guard
// doesn't trip on the deactivate/delete tests.
const editor = await db('roles').where({ name: 'editor' }).first();
const targetInsert = await db('admin_users').insert({
username: 'target', email: 'target@example.com',
password_hash: 'x', role_id: editor?.id || null,
is_active: 1, created_at: new Date(),
}).returning('id');
targetId = targetInsert[0]?.id ?? targetInsert[0];
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('activateAdminUser', () => {
it('flips is_active back to true when target is deactivated', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.activateAdminUser(targetId, actorId);
const row = await db('admin_users').where({ id: targetId }).first();
expect(row.is_active === true || row.is_active === 1).toBe(true);
});
it('is a no-op when target is already active', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 1, updated_at: new Date('2000-01-01') });
const before = await db('admin_users').where({ id: targetId }).first();
await userManagementService.activateAdminUser(targetId, actorId);
const after = await db('admin_users').where({ id: targetId }).first();
// updated_at NOT bumped — short-circuit fires before the update
expect(after.updated_at).toEqual(before.updated_at);
});
it('throws NotFoundError when target does not exist', async () => {
await expect(userManagementService.activateAdminUser(99999, actorId))
.rejects.toThrow(/not found|admin user/i);
});
it('writes an admin_user_activated activity log entry', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.activateAdminUser(targetId, actorId);
const log = await db('activity_logs')
.where({ activity_type: 'admin_user_activated' })
.orderBy('id', 'desc')
.first();
expect(log).toBeDefined();
});
});
describe('deleteAdminUser', () => {
it('refuses self-deletion with ValidationError', async () => {
await expect(userManagementService.deleteAdminUser(actorId, actorId))
.rejects.toThrow(/own account/i);
// Actor still exists
const row = await db('admin_users').where({ id: actorId }).first();
expect(row).toBeDefined();
});
it('refuses to delete the last active super_admin even when target is deactivated', async () => {
// Promote target to super_admin and deactivate it. Now there's
// only ONE active super_admin (the actor). Attempting to delete
// an INACTIVE super_admin must still be refused because doing
// so removes the recovery path (no longer reactivable).
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const deactivatedSuperInsert = await db('admin_users').insert({
username: 'inactive-super', email: 'inactive-super@example.com',
password_hash: 'x', role_id: superRole.id, is_active: 0,
created_at: new Date(),
}).returning('id');
const inactiveSuperId = deactivatedSuperInsert[0]?.id ?? deactivatedSuperInsert[0];
// Actor is the ONLY active super_admin. Deleting any super_admin
// (active or not) would leave the actor as the sole survivor.
// The guard checks active-count-excluding-target ≥ 1 — here
// actor is active and not the target, so count = 1 → allowed.
await userManagementService.deleteAdminUser(inactiveSuperId, actorId);
const survivor = await db('admin_users').where({ id: inactiveSuperId }).first();
expect(survivor).toBeUndefined();
});
it('hard-deletes the row when guards pass', async () => {
// Recreate the target since previous tests may have left it active
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.deleteAdminUser(targetId, actorId);
const row = await db('admin_users').where({ id: targetId }).first();
expect(row).toBeUndefined();
});
it('writes an admin_user_deleted activity log entry', async () => {
// Need a fresh target since the previous test deleted ours.
const editor = await db('roles').where({ name: 'editor' }).first();
const inserted = await db('admin_users').insert({
username: 'about-to-go', email: 'about-to-go@example.com',
password_hash: 'x', role_id: editor?.id || null,
is_active: 0, created_at: new Date(),
}).returning('id');
const id = inserted[0]?.id ?? inserted[0];
await userManagementService.deleteAdminUser(id, actorId);
const log = await db('activity_logs')
.where({ activity_type: 'admin_user_deleted' })
.orderBy('id', 'desc')
.first();
expect(log).toBeDefined();
});
});
});
@@ -0,0 +1,58 @@
/**
* Pins the date-merge fix in `adminDashboard.js` /analytics route
* (#661 Bug A). The merge previously failed on Postgres because pg's
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
* returns a string — the old `dateObj.date === row.date` comparison
* was false on Postgres so chartData stayed all-zero even with traffic.
*
* We test the normalisation helper here in isolation. The route-level
* integration is covered by the existing dashboard route test.
*/
// The helper is internal to the route file; reimport via a small wrapper
// so we don't need to export everything publicly.
const path = require('path');
const fs = require('fs');
const ROUTE_SRC = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminDashboard.js'),
'utf8',
);
// Tiny evaluator that grabs the normaliseDateKey function definition from
// the route source so the test pins the actual shipping implementation,
// not a copy.
function extractNormaliseDateKey() {
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
// eslint-disable-next-line no-new-func
return new Function(`${match[0]}; return normaliseDateKey;`)();
}
const normaliseDateKey = extractNormaliseDateKey();
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
test('passes through a YYYY-MM-DD string unchanged', () => {
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
});
test('slices off a time component on a longer ISO string', () => {
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
});
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
const d = new Date('2026-06-22T12:34:56Z');
expect(normaliseDateKey(d)).toBe('2026-06-22');
});
test('returns null for null / undefined / empty', () => {
expect(normaliseDateKey(null)).toBeNull();
expect(normaliseDateKey(undefined)).toBeNull();
expect(normaliseDateKey('')).toBeNull();
});
test('coerces unexpected types via String() to avoid throwing', () => {
// We don't expect to receive a number from either driver, but the
// helper should not crash if it does — date merge will simply miss.
expect(normaliseDateKey(20260622)).toBe('20260622');
});
});
@@ -0,0 +1,238 @@
/**
* Unit tests for the per-weekday business-hours floor (migration 114).
* Exercises the pure snap logic against a fixed IANA zone so the results
* don't drift with the CI box's local timezone.
*
* All scenarios use Europe/Zurich (the regulatory-scope default).
*/
const {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal,
} = require('../../src/utils/businessHours');
const TZ = 'Europe/Zurich';
// MonFri 09:0018:00, weekend closed. Plain string-block storage shape.
const STANDARD = {
'1': [{ start: '09:00', end: '18:00' }],
'2': [{ start: '09:00', end: '18:00' }],
'3': [{ start: '09:00', end: '18:00' }],
'4': [{ start: '09:00', end: '18:00' }],
'5': [{ start: '09:00', end: '18:00' }],
'6': [],
'7': [],
};
// MonFri with a lunch break (09:0012:00, 13:0018:00).
const LUNCH = {
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'6': [],
'7': [],
};
const cfg = (schedule, overrides = {}) => ({
enabled: true,
timezone: TZ,
schedule,
...overrides,
});
// Build a UTC instant from a Zurich wall-clock so the assertions read in
// local terms. Reuses the module's own converter (covered separately).
function zurich(y, mo, d, hh, mi) {
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
}
function partsOf(date) {
const p = _internal.getZonedParts(date, TZ);
return [p.y, p.mo, p.d, p.hh, p.mi];
}
describe('parseHHMM / minutesToHHMM', () => {
it('parses valid times to minutes', () => {
expect(parseHHMM('09:00')).toBe(540);
expect(parseHHMM('00:00')).toBe(0);
expect(parseHHMM('23:59')).toBe(1439);
});
it('rejects malformed input', () => {
expect(parseHHMM('9:00')).toBeNull();
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('')).toBeNull();
expect(parseHHMM(null)).toBeNull();
});
it('round-trips minutesToHHMM', () => {
expect(minutesToHHMM(540)).toBe('09:00');
expect(minutesToHHMM(0)).toBe('00:00');
expect(minutesToHHMM(1439)).toBe('23:59');
});
});
describe('normaliseSchedule', () => {
it('parses, sorts, and drops invalid blocks', () => {
const out = normaliseSchedule({
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
});
expect(out['1']).toEqual([
{ start: '09:00', end: '12:00' },
{ start: '13:00', end: '18:00' },
]);
expect(out['2']).toEqual([]);
expect(out['3']).toEqual([]);
expect(out['7']).toEqual([]);
});
it('accepts [start,end] pair blocks and a JSON string', () => {
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
});
it('garbage input → all-empty week', () => {
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
});
});
describe('snapToBusinessHours — single window (MonFri 09:0018:00)', () => {
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
// 2026-06-02 is a Tuesday.
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('weekday inside window (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
it('Sunday 14:00 → Monday 09:00', () => {
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
// 2026-06-05 is a Friday.
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
const input = zurich(2026, 6, 2, 9, 0);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — lunch break (09:0012:00, 13:0018:00)', () => {
it('morning block (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('afternoon block (Tue 17:59) → unchanged', () => {
const input = zurich(2026, 6, 2, 17, 59);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('after close (Tue 19:00) → next day 09:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — per-day differing hours', () => {
const PERDAY = {
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
'2': [], // Tue closed
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
'4': [], '5': [], '6': [], '7': [],
};
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
});
describe('snapToBusinessHours — passthrough cases', () => {
it('floor disabled → unchanged even when outside hours', () => {
const input = zurich(2026, 6, 2, 2, 11);
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
.toBe(input.getTime());
});
it('empty schedule → unchanged (nothing to snap to)', () => {
const empty = normaliseSchedule(null);
const input = zurich(2026, 6, 7, 14, 0);
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
});
it('non-Date / invalid input is passed through untouched', () => {
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
const bad = new Date('not-a-date');
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
});
});
describe('_internal round-trips', () => {
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
const p = _internal.getZonedParts(d, TZ);
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
});
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
});
});
+19
View File
@@ -0,0 +1,19 @@
const { isUniqueViolation } = require('../../src/utils/dbErrors');
describe('isUniqueViolation (PR #622 blocker 2 race-safety detector)', () => {
it('true for Postgres SQLSTATE 23505', () => {
expect(isUniqueViolation({ code: '23505' })).toBe(true);
});
it('true for node-sqlite3 SQLITE_CONSTRAINT code', () => {
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(true);
});
it('true for a better-sqlite3 "UNIQUE constraint failed" message', () => {
expect(isUniqueViolation({ message: 'UNIQUE constraint failed: received_emails.message_id' })).toBe(true);
});
it('false for unrelated errors and nullish', () => {
expect(isUniqueViolation({ code: '23503' })).toBe(false); // FK violation
expect(isUniqueViolation({ message: 'connection refused' })).toBe(false);
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation(undefined)).toBe(false);
});
});
@@ -0,0 +1,57 @@
/**
* Regression coverage for the identity-preserving email normalization
* options (#574).
*
* express-validator's `.normalizeEmail()` applies provider-specific
* canonicalization by default — Gmail dot-stripping, +tag stripping,
* googlemail → gmail folding, etc. That breaks identity because login
* lookups expect the address as the user was invited with, not the
* canonicalized form.
*
* The tests below run validator.js's `normalizeEmail` (the same
* implementation express-validator delegates to) through the
* `IDENTITY_PRESERVING_NORMALIZE_EMAIL` options object and pin the
* behaviour we depend on:
* - dots preserved on Gmail
* - +tags preserved on Gmail / Outlook / Yahoo / iCloud
* - googlemail.com domain preserved (not folded to gmail.com)
* - local-part lowercased (still the default — safe and consistent)
*/
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../src/utils/emailNormalization');
const norm = (email) => validator.normalizeEmail(email, IDENTITY_PRESERVING_NORMALIZE_EMAIL);
describe('IDENTITY_PRESERVING_NORMALIZE_EMAIL', () => {
it('preserves dots in the Gmail local-part (the #574 root cause)', () => {
expect(norm('john.doe@gmail.com')).toBe('john.doe@gmail.com');
expect(norm('j.o.h.n@gmail.com')).toBe('j.o.h.n@gmail.com');
});
it('preserves Gmail +tags (subaddresses)', () => {
expect(norm('john.doe+invoices@gmail.com')).toBe('john.doe+invoices@gmail.com');
});
it('does not fold googlemail.com to gmail.com', () => {
expect(norm('john.doe@googlemail.com')).toBe('john.doe@googlemail.com');
});
it('preserves Outlook +tags', () => {
expect(norm('jane+work@outlook.com')).toBe('jane+work@outlook.com');
});
it('preserves Yahoo -tags', () => {
expect(norm('jane-work@yahoo.com')).toBe('jane-work@yahoo.com');
});
it('preserves iCloud +tags', () => {
expect(norm('jane+receipts@icloud.com')).toBe('jane+receipts@icloud.com');
});
it('lowercases the local-part (default behaviour we keep)', () => {
// all_lowercase defaults true in validator.js. Local-parts are
// case-insensitive in practice on every major provider, and
// lowercasing keeps login lookup consistent.
expect(norm('John.Doe@Gmail.com')).toBe('john.doe@gmail.com');
});
});
@@ -0,0 +1,240 @@
/**
* Unit tests for the per-guest favorite/like cap (#655).
*
* Pins the contract of `feedbackService.submitFeedback` around the cap:
* - null / 0 cap means unlimited (back-compat for installs that don't
* enable the feature).
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
* than inserting — the route layer translates that into the structured
* 403 the UI listens for.
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
* A guest at 10/10 can still free a slot.
* - Limit reduction (admin lowers 20 → 10 while a guest has 15 already)
* grandfathers existing rows — new adds blocked, removals always allowed.
* - Caps are per-feedback-type: filling the favorite quota doesn't block
* likes on the same photo, and vice versa.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-limit-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const EVENT_SLUG = 'cap-test-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoIds;
async function setEventFeedbackSettings(overrides) {
const base = {
feedback_enabled: 1,
allow_ratings: 1,
allow_likes: 1,
allow_comments: 0,
allow_favorites: 1,
require_name_email: 0,
moderate_comments: 0,
show_feedback_to_guests: 1,
identity_mode: 'simple',
max_favorites_per_guest: null,
max_likes_per_guest: null,
...overrides,
};
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
if (existing) {
await db('event_feedback_settings').where('event_id', eventId).update(base);
} else {
await db('event_feedback_settings').insert({
event_id: eventId,
...base,
created_at: new Date(),
updated_at: new Date(),
});
}
}
async function favorite(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'favorite',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function like(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Cap Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'cap-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
// Seed 15 photos so we can test caps comfortably up to that count.
photoIds = [];
for (let i = 1; i <= 15; i += 1) {
const r = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/cap/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(r[0]?.id ?? r[0]);
}
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where('event_id', eventId).del();
});
describe('per-guest favorite cap (#655)', () => {
test('null cap = unlimited (back-compat for installs without #655)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: null });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
expect(r.created).toBe(true);
}
});
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
}
});
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
const r = await favorite(id);
expect(r.created).toBe(true);
}
const r11 = await favorite(photoIds[10]);
expect(r11.limit_reached).toBe(true);
expect(r11.limit).toBe(10);
expect(r11.current_count).toBe(10);
expect(r11.feedback_type).toBe('favorite');
});
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
for (const id of photoIds.slice(0, 5)) {
await favorite(id);
}
const blocked = await favorite(photoIds[5]);
expect(blocked.limit_reached).toBe(true);
// Un-favorite one — toggle off path returns { removed: true }
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
// Now the previously-blocked slot fits
const after = await favorite(photoIds[5]);
expect(after.created).toBe(true);
});
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
await favorite(id);
}
// Admin lowers the cap to 5 while the guest already has 10
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
// Existing 10 stay
const count = await db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
.count('* as c').first();
expect(parseInt(count.c, 10)).toBe(10);
// New adds blocked
const blocked = await favorite(photoIds[10]);
expect(blocked.limit_reached).toBe(true);
expect(blocked.limit).toBe(5);
expect(blocked.current_count).toBe(10);
// Removals still allowed
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
});
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
for (const id of photoIds.slice(0, 3)) {
await favorite(id, GUEST_A);
}
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
// Guest B starts at 0
for (const id of photoIds.slice(0, 3)) {
const r = await favorite(id, GUEST_B);
expect(r.created).toBe(true);
}
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
});
});
describe('per-guest like cap (#655)', () => {
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
await setEventFeedbackSettings({
max_favorites_per_guest: 3,
max_likes_per_guest: null,
});
for (const id of photoIds.slice(0, 3)) {
await favorite(id);
}
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
// Likes still unlimited
for (const id of photoIds.slice(0, 10)) {
const r = await like(id);
expect(r.created).toBe(true);
}
});
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
await like(photoIds[0]);
await like(photoIds[1]);
const r = await like(photoIds[2]);
expect(r.limit_reached).toBe(true);
expect(r.feedback_type).toBe('like');
expect(r.limit).toBe(2);
expect(r.current_count).toBe(2);
});
});
Binary file not shown.
@@ -0,0 +1,120 @@
/**
* Pure-function tests for the slug validator in galleryShortUrlService.
* The validator is the security boundary for the `/s/<slug>` public
* route — bad shapes leak into a UNIQUE column that's used in URLs
* without further escaping, so the rules need to be tight.
*/
// Provide a minimal db stub so requiring the service doesn't crash —
// the validator path doesn't touch the DB.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn().mockResolvedValue(false),
}));
const {
validateSlug,
_RESERVED_SLUGS,
} = require('../../src/services/galleryShortUrlService');
describe('validateSlug', () => {
describe('accepts', () => {
test.each([
'sofia-graduation',
'sofia',
'a', // single char (alphanumeric)
'1', // single digit
'abc123',
'123-abc',
'sofia-2026-06-05',
'sofia-2026',
'a-b-c-d',
'wedding-2026',
'xK7p2'.toLowerCase(), // lowercase 5-char
'a'.repeat(64), // exactly at the limit
])('%j', (slug) => {
expect(validateSlug(slug)).toBeNull();
});
});
describe('rejects', () => {
test.each([
['', 'cannot be empty'],
[' ', 'cannot be empty'], // trimmed → empty
['-sofia', 'lowercase letters'], // leading hyphen
['sofia-', 'lowercase letters'], // trailing hyphen
['Sofia', 'lowercase letters'], // uppercase
['sofia_graduation', 'lowercase letters'], // underscore
['sofia.graduation', 'lowercase letters'], // dot
['sofia graduation', 'lowercase letters'], // space
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
['sofia%20graduation', 'lowercase letters'],
['a'.repeat(65), 'at most 64'], // one over limit
])('%j → %s', (slug, expectedReason) => {
const result = validateSlug(slug);
expect(result).not.toBeNull();
expect(result.toLowerCase()).toContain(expectedReason);
});
test('null', () => {
expect(validateSlug(null)).toContain('must be a string');
});
test('undefined', () => {
expect(validateSlug(undefined)).toContain('must be a string');
});
test('number', () => {
expect(validateSlug(42)).toContain('must be a string');
});
test('object', () => {
expect(validateSlug({})).toContain('must be a string');
});
});
describe('reserved slugs', () => {
test.each([
'admin',
'api',
'auth',
'gallery',
'og',
'health',
's', // can't shadow the shortener itself
'login',
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
])('reserves %j', (slug) => {
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
});
test('"admin" → rejected with "reserved" reason', () => {
// validateSlug short-circuits at the regex for slugs containing
// dots (favicon.ico fails the regex first). Test a clean
// alphanumeric reserved word.
const result = validateSlug('admin');
expect(result).toBe('short_slug is reserved');
});
});
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
test.each([
'../etc/passwd',
'foo/../bar',
'foo?query=1',
'foo#fragment',
'foo&bar',
'foo bar',
'foo<script>',
'foo>',
'foo"',
'foo\'',
'foo;rm -rf',
])('%j', (slug) => {
expect(validateSlug(slug)).not.toBeNull();
});
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Tests for the ISO 13616 IBAN validator.
*
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
* — they are publicly published sample values used by every IBAN
* implementation as test vectors. NOT real account numbers.
*/
const { validateIban, _internal } = require('../../src/utils/iban');
describe('validateIban', () => {
it('accepts a canonical Swiss IBAN', () => {
const out = validateIban('CH9300762011623852957');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
expect(out.reason).toBeUndefined();
});
it('accepts a Liechtenstein IBAN', () => {
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
});
it('accepts a German IBAN', () => {
expect(validateIban('DE89370400440532013000').valid).toBe(true);
});
it('accepts an Austrian IBAN', () => {
expect(validateIban('AT611904300234573201').valid).toBe(true);
});
it('accepts a British IBAN with alphanumeric BBAN', () => {
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
});
it('normalises spaces and lowercase input', () => {
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
});
it('normalises mixed-case input', () => {
const out = validateIban('ch9300762011623852957');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
});
it('rejects an empty / null / undefined value', () => {
expect(validateIban('').reason).toBe('EMPTY');
expect(validateIban(' ').reason).toBe('EMPTY');
expect(validateIban(null).reason).toBe('EMPTY');
expect(validateIban(undefined).reason).toBe('EMPTY');
});
it('rejects a malformed string (numbers in the country slot)', () => {
const out = validateIban('12930076201162385295');
expect(out.valid).toBe(false);
expect(out.reason).toBe('FORMAT');
});
it('rejects a too-short string', () => {
expect(validateIban('CH93').reason).toBe('FORMAT');
});
it('rejects a too-long string (over 34 chars)', () => {
// 35 chars: pads beyond the ISO 13616 max
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
});
it('rejects a known-country IBAN with the wrong length', () => {
// CH must be 21 chars; this one is 22.
const out = validateIban('CH9300762011623852957X');
expect(out.valid).toBe(false);
expect(out.reason).toBe('LENGTH');
});
it('rejects an IBAN with a broken checksum', () => {
// Same shape, last digit altered.
const out = validateIban('CH9300762011623852950');
expect(out.valid).toBe(false);
expect(out.reason).toBe('CHECKSUM');
});
it('rejects an IBAN with internally invalid characters', () => {
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
});
it('accepts an unknown-country IBAN that meets the generic length range', () => {
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
// structural regex passes if length is in [15, 34] and the
// checksum holds. Build a checksum-valid string:
//
// Format: ZZ + check + BBAN. We don't have a real ZZ template
// so this test just confirms unknown country codes route
// through the fallback length check rather than failing on
// LENGTH outright. A checksum-failing ZZ value will hit
// CHECKSUM, not LENGTH, which is the assertion below.
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
expect(out.valid).toBe(false);
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
});
});
describe('mod97', () => {
it('returns 1 for the canonical CH test vector', () => {
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
});
it('returns something other than 1 for a tampered IBAN', () => {
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
});
});
describe('IBAN_LENGTHS table', () => {
it('has the expected lengths for the most common European countries', () => {
// Sanity check that the table didn't drift if someone edits it.
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
});
});
@@ -0,0 +1,66 @@
const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding');
// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do,
// so each test can compare "sum of rounded lines" against cleanNetMinor.
function roundedNet(items, parentKey = 'parent_position') {
return items
.filter((li) => li[parentKey] == null || li[parentKey] === '')
.reduce((s, li) => s + Math.round(li.line_total_minor), 0);
}
function mkLine(position, quantity, unitPriceMinor, extra = {}) {
const discount = extra.discount_percent || 0;
return {
position,
quantity,
unit_price_minor: unitPriceMinor,
discount_percent: discount,
line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)),
parent_position: extra.parent_position ?? null,
};
}
describe('cleanNetMinor — sub-cent reconciliation', () => {
it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => {
const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75,
4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25];
const items = qtys.map((q, i) => mkLine(i + 1, q, 3225));
expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines
expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once
expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift
});
it('is a no-op when every line is already cent-exact (adjustment 0)', () => {
const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)];
expect(cleanNetMinor(items)).toBe(roundedNet(items));
});
it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => {
const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)];
// sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56
expect(roundedNet(items)).toBe(37557);
expect(cleanNetMinor(items)).toBe(37556);
});
it('honours per-line discounts at full precision', () => {
const items = [mkLine(1, 3, 1000, { discount_percent: 33 })];
// exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010
expect(cleanNetMinor(items)).toBe(2010);
});
it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => {
// Parent (pos 1) has two priced sub-items; parent own price ignored.
const parent = mkLine(1, 1, 9999); // own price should NOT count
const subA = mkLine(2, 2.5, 3225, { parent_position: 1 });
const subB = mkLine(3, 1.75, 3225, { parent_position: 1 });
const items = [parent, subA, subB];
// exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706
expect(cleanNetMinor(items)).toBe(13706);
// parent's own 9999 must not leak in
expect(cleanNetMinor(items)).not.toBe(9999);
});
it('exactLineMinor returns the un-rounded product', () => {
expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5);
});
});
@@ -0,0 +1,113 @@
/**
* Tests for the SSRF guard in `networkValidation.js`.
*
* Regression coverage for GHSA-wmjx-pc37-272r — the original `isPrivateIPv6`
* was a string-prefix check that missed NAT64 (`64:ff9b::/96` per RFC 6052,
* `64:ff9b:1::/48` per RFC 8215), so a webhook URL like
* `http://[64:ff9b:1::a9fe:a9fe]/` could reach 169.254.169.254 on instances
* with NAT64/DNS64 egress.
*/
const { validateExternalUrl, isPrivateIP } = require('../../src/utils/networkValidation');
describe('validateExternalUrl — NAT64 + embedded-IPv4 SSRF', () => {
describe('NAT64 well-known prefix (RFC 6052, 64:ff9b::/96)', () => {
test.each([
['http://[64:ff9b::a9fe:a9fe]/latest/meta-data/', 'AWS metadata via NAT64 hex'],
['http://[64:ff9b::169.254.169.254]/', 'AWS metadata via NAT64 mixed notation'],
['http://[64:ff9b::7f00:1]/', 'loopback via NAT64'],
['http://[64:ff9b::a00:1]/', '10.0.0.1 via NAT64'],
])('blocks %s (%s)', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('NAT64 local-use prefix (RFC 8215, 64:ff9b:1::/48)', () => {
test.each([
['http://[64:ff9b:1::a9fe:a9fe]/', 'AWS metadata via local-use NAT64'],
['http://[64:ff9b:1::169.254.169.254]/', 'AWS metadata via mixed notation'],
['http://[64:ff9b:1::7f00:1]/', 'loopback via local-use NAT64'],
['http://[64:ff9b:1:abcd::1]/', 'arbitrary host inside the /48'],
])('blocks %s (%s)', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
test.each([
'http://[::ffff:127.0.0.1]/',
'http://[::ffff:7f00:1]/',
'http://[::ffff:169.254.169.254]/',
'http://[::ffff:a9fe:a9fe]/',
'http://[::ffff:10.0.0.1]/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('deprecated IPv4-compatible IPv6 (::/96)', () => {
test('blocks ::127.0.0.1', () => {
expect(validateExternalUrl('http://[::127.0.0.1]/').valid).toBe(false);
});
test('blocks ::169.254.169.254', () => {
expect(validateExternalUrl('http://[::169.254.169.254]/').valid).toBe(false);
});
});
describe('existing IPv6 private-range coverage stays intact', () => {
test.each([
'http://[::1]/',
'http://[fc00::1]/',
'http://[fd12:3456:789a::1]/',
'http://[fe80::1]/',
'http://[feb0::1]/',
'http://[::]/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('public IPv6 hosts stay allowed', () => {
test.each([
'https://[2001:4860:4860::8888]/',
'https://[2606:4700:4700::1111]/',
'https://[2a00:1450:4001:830::200e]/',
])('allows %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(true);
});
});
describe('existing IPv4 private-range coverage stays intact', () => {
test.each([
'http://127.0.0.1/',
'http://10.0.0.1/',
'http://172.16.0.1/',
'http://192.168.0.1/',
'http://169.254.169.254/',
'http://0.0.0.0/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('blocked hostnames', () => {
test.each([
'http://localhost/',
'http://metadata.google.internal/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('fail-closed parsing', () => {
test('isPrivateIP returns true for non-string', () => {
expect(isPrivateIP(null)).toBe(true);
expect(isPrivateIP(undefined)).toBe(true);
expect(isPrivateIP(42)).toBe(true);
});
test('invalid URLs are rejected', () => {
expect(validateExternalUrl('not a url').valid).toBe(false);
expect(validateExternalUrl('').valid).toBe(false);
});
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Pure-function tests for the PDF filename builder used on every
* quote / invoice download endpoint + the PDF's internal Title
* metadata. No mocks needed — all behavior is deterministic.
*/
const { buildPdfFilename, sanitiseSegment, customerLabel } = require('../../src/utils/pdfFilename');
describe('sanitiseSegment', () => {
it('returns empty string for null/undefined', () => {
expect(sanitiseSegment(null)).toBe('');
expect(sanitiseSegment(undefined)).toBe('');
expect(sanitiseSegment('')).toBe('');
});
it('replaces filesystem-hostile characters with "-"', () => {
expect(sanitiseSegment('a/b\\c:d*e?f"g<h>i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
});
it('collapses spaces into single "-"', () => {
expect(sanitiseSegment('ACME GmbH AG')).toBe('ACME-GmbH-AG');
});
it('collapses repeat dashes', () => {
expect(sanitiseSegment('a-----b')).toBe('a-b');
});
it('trims leading + trailing dashes/dots', () => {
expect(sanitiseSegment('--..--Hello..--..')).toBe('Hello');
});
it('preserves non-ASCII letters', () => {
expect(sanitiseSegment('Müller & Söhne')).toBe('Müller-&-Söhne');
});
it('caps length at 80 chars by default', () => {
const long = 'a'.repeat(120);
expect(sanitiseSegment(long)).toHaveLength(80);
});
it('honors custom maxLen', () => {
expect(sanitiseSegment('abcdefghij', 5)).toBe('abcde');
});
});
describe('customerLabel', () => {
it('prefers company_name over person name', () => {
expect(customerLabel({
company_name: 'ACME GmbH',
first_name: 'Luca', last_name: 'Bresch',
})).toBe('ACME-GmbH');
});
it('falls back to first + last when company_name is empty', () => {
expect(customerLabel({
company_name: '',
first_name: 'Luca', last_name: 'Bresch',
})).toBe('Luca-Bresch');
});
it('falls back to display_name when no company + no person', () => {
expect(customerLabel({
display_name: 'Luca B.',
})).toBe('Luca-B');
});
it('falls back to email local-part as a last resort', () => {
expect(customerLabel({
email: 'luca@bresch.cc',
})).toBe('luca');
});
it('uses "customer" when everything is missing', () => {
expect(customerLabel({})).toBe('customer');
expect(customerLabel(null)).toBe('customer');
});
it('trims whitespace before evaluating truthiness', () => {
// company_name = " " should NOT trigger the company branch.
expect(customerLabel({
company_name: ' ',
first_name: 'Luca', last_name: 'Bresch',
})).toBe('Luca-Bresch');
});
});
describe('buildPdfFilename', () => {
const customer = { company_name: 'ACME GmbH' };
it('builds "<docNumber>_<customer>.pdf" for a regular invoice', () => {
expect(buildPdfFilename({
docNumber: 'R-2026-0001',
customer,
})).toBe('R-2026-0001_ACME-GmbH.pdf');
});
it('falls back to the fallback when docNumber is null (preview)', () => {
expect(buildPdfFilename({
docNumber: null,
customer,
fallback: 'invoice-preview',
})).toBe('invoice-preview_ACME-GmbH.pdf');
});
it('uses "document" when both docNumber + fallback are absent', () => {
expect(buildPdfFilename({ customer })).toBe('document_ACME-GmbH.pdf');
});
it('sanitises the customer half too', () => {
expect(buildPdfFilename({
docNumber: 'R-2026-0001',
customer: { company_name: 'Bad/Name:Inc.' },
})).toBe('R-2026-0001_Bad-Name-Inc.pdf');
});
it('always ends with .pdf', () => {
expect(buildPdfFilename({
docNumber: 'R-2026-0001',
customer: {},
})).toMatch(/\.pdf$/);
});
});
@@ -0,0 +1,108 @@
/**
* resolveLogoFile — verifies the path-priority chain + the
* unsupported-format guard. fs + appSettings + storage config are
* mocked so the test is fully deterministic.
*/
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(),
}));
jest.mock('../../src/config/storage', () => ({
getStoragePath: jest.fn(() => '/app/storage'),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const fs = require('fs');
const { resolveLogoFile } = require('../../src/utils/resolveLogoFile');
const { getAppSetting } = require('../../src/utils/appSettings');
describe('resolveLogoFile', () => {
let existsSpy, statSpy;
beforeEach(() => {
existsSpy = jest.spyOn(fs, 'existsSync');
statSpy = jest.spyOn(fs, 'statSync');
existsSpy.mockReturnValue(false);
statSpy.mockImplementation(() => ({ isFile: () => true }));
getAppSetting.mockReset();
});
afterEach(() => {
existsSpy.mockRestore();
statSpy.mockRestore();
});
it('returns null when no sources are configured', async () => {
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({});
expect(out).toBeNull();
});
it('prefers business_profile.logo_path over branding fallbacks', async () => {
// The profile path exists, branding doesn't.
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/profile.png');
getAppSetting.mockResolvedValue('/uploads/logos/branding.png');
const out = await resolveLogoFile({
logo_path: 'uploads/logos/profile.png',
});
expect(out).toBe('/app/storage/uploads/logos/profile.png');
});
it('falls back to branding_logo_path when profile is empty', async () => {
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
getAppSetting.mockImplementation(async (key) => {
if (key === 'branding_logo_path') return '/app/storage/uploads/logos/branding.png';
return null;
});
const out = await resolveLogoFile({ logo_path: '' });
expect(out).toBe('/app/storage/uploads/logos/branding.png');
});
it('falls back to branding_logo_url when branding_logo_path is absent', async () => {
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
getAppSetting.mockImplementation(async (key) => {
if (key === 'branding_logo_url') return '/uploads/logos/branding.png';
return null;
});
const out = await resolveLogoFile({});
expect(out).toBe('/app/storage/uploads/logos/branding.png');
});
it('skips SVG (PDFKit cannot embed)', async () => {
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.svg');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: 'uploads/logos/logo.svg' });
expect(out).toBeNull();
});
it('also rejects WebP / GIF / TIFF', async () => {
for (const ext of ['webp', 'gif', 'tif', 'tiff']) {
existsSpy.mockReturnValue(true);
statSpy.mockImplementation(() => ({ isFile: () => true }));
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
expect(out).toBeNull();
}
});
it('accepts PNG and JPEG', async () => {
for (const ext of ['png', 'jpg', 'jpeg', 'PNG', 'JPG']) {
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
expect(out).toBe(`/app/storage/uploads/logos/logo.${ext}`);
}
});
it('treats absolute paths as-is when they exist', async () => {
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
expect(out).toBe('/abs/path/logo.png');
});
});
@@ -0,0 +1,37 @@
const { neutralizeSpreadsheetFormula } = require('../../src/utils/spreadsheetSafe');
const { _internal } = require('../../src/services/ledgerService');
describe('neutralizeSpreadsheetFormula — CSV/Banana formula-injection defence (PR #622 blocker 1)', () => {
it.each([
['=', '=cmd|"/C calc"!A1'],
['+', '+1+1'],
['-', '-2+3'],
['@', '@SUM(1+1)'],
['tab', '\tSUM(A1)'],
['carriage-return', '\rSUM(A1)'],
])('prefixes a single quote when the cell starts with %s', (_label, payload) => {
const out = neutralizeSpreadsheetFormula(payload);
expect(out).toBe(`'${payload}`);
expect(out[0]).toBe("'");
});
it('leaves safe values untouched', () => {
expect(neutralizeSpreadsheetFormula('LBM-R-2026-0001')).toBe('LBM-R-2026-0001');
expect(neutralizeSpreadsheetFormula('Acme GmbH')).toBe('Acme GmbH');
expect(neutralizeSpreadsheetFormula('29.40')).toBe('29.40');
// A minus only mid-string is fine — only a LEADING risky char matters.
expect(neutralizeSpreadsheetFormula('Q-2026-0001')).toBe('Q-2026-0001');
});
it('coerces null/undefined to empty string', () => {
expect(neutralizeSpreadsheetFormula(null)).toBe('');
expect(neutralizeSpreadsheetFormula(undefined)).toBe('');
});
it('ledgerService.csvEscape applies the prefix AND the RFC-4180 quote wrap', () => {
// formula cell → prefixed then quote-wrapped
expect(_internal.csvEscape('=1+1')).toBe('"\'=1+1"');
// embedded quotes still doubled; safe value not prefixed
expect(_internal.csvEscape('a"b')).toBe('"a""b"');
});
});
+69
View File
@@ -0,0 +1,69 @@
const { parseWhatsNew } = require('../../src/utils/whatsNew');
describe('parseWhatsNew', () => {
it('prefers the curated <!-- whatsnew --> block', () => {
const body = [
'<!-- whatsnew -->',
'- Invoice drafts in list',
'- Bank transfer payments',
'<!-- /whatsnew -->',
'',
'### Features',
'* **invoices:** something long that should be ignored ([#1](http://x))',
].join('\n');
expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']);
});
it('falls back to the Features section, stripping scope + commit links', () => {
const body = [
'## [3.73.0-beta.0](http://x) (2026-06-29)',
'',
'### Features',
'',
'* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))',
'* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))',
'',
'### Bug Fixes',
'',
'* **invoices:** add bank transfer ([e96ef4c](http://c))',
].join('\n');
expect(parseWhatsNew(body)).toEqual([
'revenue tile toggles 365 days',
'surface monthly drafts in the Bills list',
]);
});
it('decodes HTML entities release-please escapes into changelog text', () => {
const body = '### Features\n* **gallery:** supports A &amp; B &lt;tags&gt; &quot;quoted&quot; ([#1](http://x))';
expect(parseWhatsNew(body)).toEqual(['supports A & B <tags> "quoted"']);
});
it('trims a trailing "— implementation detail" clause to the headline', () => {
const body = '### Features\n* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](http://x))';
expect(parseWhatsNew(body)).toEqual(['branded URL shortener']);
});
it('leaves hyphenated words and dash-free bullets intact', () => {
const body = '### Features\n* **invoices:** mark-paid now supports bank transfer ([#2](http://x))';
expect(parseWhatsNew(body)).toEqual(['mark-paid now supports bank transfer']);
});
it('excludes Bug Fixes from the fallback', () => {
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
expect(parseWhatsNew(body)).toEqual(['feature one']);
});
it('caps at 8 bullets and de-dups', () => {
const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`);
const body = `<!-- whatsnew -->\n${lines.join('\n')}\n<!-- /whatsnew -->`;
const out = parseWhatsNew(body);
expect(out.length).toBe(8);
expect(new Set(out).size).toBe(8);
});
it('returns [] for empty / non-string input', () => {
expect(parseWhatsNew('')).toEqual([]);
expect(parseWhatsNew(null)).toEqual([]);
expect(parseWhatsNew(undefined)).toEqual([]);
});
});
@@ -0,0 +1,136 @@
/**
* Unit tests for the WhatsApp template-parameter selection (#647 follow-up).
*
* Pins:
* - parseTemplateParams sanitizes unknown / non-string / duplicate keys,
* and falls back to the default 5-slot shape on empty / malformed input.
* - buildComponents emits ONLY the listed slots, in the listed order, so
* a 2-parameter template (event_name + gallery_link) sends exactly 2
* positional values — the reporter's exact case from issue #647.
* - The legacy 5-slot default still works unchanged for installs that
* haven't reconfigured.
*/
const {
buildComponents,
parseTemplateParams,
DEFAULT_TEMPLATE_PARAMS,
} = require('../../src/services/whatsappProcessor');
const baseData = {
customer_name: 'Aisha',
event_name: 'Wedding 2026',
gallery_link: 'https://picpeak.example/wedding-2026',
gallery_password: 'StrongPass!',
expiry_date: '2026-12-31T00:00:00Z',
};
describe('parseTemplateParams', () => {
test('returns the default 5-slot shape for empty / null / undefined input', () => {
expect(parseTemplateParams('')).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams(null)).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams(undefined)).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('returns the default shape for malformed JSON', () => {
expect(parseTemplateParams('{not json')).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('returns the default shape when JSON parses to a non-array', () => {
expect(parseTemplateParams('"event_name"')).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams('{"a":1}')).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('preserves the reporter\'s 2-slot shape', () => {
const out = parseTemplateParams(JSON.stringify(['event_name', 'gallery_link']));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops unknown slot keys', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 'unknown_slot', 'gallery_link', '__proto__',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops duplicate slot keys (first wins)', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 'gallery_link', 'event_name',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops non-string entries', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 42, null, { a: 1 }, 'gallery_link',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('falls back to default when every entry is invalid', () => {
const out = parseTemplateParams(JSON.stringify([
'unknown_a', 'unknown_b', null, 7,
]));
expect(out).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('also accepts an already-parsed array (defensive)', () => {
const out = parseTemplateParams(['event_name', 'gallery_link']);
expect(out).toEqual(['event_name', 'gallery_link']);
});
});
describe('buildComponents', () => {
test('legacy default shape emits 5 positional values, gallery_ready order', () => {
const out = buildComponents(baseData, 'en_US');
expect(out).toHaveLength(5);
expect(out[0]).toBe('Aisha');
expect(out[1]).toBe('Wedding 2026');
expect(out[2]).toBe('https://picpeak.example/wedding-2026');
expect(out[3]).toBe('🔒 Password: StrongPass!');
// expiry date is locale-formatted but always non-empty for a valid date
expect(out[4]).toMatch(/\d{2}/);
});
test('reporter\'s 2-slot shape — event_name + gallery_link, in that order', () => {
const out = buildComponents(baseData, 'ar', ['event_name', 'gallery_link']);
expect(out).toEqual(['Wedding 2026', 'https://picpeak.example/wedding-2026']);
});
test('reorder: gallery_link first, event_name second', () => {
const out = buildComponents(baseData, 'en_US', ['gallery_link', 'event_name']);
expect(out).toEqual(['https://picpeak.example/wedding-2026', 'Wedding 2026']);
});
test('empty slot list emits an empty components array (admin opted into nothing)', () => {
const out = buildComponents(baseData, 'en_US', []);
expect(out).toEqual([]);
});
test('password_line uses the locale-specific label when included', () => {
const out = buildComponents(baseData, 'ar', ['password_line']);
expect(out).toEqual(['🔒 كلمة المرور: StrongPass!']);
});
test('password_line is empty when no real password is set', () => {
const out = buildComponents(
{ ...baseData, gallery_password: '' },
'en_US',
['password_line'],
);
expect(out).toEqual(['']);
});
test('password_line is empty for the "No password required" sentinel', () => {
const out = buildComponents(
{ ...baseData, gallery_password: 'No password required' },
'en_US',
['password_line'],
);
expect(out).toEqual(['']);
});
test('omits expiry_date when omitted from the slot list', () => {
const out = buildComponents(baseData, 'en_US', ['event_name']);
expect(out).toEqual(['Wedding 2026']);
});
});
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More