Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1ae562a37 | |||
| c153b5b891 |
+142
-13
@@ -4,24 +4,65 @@
|
||||
# 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)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 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
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# 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 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
|
||||
@@ -39,6 +80,21 @@ 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.
|
||||
API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
@@ -63,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=
|
||||
@@ -80,6 +130,85 @@ APP_STORAGE=./storage
|
||||
APP_DATA=./data
|
||||
LOGS=./logs
|
||||
|
||||
# ─── Storage Backend ────────────────────────────────────────────────────────
|
||||
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
|
||||
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
|
||||
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
|
||||
#
|
||||
# STORAGE_BACKEND=local (default)
|
||||
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
|
||||
# existing deployment keeps working unchanged.
|
||||
#
|
||||
# STORAGE_BACKEND=s3
|
||||
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
|
||||
# disabled in this mode (S3 has no inotify) — every photo must enter via the
|
||||
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
|
||||
# existing local content to S3 before flipping the env.
|
||||
#
|
||||
# STORAGE_BACKEND=local
|
||||
#
|
||||
# STORAGE_S3_BUCKET=picpeak
|
||||
# STORAGE_S3_REGION=us-east-1
|
||||
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
|
||||
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
|
||||
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
|
||||
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
|
||||
# STORAGE_S3_PREFIX=picpeak
|
||||
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
|
||||
# STORAGE_S3_SSL=true
|
||||
#
|
||||
# Minimum IAM policy (AWS S3) for the bucket above:
|
||||
# {
|
||||
# "Version": "2012-10-17",
|
||||
# "Statement": [{
|
||||
# "Effect": "Allow",
|
||||
# "Action": [
|
||||
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
|
||||
# "s3:ListBucket", "s3:GetBucketLocation"
|
||||
# ],
|
||||
# "Resource": [
|
||||
# "arn:aws:s3:::picpeak",
|
||||
# "arn:aws:s3:::picpeak/*"
|
||||
# ]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
|
||||
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
|
||||
|
||||
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
|
||||
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
|
||||
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
|
||||
# per-webhook secret in the X-PicPeak-Signature header.
|
||||
#
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
|
||||
# Block URLs resolving to private IPs / loopback / .local etc. as an
|
||||
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
|
||||
# the same docker network or localhost. Production deployments must
|
||||
# leave this OFF.
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
#
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
|
||||
# How often the worker polls webhook_deliveries for pending rows.
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
|
||||
#
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
|
||||
# Maximum in-flight deliveries per worker tick. One slow consumer can
|
||||
# monopolize all 5 slots — bump this if your receivers are slow OR ship
|
||||
# a separate webhook-only deployment.
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY=5
|
||||
#
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
|
||||
# Per-request timeout. Beyond this, the delivery is recorded as a
|
||||
# network error and retried.
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS=10000
|
||||
#
|
||||
# WEBHOOK_MAX_ATTEMPTS (default: 5)
|
||||
# Total attempts before a delivery is marked failed. Backoff between
|
||||
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
|
||||
# WEBHOOK_MAX_ATTEMPTS=5
|
||||
|
||||
# Note on FRONTEND_API_URL (documentation only):
|
||||
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
|
||||
@@ -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 email security@example.com 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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
+439
-149
@@ -1,17 +1,30 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
# This workflow is triggered by:
|
||||
# - Push to main/develop branches (builds 'latest' or branch-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)
|
||||
# - Manual workflow dispatch
|
||||
#
|
||||
# Multi-arch strategy:
|
||||
# Each image (backend, frontend) is built once per architecture on a
|
||||
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
|
||||
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
|
||||
# merge job combines the digests into a multi-arch manifest and applies
|
||||
# the human-readable tags. This is the pattern documented at
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
#
|
||||
# Native runners are used instead of QEMU because npm install under
|
||||
# QEMU was previously too slow/unreliable for regular branch builds.
|
||||
|
||||
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:
|
||||
@@ -25,53 +38,68 @@ 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: ${{ github.repository }}/backend
|
||||
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
|
||||
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
|
||||
# "Compute image names" step. GHCR requires all-lowercase repository names,
|
||||
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
# Determine if this is a beta or stable release
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
# Only build ARM64 for tagged releases (v*.*.*)
|
||||
# QEMU emulation is too slow/unreliable for npm operations on regular builds
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.platforms.outputs.skip_qemu != 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -86,6 +114,158 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine if pushing
|
||||
id: push-decision
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Backend (labels only)
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
|
||||
- name: Build Backend image (push by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=gha,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 }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-backend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
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
|
||||
# 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'
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-backend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
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
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -103,86 +283,59 @@ 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: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- 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'
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Frontend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
# Determine if this is a beta or stable release
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
# Only build ARM64 for tagged releases (v*.*.*)
|
||||
# QEMU emulation is too slow/unreliable for npm operations on regular builds
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.platforms.outputs.skip_qemu != 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -197,6 +350,139 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine if pushing
|
||||
id: push-decision
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Frontend (labels only)
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
|
||||
- name: Build Frontend image (push by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
cache-from: type=gha,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 }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-frontend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
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
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-frontend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
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
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -214,84 +500,88 @@ 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: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- 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'
|
||||
|
||||
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
|
||||
# automatically creates multi-arch manifests when building for multiple platforms.
|
||||
# The images are already properly tagged and include all architectures.
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
summary:
|
||||
needs: [build-backend, build-frontend]
|
||||
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
|
||||
echo "ℹ️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
|
||||
echo "ℹ️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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")"
|
||||
+39
-1
@@ -69,7 +69,9 @@ backend/data/
|
||||
backend/docs/
|
||||
backend/logs/
|
||||
logs/
|
||||
storage/
|
||||
# Anchored to repo root: matches the top-level runtime storage dir,
|
||||
# NOT backend/src/services/storage/ (the storage backend abstraction code).
|
||||
/storage/
|
||||
data/
|
||||
certbot/
|
||||
|
||||
@@ -86,11 +88,47 @@ docs/*_PLAN.md
|
||||
docs/test-*.md
|
||||
docs/feature-*.md
|
||||
|
||||
# Scaffolding documentation (local development reference)
|
||||
docs/DATABASE_SCHEMA.md
|
||||
docs/BACKEND_SERVICES.md
|
||||
docs/API_ROUTES.md
|
||||
docs/FRONTEND_ARCHITECTURE.md
|
||||
docs/DEVELOPER_ONBOARDING.md
|
||||
docs/ENVIRONMENT_VARIABLES.md
|
||||
|
||||
# Build artifact: OpenAPI spec generated locally + synced into the
|
||||
# picpeak-docs repo. Never tracked here — the docs site at
|
||||
# docs.picpeak.app is the source of truth.
|
||||
docs/openapi.json
|
||||
docs/openapi.yaml
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
|
||||
tests/e2e/local/
|
||||
playwright-local-results/
|
||||
e2e-test.log
|
||||
scripts/e2e-local.sh
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
backend/*.db
|
||||
|
||||
# Test files and artifacts
|
||||
test-images/
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.0.0-beta.0"
|
||||
".": "3.79.1-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.2"
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
+2170
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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! 🎉
|
||||
@@ -1,799 +0,0 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#-configuration)
|
||||
- [Deployment](#-deployment)
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Automated Setup Script (Easiest)
|
||||
|
||||
For the simplest installation, use our unified setup script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
### Option 2: Docker with Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Create required directories
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
# Deploy using pre-built images
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
**Available image tags:**
|
||||
| Channel | Tags | Description |
|
||||
|---------|------|-------------|
|
||||
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
|
||||
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
|
||||
| Branch | `main`, `beta` | Latest from each branch |
|
||||
|
||||
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
|
||||
|
||||
### Option 3: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name (for production)
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
|
||||
Generate secure values:
|
||||
```bash
|
||||
# JWT Secret
|
||||
openssl rand -base64 64
|
||||
|
||||
# Database Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
|
||||
# Redis Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
```
|
||||
|
||||
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
|
||||
- Avoid `$` in passwords (recommended - use the commands above)
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
- `DB_PASSWORD` - PostgreSQL password
|
||||
- `REDIS_PASSWORD` - Redis password
|
||||
- `SMTP_*` - Email configuration
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
```env
|
||||
DB_HOST=db.example.com
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=change_me
|
||||
DB_NAME=picpeak_prod
|
||||
```
|
||||
|
||||
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you don’t set `DB_HOST` it will use the bundled `postgres` container.
|
||||
|
||||
### Frontend Configuration (frontend/.env)
|
||||
Create `frontend/.env` from `frontend/.env.example`:
|
||||
```bash
|
||||
cp frontend/.env.example frontend/.env
|
||||
```
|
||||
|
||||
Update `frontend/.env` with:
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
|
||||
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
|
||||
|
||||
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
|
||||
|
||||
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||
- The backend API runs on port **3001**
|
||||
- The frontend `.env` file MUST point to the correct backend port (3001)
|
||||
- Default `.env.example` is configured for Docker deployment
|
||||
|
||||
### Email Configuration Examples
|
||||
|
||||
#### Gmail
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
```
|
||||
|
||||
#### SendGrid
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Using Pre-built Images (Fastest)
|
||||
|
||||
```bash
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Start services using production compose file
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Building from Source (For Customization)
|
||||
|
||||
```bash
|
||||
# Build images locally
|
||||
docker compose build
|
||||
|
||||
# Or build with no cache for clean build
|
||||
docker compose build --no-cache
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
By default, services are exposed on:
|
||||
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
|
||||
- Backend/API: http://localhost:3001 (API only; no UI routes)
|
||||
- PostgreSQL: localhost:5432 (if needed)
|
||||
- Redis: localhost:6379 (if needed)
|
||||
|
||||
### Initial Admin Setup
|
||||
|
||||
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
|
||||
|
||||
#### Finding the Auto-Generated Admin Password
|
||||
|
||||
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
|
||||
|
||||
**Option 1: Search Docker logs for admin password** (recommended)
|
||||
```bash
|
||||
# Find the auto-generated admin password in logs
|
||||
docker compose logs backend | grep "Admin password"
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
```
|
||||
✅ Admin password generated: BraveTiger6231!
|
||||
```
|
||||
|
||||
**Option 2: View the complete initialization logs**
|
||||
```bash
|
||||
# View the complete admin setup logs
|
||||
docker compose logs backend | grep -A 10 "Admin user created"
|
||||
```
|
||||
|
||||
**Option 3: Check the saved credentials file**
|
||||
```bash
|
||||
# The password is also saved in the backend container
|
||||
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
**Option 4: Use the helper script**
|
||||
```bash
|
||||
# Show current admin username and email (password is hidden)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# Reset the admin password to a new random password (displays new password in console)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- When resetting password, the new password is displayed once in the console - save it immediately
|
||||
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||
|
||||
## 🔐 First Login
|
||||
|
||||
After deployment, you must complete the first login process which includes mandatory password change for security.
|
||||
|
||||
### Step 1: Locate Your Admin Password
|
||||
|
||||
1. **Find the auto-generated password** from the credentials file:
|
||||
```bash
|
||||
# Docker deployment
|
||||
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
|
||||
|
||||
# Or directly from the host (if you have access)
|
||||
cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
2. **Note the admin email** (default: `admin@example.com` unless customized)
|
||||
|
||||
### Step 2: Access Admin Panel
|
||||
|
||||
1. Navigate to your frontend domain and open the admin section:
|
||||
- `http://your-domain.com/admin` (behind reverse proxy)
|
||||
- `http://localhost:3000/admin` (Docker local)
|
||||
|
||||
The backend at `:3001` serves API only and does not serve the admin UI.
|
||||
2. Login using:
|
||||
- **Email**: `admin@example.com` (or your custom admin email)
|
||||
- **Password**: The auto-generated password from the logs
|
||||
|
||||
### Step 3: Mandatory Password Change
|
||||
|
||||
Upon first login, the system will **automatically redirect** you to change your password:
|
||||
|
||||
1. **You cannot skip this step** - it's enforced for security
|
||||
2. Enter the current auto-generated password
|
||||
3. Create a new secure password meeting these requirements:
|
||||
- Minimum 12 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
- At least one special character (!@#$%^&*)
|
||||
|
||||
### Security Best Practices for New Password
|
||||
|
||||
- **Use a unique password** not used elsewhere
|
||||
- **Consider a password manager** for generation and storage
|
||||
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
|
||||
- **Avoid personal information** (names, dates, etc.)
|
||||
- **Save securely** - you cannot recover this password easily
|
||||
|
||||
### If You Lose Access
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
|
||||
|
||||
```env
|
||||
# .env
|
||||
ADMIN_EMAIL=your-email@yourdomain.com
|
||||
```
|
||||
|
||||
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Configuring Your Channel
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
|
||||
```yaml
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
```
|
||||
|
||||
### Switching Channels
|
||||
|
||||
To switch between channels:
|
||||
|
||||
```bash
|
||||
# Edit your .env file
|
||||
nano .env
|
||||
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
|
||||
|
||||
# Pull the new images and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
|
||||
- Checks GitHub releases hourly (cached to avoid rate limits)
|
||||
- Shows updates relevant to your current channel (stable or beta)
|
||||
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
|
||||
### Routing Schema
|
||||
|
||||
PicPeak consists of two services that need to be routed correctly:
|
||||
|
||||
| Path | Service | Port | Description |
|
||||
|------|---------|------|-------------|
|
||||
| `/api/*` | Backend | 3001 | All API endpoints |
|
||||
| `/photos/*` | Backend | 3001 | Protected photo files |
|
||||
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
|
||||
| `/uploads/*` | Backend | 3001 | Upload files |
|
||||
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
|
||||
|
||||
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
|
||||
|
||||
### Option 1: Nginx
|
||||
|
||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Backend: API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
location ~ ^/(photos|thumbnails|uploads)/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Option 2: Traefik
|
||||
|
||||
Add labels to `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# API endpoints
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||
# Protected media files
|
||||
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
|
||||
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
|
||||
Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Backend: API endpoints
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /thumbnails/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
For any reverse proxy, you can use Let's Encrypt:
|
||||
|
||||
```bash
|
||||
# With Certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
|
||||
|
||||
# Or use your reverse proxy's built-in ACME support
|
||||
```
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
#### Manual Backup
|
||||
```bash
|
||||
# Database backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Files backup
|
||||
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
|
||||
```
|
||||
|
||||
#### Automated Backup
|
||||
The application includes a built-in backup service. Configure it in the admin panel:
|
||||
1. Login to admin panel
|
||||
2. Go to Settings → Backup
|
||||
3. Configure destination and schedule
|
||||
4. Enable backup service
|
||||
|
||||
### Updates
|
||||
|
||||
#### Method 1: Using Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull latest changes (for configuration updates)
|
||||
git pull
|
||||
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
|
||||
# Restart with new images
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
```
|
||||
|
||||
#### Method 2: Building from Source
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version or Channel Updates
|
||||
|
||||
To use a specific version or switch channels, update your `.env` file:
|
||||
|
||||
```bash
|
||||
# Edit .env to change the channel or pin to a specific version
|
||||
nano .env
|
||||
|
||||
# Options for PICPEAK_CHANNEL:
|
||||
# - stable (recommended, production-ready)
|
||||
# - beta (early access to new features)
|
||||
# - v2.3.0 (pin to specific stable version)
|
||||
# - v2.3.0-beta.1 (pin to specific beta version)
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard will notify you when updates are available for your configured channel.
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
```bash
|
||||
docker exec picpeak-backend npm run migrate
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 502 Bad Gateway / Login Failures
|
||||
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
|
||||
|
||||
1. **CORS Configuration Errors**:
|
||||
```bash
|
||||
# WRONG - Missing port will cause CORS errors
|
||||
FRONTEND_URL=http://10.0.252.12
|
||||
|
||||
# CORRECT - Include the port you're accessing from
|
||||
FRONTEND_URL=http://10.0.252.12:3000
|
||||
```
|
||||
|
||||
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
|
||||
|
||||
2. **After Container Restarts**:
|
||||
- Nginx may have cached old container IPs
|
||||
- Solution: `docker restart picpeak-frontend`
|
||||
- Always wait 30-60 seconds for health checks
|
||||
|
||||
3. **Backend Not Starting After Migrations**:
|
||||
- The logs may only show migrations completed
|
||||
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
|
||||
- Should see `node server.js` process
|
||||
|
||||
4. **Login After Fresh Install**:
|
||||
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
|
||||
- Email: `admin@example.com` (or your custom admin email from .env)
|
||||
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
|
||||
- Remember: Password MUST be changed on first login
|
||||
|
||||
5. **Complete Fix Sequence**:
|
||||
```bash
|
||||
# 1. Fix your .env file URLs
|
||||
# 2. Full restart
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Wait for healthy status
|
||||
sleep 60
|
||||
docker ps # All should show (healthy)
|
||||
|
||||
# 4. Test backend directly
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# 5. Test through frontend
|
||||
curl http://localhost:3000/api/public/settings
|
||||
```
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
|
||||
# Change ports in .env
|
||||
FRONTEND_PORT=3002
|
||||
BACKEND_PORT=3003
|
||||
```
|
||||
|
||||
#### Docker Compose Variable Substitution Errors
|
||||
If you see warnings like:
|
||||
```
|
||||
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
|
||||
```
|
||||
|
||||
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
|
||||
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
|
||||
2. **Alternative**: Escape `$` as `$$` in your .env file
|
||||
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
|
||||
|
||||
#### Permission Errors
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R 1000:1000 events data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
#### Database Connection Issues
|
||||
```bash
|
||||
# Check if database is running
|
||||
docker compose ps
|
||||
docker compose logs postgres
|
||||
|
||||
# Test connection
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
#### Email Not Sending
|
||||
- Verify SMTP settings in .env
|
||||
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
|
||||
- For Gmail, use app-specific password
|
||||
- Check logs: `docker compose logs backend | grep email`
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Frontend health
|
||||
curl http://localhost:3000
|
||||
|
||||
# Database health
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker exec -it picpeak-backend sh
|
||||
|
||||
# Enter database
|
||||
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
|
||||
|
||||
# Reset admin password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
|
||||
# Check disk usage
|
||||
df -h
|
||||
du -sh events/ storage/ backup/
|
||||
|
||||
# View running processes
|
||||
docker compose top
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
|
||||
2. **Firewall**: Only expose necessary ports (80, 443)
|
||||
3. **Secure passwords**: Use strong, unique passwords for all services
|
||||
4. **Regular updates**: Keep Docker images and system packages updated
|
||||
5. **Backup strategy**: Set up automated backups and test restoration
|
||||
6. **Monitor logs**: Regularly check logs for suspicious activity
|
||||
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check logs first: `docker compose logs`
|
||||
- Review documentation in the repository
|
||||
- Check existing issues on GitHub
|
||||
- Create a new issue with:
|
||||
- Error messages
|
||||
- Log output
|
||||
- Environment details (without secrets)
|
||||
- Steps to reproduce
|
||||
@@ -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" />
|
||||
|
||||
@@ -7,12 +15,28 @@
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
|
||||
</div>
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
|
||||
## 🎮 Live Demo
|
||||
|
||||
Try PicPeak without installing anything:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
|
||||
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
|
||||
| **Email** | `demo@picpeak.app` |
|
||||
| **Password** | `Demo2026!` |
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
@@ -33,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
|
||||
|
||||
@@ -52,38 +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
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3005
|
||||
# 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 non‑root user by default.
|
||||
- Set `PUID` and `PGID` in your `.env` to match your host user’s 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 4–6 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
@@ -113,8 +157,8 @@ PICPEAK_CHANNEL=v2.3.0
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.production.yml pull
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
@@ -127,10 +171,18 @@ UPDATE_CHECK_ENABLED=false
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
|
||||
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
|
||||
|
||||
- 🚀 [**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
|
||||
- 📽️ [**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
|
||||
|
||||
Project meta:
|
||||
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
@@ -156,20 +208,127 @@ 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
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: File-based with automatic archiving
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 💾 Storage Backends
|
||||
|
||||
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
|
||||
|
||||
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|
||||
|---|---|---|
|
||||
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
|
||||
| Admin UI upload | ✅ | ✅ |
|
||||
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
|
||||
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
|
||||
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
|
||||
| Backups | ✅ | ✅ |
|
||||
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
|
||||
|
||||
### Switching to an S3-compatible backend
|
||||
|
||||
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
|
||||
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
|
||||
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
|
||||
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
|
||||
|
||||
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
|
||||
|
||||
## 🔔 Webhooks
|
||||
|
||||
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
|
||||
|
||||
### Event types
|
||||
|
||||
| Event | Fires when |
|
||||
|---|---|
|
||||
| `event.created` | Gallery created (admin or API) |
|
||||
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
|
||||
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
|
||||
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
|
||||
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
|
||||
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
|
||||
|
||||
### Payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"type": "event.published",
|
||||
"created_at": "2026-04-28T05:25:00.000Z",
|
||||
"data": {
|
||||
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also sent on every request:
|
||||
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
|
||||
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
|
||||
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
|
||||
- `User-Agent: PicPeak-Webhooks/1.0`
|
||||
|
||||
### Verifying signatures
|
||||
|
||||
**Node.js**
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
function verify(secret, rawBody, signature) {
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const a = Buffer.from(expected, 'hex');
|
||||
const b = Buffer.from(signature, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
```
|
||||
|
||||
**Python**
|
||||
```python
|
||||
import hmac, hashlib
|
||||
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
**curl + openssl** (one-liner for a quick replay)
|
||||
```sh
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
|
||||
```
|
||||
|
||||
### Retries + observability
|
||||
|
||||
- `2xx` → success, recorded with latency
|
||||
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
|
||||
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
|
||||
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
|
||||
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
|
||||
|
||||
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
|
||||
|
||||
### SSRF protection
|
||||
|
||||
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
|
||||
|
||||
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### 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
|
||||
@@ -179,6 +338,26 @@ Perfect for:
|
||||
- **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:
|
||||
|
||||
@@ -212,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 | 3GB–Unlimited*** |
|
||||
| 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 0–10 h depending on tier).
|
||||
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
@@ -234,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
|
||||
|
||||
@@ -279,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
|
||||
@@ -297,10 +483,32 @@ These features are currently in beta testing and may have limited functionality
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/theluap" target="_blank">
|
||||
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@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.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
@@ -311,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.
|
||||
@@ -318,7 +554,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
|
||||
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
@@ -327,7 +563,9 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
|
||||
<a href="https://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
|
||||
@@ -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 4–6 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
|
||||
|
||||
4–6 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 4–6 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.
|
||||
+7
-7
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -15,9 +15,9 @@ 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 by:
|
||||
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Mark it clearly as "SECURITY" in the title
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **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
|
||||
- Steps to reproduce
|
||||
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- 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!
|
||||
+39
-19
@@ -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)
|
||||
@@ -219,29 +232,36 @@ location ~ ^/(photos|thumbnails|uploads) {
|
||||
|
||||
### Creating a Gallery
|
||||
|
||||
#### Method 1: Via Admin Panel (Recommended)
|
||||
1. Login to admin panel
|
||||
#### Via Admin Panel
|
||||
1. Login to admin panel at `/admin`
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings and upload photos
|
||||
3. Configure settings (name, date, password, customer email)
|
||||
4. Upload photos via drag & drop in the Photos tab
|
||||
5. Publish the gallery when ready
|
||||
|
||||
#### Adding Photos via File System
|
||||
|
||||
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
|
||||
|
||||
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
|
||||
|
||||
#### Method 2: File System
|
||||
```bash
|
||||
# Docker installation
|
||||
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
|
||||
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
|
||||
# Docker installation — copy photos into an existing event's folder
|
||||
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
|
||||
|
||||
# Native installation
|
||||
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
|
||||
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
|
||||
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
|
||||
```
|
||||
|
||||
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
wedding-smith-2024/
|
||||
├── collages/ # Group photos
|
||||
├── individual/ # Individual photos
|
||||
└── thumbnails/ # Auto-generated thumbnails
|
||||
<event-slug>/
|
||||
├── collages/ # Group photos (optional subfolder)
|
||||
├── individual/ # Individual photos (optional subfolder)
|
||||
└── photo.jpg # Photos at root level also work
|
||||
```
|
||||
|
||||
## 🔧 Service Management
|
||||
@@ -461,11 +481,11 @@ sudo -u picpeak node scripts/reset-admin-password.js
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://github.com/the-luap/picpeak)
|
||||
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
||||
- [Full Documentation](https://docs.picpeak.app)
|
||||
- [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
|
||||
@@ -543,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)
|
||||
|
||||
@@ -9,11 +9,57 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# 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. 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# URLs (adjust for your domain)
|
||||
ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
|
||||
|
||||
# API URL for email assets (logos, images in emails)
|
||||
# This must be the publicly accessible URL where recipients can load images
|
||||
# If not set, defaults to http://localhost:3001 which will break images in production emails
|
||||
API_URL=https://photos.example.com/api
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=localhost
|
||||
@@ -39,6 +85,7 @@ SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
|
||||
+54
-14
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
@@ -7,14 +7,10 @@ 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"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
@@ -27,19 +23,46 @@ RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
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 and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# 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()).
|
||||
# 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
|
||||
@@ -51,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"]
|
||||
|
||||
@@ -5,8 +5,10 @@ WORKDIR /app
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
|
||||
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
||||
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
||||
RUN apk add --no-cache dumb-init ffmpeg
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
@@ -28,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',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,15 @@ const crypto = require('crypto');
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initialize: initDb } = require('../../src/database/db');
|
||||
const { db, initializeDatabase: initDb } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
// Test configuration
|
||||
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
|
||||
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
|
||||
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
|
||||
const TEST_CONFIG = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: 'test-backup-bucket-' + Date.now(),
|
||||
@@ -56,9 +59,17 @@ describe('S3 Backup Integration Tests', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
await initDb();
|
||||
await db.migrate.latest();
|
||||
// Schema is expected to already be applied by `npm run migrate` against
|
||||
// the dev database. db.migrate.latest() can't be used here because
|
||||
// PicPeak's custom run-migrations.js tracks state in the `migrations`
|
||||
// table (not knex's `knex_migrations`), so knex would try to re-apply
|
||||
// every migration and crash on duplicate-table errors.
|
||||
const ok = await db.schema.hasTable('events')
|
||||
&& await db.schema.hasTable('app_settings')
|
||||
&& await db.schema.hasTable('backup_runs');
|
||||
if (!ok) {
|
||||
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
|
||||
}
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
@@ -69,10 +80,12 @@ describe('S3 Backup Integration Tests', () => {
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
if (process.env.UNMOCK_LOGGER !== 'true') {
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -165,8 +178,9 @@ describe('S3 Backup Integration Tests', () => {
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
|
||||
// pg driver returns bigint columns as strings; coerce for the size assertion.
|
||||
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
|
||||
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
@@ -269,13 +283,16 @@ describe('S3 Backup Integration Tests', () => {
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(secondRun.files_backed_up).toBe(1); // Only modified file
|
||||
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
|
||||
|
||||
// Check manifest indicates incremental
|
||||
// Check manifest indicates incremental. The current manifest schema
|
||||
// groups counts under `incremental.changes.*` (added/modified/deleted/
|
||||
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
|
||||
if (secondRun.manifest_path) {
|
||||
const manifest = await backupService.getBackupManifest(secondRun.id);
|
||||
expect(manifest.manifest.incremental).toBeDefined();
|
||||
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
|
||||
expect(manifest.manifest.incremental.changes).toBeDefined();
|
||||
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -468,15 +485,16 @@ describe('S3 Backup Integration Tests', () => {
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
// Schema drift: app_settings has no created_at column anymore and the
|
||||
// unique constraint is on setting_key alone, not (setting_type, key).
|
||||
for (const setting of settings) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflict(['setting_type', 'setting_key'])
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
|
||||
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
throw new Error('db disabled in this test');
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function makeSourceJpeg(targetDir, name) {
|
||||
const localPath = path.join(targetDir, name);
|
||||
// 800x600 random RGB image so sharp has something realistic to thumbnail.
|
||||
const width = 800;
|
||||
const height = 600;
|
||||
const buf = Buffer.alloc(width * height * 3);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||||
await sharp(buf, { raw: { width, height, channels: 3 } })
|
||||
.jpeg({ quality: 90 })
|
||||
.toFile(localPath);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
storageModule.setStorageForTesting(storage);
|
||||
// Require AFTER setStorageForTesting so the module sees our injection.
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('generateThumbnail writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(key).toBe('thumbnails/thumb_sample.jpg');
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat.size).toBeGreaterThan(100);
|
||||
|
||||
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
expect(meta.width).toBeLessThanOrEqual(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('generateHeroImage writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
|
||||
const key = await imageProcessor.generateHeroImage(src);
|
||||
expect(key).toBe('heroes/hero_hero-source.jpg');
|
||||
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);
|
||||
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('withLocalCopy yields a usable local path on both backends', async () => {
|
||||
const sourceKey = 'fixture/withlocal.jpg';
|
||||
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
|
||||
const buf = await fs.readFile(src);
|
||||
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
|
||||
|
||||
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
|
||||
const meta = await sharp(localPath).metadata();
|
||||
return meta.width;
|
||||
});
|
||||
expect(seenSize).toBe(800);
|
||||
});
|
||||
});
|
||||
@@ -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,189 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { Readable } = require('stream');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
|
||||
// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed.
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
// Build the matrix of backends to test. Local always runs; S3 runs against MinIO
|
||||
// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so
|
||||
// every consumer can rely on identical semantics.
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
// Empty bucket then delete it.
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function readToString(stream) {
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('put + get + exists + stat + delete round-trip with a buffer body', async () => {
|
||||
const key = 'photos/event-a/IMG_0001.jpg';
|
||||
const body = Buffer.from('hello picpeak');
|
||||
|
||||
await storage.put(key, body, { contentType: 'image/jpeg' });
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat).not.toBeNull();
|
||||
expect(stat.size).toBe(body.length);
|
||||
|
||||
const stream = await storage.get(key);
|
||||
const text = await readToString(stream);
|
||||
expect(text).toBe('hello picpeak');
|
||||
|
||||
await storage.delete(key);
|
||||
expect(await storage.exists(key)).toBe(false);
|
||||
expect(await storage.stat(key)).toBeNull();
|
||||
});
|
||||
|
||||
test('put accepts a Readable stream body', async () => {
|
||||
const key = 'photos/event-b/streamed.bin';
|
||||
const body = Readable.from(Buffer.from('streamed payload'));
|
||||
|
||||
await storage.put(key, body);
|
||||
|
||||
const got = await readToString(await storage.get(key));
|
||||
expect(got).toBe('streamed payload');
|
||||
});
|
||||
|
||||
test('putFromFile + getToFile round-trip', async () => {
|
||||
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`);
|
||||
const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`);
|
||||
await fsp.writeFile(tmpIn, 'file payload');
|
||||
|
||||
const key = 'thumbnails/thumb_x.jpg';
|
||||
await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' });
|
||||
|
||||
await storage.getToFile(key, tmpOut);
|
||||
const text = await fsp.readFile(tmpOut, 'utf-8');
|
||||
expect(text).toBe('file payload');
|
||||
|
||||
await fsp.unlink(tmpIn).catch(() => {});
|
||||
await fsp.unlink(tmpOut).catch(() => {});
|
||||
});
|
||||
|
||||
test('list returns entries under a prefix with size + key', async () => {
|
||||
await storage.put('events/active/a/photo1.jpg', Buffer.from('a1'));
|
||||
await storage.put('events/active/a/photo2.jpg', Buffer.from('a22'));
|
||||
await storage.put('events/active/b/photo3.jpg', Buffer.from('b333'));
|
||||
|
||||
const entries = await storage.list('events/active/a');
|
||||
const keys = entries.map((e) => e.key).sort();
|
||||
expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']);
|
||||
const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size]));
|
||||
expect(sizes['events/active/a/photo1.jpg']).toBe(2);
|
||||
expect(sizes['events/active/a/photo2.jpg']).toBe(3);
|
||||
});
|
||||
|
||||
test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => {
|
||||
await storage.put('uploads/temp.jpg', Buffer.from('rename-me'));
|
||||
await storage.rename('uploads/temp.jpg', 'uploads/final.jpg');
|
||||
|
||||
expect(await storage.exists('uploads/temp.jpg')).toBe(false);
|
||||
expect(await storage.exists('uploads/final.jpg')).toBe(true);
|
||||
const text = await readToString(await storage.get('uploads/final.jpg'));
|
||||
expect(text).toBe('rename-me');
|
||||
});
|
||||
|
||||
test('copy duplicates an object without removing the source', async () => {
|
||||
await storage.put('events/source.jpg', Buffer.from('src'));
|
||||
await storage.copy('events/source.jpg', 'events/copied.jpg');
|
||||
|
||||
expect(await storage.exists('events/source.jpg')).toBe(true);
|
||||
expect(await storage.exists('events/copied.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
test('delete on a missing key is a no-op (does not throw)', async () => {
|
||||
await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('stat on a missing key returns null', async () => {
|
||||
expect(await storage.stat('still/not/here.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects path traversal attempts', async () => {
|
||||
await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i);
|
||||
await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
|
||||
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
|
||||
// the SSRF check by default.
|
||||
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
||||
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
||||
|
||||
const http = require('http');
|
||||
const { db } = require('../../src/database/db');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
||||
|
||||
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
||||
// in the docker-compose flow but spun up inside the Jest process so the
|
||||
// suite is self-contained.
|
||||
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
|
||||
const requests = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
||||
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
||||
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
||||
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
|
||||
// route layer's allowlist check is bypassed here since we insert
|
||||
// straight into the DB.
|
||||
const { plaintext, preview } = webhookService.generateSecret();
|
||||
const insert = await db('webhooks').insert({
|
||||
name: extras.name || 'test',
|
||||
url,
|
||||
secret: plaintext,
|
||||
secret_preview: preview,
|
||||
events: JSON.stringify(events),
|
||||
active: extras.active !== false,
|
||||
created_by: 1,
|
||||
}).returning('id');
|
||||
const id = insert[0]?.id || insert[0];
|
||||
return { id, secret: plaintext };
|
||||
}
|
||||
|
||||
async function clearWebhooks() {
|
||||
await db('webhook_deliveries').del();
|
||||
await db('webhooks').del();
|
||||
}
|
||||
|
||||
describe('webhook delivery worker (#327)', () => {
|
||||
beforeAll(async () => {
|
||||
// Schema is expected to already be applied by `npm run migrate`. We
|
||||
// just verify the webhooks tables exist; if not, the test harness has
|
||||
// missed running migration 082.
|
||||
const ok = await db.schema.hasTable('webhooks');
|
||||
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
stopWebhookDeliveryWorker();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearWebhooks();
|
||||
});
|
||||
|
||||
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
const { id, secret } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
|
||||
await __test.tick();
|
||||
|
||||
expect(stub.requests).toHaveLength(1);
|
||||
const got = stub.requests[0];
|
||||
const sig = got.headers['x-picpeak-signature'];
|
||||
expect(sig).toBeTruthy();
|
||||
// Receiver-side verification using the SAME helper we ship in the README.
|
||||
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
|
||||
// Tampering must fail.
|
||||
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('success');
|
||||
expect(row.attempt_count).toBe(1);
|
||||
expect(row.response_status).toBe(200);
|
||||
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('headers include event type and a unique delivery id', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
await insertWebhook(stub.url, ['photo.uploaded']);
|
||||
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
|
||||
await __test.tick();
|
||||
|
||||
const got = stub.requests[0];
|
||||
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
|
||||
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
|
||||
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
|
||||
const stub = await makeStub({ status: 500 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: { id: 2 } });
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('pending');
|
||||
expect(row.attempt_count).toBe(1);
|
||||
expect(row.response_status).toBe(500);
|
||||
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
|
||||
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
|
||||
expect(dueIn).toBeGreaterThan(50_000);
|
||||
expect(dueIn).toBeLessThan(70_000);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
|
||||
const stub = await makeStub({ status: 500 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
// Pre-seed a delivery already at attempt_count = 4 so a single tick
|
||||
// takes it to 5 → failed (avoids waiting through backoffs).
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 4,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.attempt_count).toBe(5);
|
||||
expect(row.completed_at).toBeTruthy();
|
||||
expect(row.next_retry_at).toBeNull();
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('truncates response body to 1KB before storing', async () => {
|
||||
const big = 'x'.repeat(5000);
|
||||
const stub = await makeStub({ status: 200, bodyOverride: big });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: {} });
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('success');
|
||||
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
|
||||
// fire enqueues regardless of active state at fire-time, but we
|
||||
// disabled BEFORE firing so nothing is enqueued. Direct insert to
|
||||
// exercise the worker's mid-flight disable check:
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 0,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
expect(stub.requests).toHaveLength(0);
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.last_error).toMatch(/disabled/i);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
|
||||
__test.setAllowPrivateUrls(false);
|
||||
try {
|
||||
const { id } = await insertWebhook('http://127.0.0.1:9/');
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 0,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.last_error).toMatch(/private|internal/i);
|
||||
} finally {
|
||||
__test.setAllowPrivateUrls(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('worker can be started + stopped without leaking timers', async () => {
|
||||
startWebhookDeliveryWorker();
|
||||
startWebhookDeliveryWorker(); // idempotent
|
||||
stopWebhookDeliveryWorker();
|
||||
stopWebhookDeliveryWorker(); // idempotent
|
||||
// If timers leaked the test runner would warn after force-exit; assertion
|
||||
// is just "no throw".
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,112 @@
|
||||
/**
|
||||
* Unit test for the non-mutating isSessionExpired() helper added to
|
||||
* middleware/sessionTimeout.js. Used by GET /auth/session to mirror the
|
||||
* timeout enforcement that sessionTimeoutMiddleware applies to /api/admin
|
||||
* endpoints — closing the asymmetry that surfaced as the redirect-loop
|
||||
* recurrence on v3.39.1-beta.0 (issue #350).
|
||||
*
|
||||
* The helper has two branches:
|
||||
* 1. In-memory `lastActivity` exists for this token → expired iff
|
||||
* now - lastActivity > timeout.
|
||||
* 2. No in-memory entry (post-restart, or first request) → expired
|
||||
* iff token's iat is older than the timeout (post-restart guard
|
||||
* that the existing middleware already implements at line ~101).
|
||||
*
|
||||
* Both branches must NOT mutate the in-memory `sessions` Map — the
|
||||
* middleware is the only place that tracks activity. We assert that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
where: () => ({
|
||||
first: () => ({
|
||||
timeout: () => Promise.resolve(null),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Speed up the cached-timeout reads. The module reads
|
||||
// `security_session_timeout_minutes` from app_settings and falls back to
|
||||
// DEFAULT_SESSION_TIMEOUT (60 min) when the row is null.
|
||||
const SIXTY_MINUTES_MS = 60 * 60 * 1000;
|
||||
|
||||
const sessionTimeout = require('../../src/middleware/sessionTimeout');
|
||||
const { isSessionExpired } = sessionTimeout;
|
||||
|
||||
function makeDecodedToken({ id = 1, iatSecondsAgo = 0 } = {}) {
|
||||
return { id, iat: Math.floor((Date.now() - iatSecondsAgo * 1000) / 1000) };
|
||||
}
|
||||
|
||||
describe('isSessionExpired (sessionTimeout helper)', () => {
|
||||
it('returns false for a freshly-issued token with no in-memory record', async () => {
|
||||
const decoded = makeDecodedToken({ id: 1, iatSecondsAgo: 60 });
|
||||
expect(await isSessionExpired('fresh-token-1', decoded)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when iat is older than the timeout (post-restart guard)', async () => {
|
||||
const decoded = makeDecodedToken({
|
||||
id: 2,
|
||||
// 90 minutes > 60 minute default timeout
|
||||
iatSecondsAgo: 90 * 60,
|
||||
});
|
||||
expect(await isSessionExpired('stale-token-2', decoded)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false / true based on lastActivity when one exists', async () => {
|
||||
// Drive the in-memory map by running the actual middleware once to
|
||||
// record activity for the token, then check the helper.
|
||||
const decoded = makeDecodedToken({ id: 3 });
|
||||
|
||||
// Drive the actual middleware once with a real signed token so it
|
||||
// records this token in the in-memory `sessions` Map. Then check the
|
||||
// helper sees that recent activity and reports "not expired".
|
||||
const res = { status: jest.fn(() => res), json: jest.fn() };
|
||||
const jwt = require('jsonwebtoken');
|
||||
process.env.JWT_SECRET = 'session-timeout-helper-test-secret';
|
||||
const realToken = jwt.sign(decoded, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
});
|
||||
const realReq = {
|
||||
headers: { authorization: `Bearer ${realToken}` },
|
||||
cookies: {},
|
||||
};
|
||||
await sessionTimeout.sessionTimeoutMiddleware(realReq, res, () => {});
|
||||
|
||||
const decodedReal = jwt.decode(realToken);
|
||||
// Just-recorded → not expired
|
||||
expect(await isSessionExpired(realToken, decodedReal)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when token / decoded is missing (defensive)', async () => {
|
||||
expect(await isSessionExpired(null, { id: 1 })).toBe(false);
|
||||
expect(await isSessionExpired('tok', null)).toBe(false);
|
||||
expect(await isSessionExpired('tok', {})).toBe(false);
|
||||
});
|
||||
|
||||
// Sanity: the helper must not poke the `sessions` Map. Indirectly check
|
||||
// by counting active sessions before/after a call with a never-seen
|
||||
// token — should not change.
|
||||
it('does not mutate the in-memory sessions map', async () => {
|
||||
const before = sessionTimeout.getActiveSessions();
|
||||
await isSessionExpired('never-seen-token-99', makeDecodedToken({ id: 99 }));
|
||||
const after = sessionTimeout.getActiveSessions();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('uses the default 60-minute timeout when no DB setting exists', async () => {
|
||||
// 59 minutes → not expired
|
||||
const fresh = makeDecodedToken({ id: 4, iatSecondsAgo: 59 * 60 });
|
||||
expect(await isSessionExpired('fresh-4', fresh)).toBe(false);
|
||||
|
||||
// 61 minutes → expired (just past the default)
|
||||
const stale = makeDecodedToken({ id: 5, iatSecondsAgo: 61 * 60 });
|
||||
expect(await isSessionExpired('stale-5', stale)).toBe(true);
|
||||
});
|
||||
|
||||
// Document the constant the test relies on so a future timeout change
|
||||
// makes this assertion explicit rather than mysterious.
|
||||
it('default timeout is 60 minutes (constant under test)', () => {
|
||||
expect(SIXTY_MINUTES_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -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,395 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) out[c] = row[c];
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) =>
|
||||
Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
// Mock sessionTimeout's isSessionExpired so each test controls the return.
|
||||
// Default: not expired (so existing tests keep passing without setup).
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({
|
||||
endSession: jest.fn(),
|
||||
isSessionExpired: jest.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
|
||||
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
|
||||
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
|
||||
// The new isSessionExpired helper closes that asymmetry.
|
||||
describe('session-timeout symmetry', () => {
|
||||
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
|
||||
|
||||
beforeEach(() => {
|
||||
isSessionExpired.mockReset();
|
||||
// Default to "active session" so the other admin checks above also
|
||||
// pass when this branch runs.
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(true);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('Session expired');
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token (helper says not expired)', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call isSessionExpired for gallery tokens', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through (treats as valid) if the helper itself throws', async () => {
|
||||
// Defensive: the require() in auth.js is wrapped in try/catch so a
|
||||
// missing/broken helper doesn't fail-closed during early bootstrap.
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockRejectedValue(new Error('boom'));
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,109 @@
|
||||
/**
|
||||
* Unit tests for backgroundProcessor.claimNextPhoto.
|
||||
*
|
||||
* Mocks the db so we don't need a live postgres/sqlite — focuses on
|
||||
* the claim contract: returns null when no rows, returns row + flips
|
||||
* status to 'processing' when one is available, returns null when a
|
||||
* race loses the UPDATE-with-guard.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/services/photoProcessor', () => ({
|
||||
processPhoto: jest.fn(),
|
||||
processUploadedPhotos: jest.fn(),
|
||||
queueFilesForProcessing: jest.fn(),
|
||||
}));
|
||||
|
||||
// Build a fake knex instance whose .transaction() takes a callback we can
|
||||
// drive from the test, and whose query-builder records calls.
|
||||
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
|
||||
const queries = [];
|
||||
|
||||
const builder = () => {
|
||||
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
|
||||
queries.push(recorded);
|
||||
const chain = {
|
||||
where: jest.fn(function (...args) {
|
||||
recorded.wheres.push(args);
|
||||
return chain;
|
||||
}),
|
||||
orderBy: jest.fn(function () {
|
||||
recorded.ordered = true;
|
||||
return chain;
|
||||
}),
|
||||
forUpdate: jest.fn(function () {
|
||||
recorded.locked = true;
|
||||
return chain;
|
||||
}),
|
||||
skipLocked: jest.fn(function () {
|
||||
recorded.skipped = true;
|
||||
return chain;
|
||||
}),
|
||||
first: jest.fn(async function () {
|
||||
// Only the SELECT chain returns the pending row; the UPDATE chain
|
||||
// never calls .first().
|
||||
return pendingRow ? { ...pendingRow } : null;
|
||||
}),
|
||||
update: jest.fn(async function (data) {
|
||||
recorded.updates = data;
|
||||
return updateResult;
|
||||
}),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
const trxFn = (table) => builder(table);
|
||||
trxFn.client = { config: { client: clientName } };
|
||||
trxFn.transaction = async (cb) => cb(trxFn);
|
||||
|
||||
// Top-level db('photos') returns same builder for the janitor test path.
|
||||
const db = trxFn;
|
||||
return { db, queries };
|
||||
}
|
||||
|
||||
describe('backgroundProcessor.claimNextPhoto', () => {
|
||||
function loadProcessor(db) {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
return require('../../src/services/backgroundProcessor');
|
||||
}
|
||||
|
||||
it('returns null when there are no pending photos (postgres path)', async () => {
|
||||
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the claimed row and flips status (postgres path)', async () => {
|
||||
const pendingRow = { id: 42, processing_status: 'pending' };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(true);
|
||||
expect(queries[0].skipped).toBe(true);
|
||||
// The second query is the status update.
|
||||
expect(queries[1].updates.processing_status).toBe('processing');
|
||||
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// SQLite path: no FOR UPDATE / SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(false);
|
||||
expect(queries[0].skipped).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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:00–11: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:00–11: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,75 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.htmlToText.
|
||||
*
|
||||
* Regression: when a template ships without a body_text, sendTemplateEmail
|
||||
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
|
||||
* That regex strips angle-bracket tags but leaves the *contents* of <style>
|
||||
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
|
||||
* embeds a 100+ line <style> block) produced a "plain-text" email starting
|
||||
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToText('')).toBe('');
|
||||
expect(htmlToText(null)).toBe('');
|
||||
expect(htmlToText(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('strips <style> blocks and their contents', () => {
|
||||
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
|
||||
const out = htmlToText(html);
|
||||
expect(out).toBe('Hello');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/color/);
|
||||
});
|
||||
|
||||
it('strips <script> blocks and their contents', () => {
|
||||
const html = '<body><script>alert("x")</script>Hi</body>';
|
||||
expect(htmlToText(html)).toBe('Hi');
|
||||
});
|
||||
|
||||
it('converts <br> tags to newlines', () => {
|
||||
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
|
||||
});
|
||||
|
||||
it('keeps a paragraph break between adjacent <p> tags', () => {
|
||||
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
|
||||
});
|
||||
|
||||
it('decodes the common HTML entities', () => {
|
||||
expect(htmlToText('Tom & Jerry <3 "hi"'))
|
||||
.toBe('Tom & Jerry <3 "hi"');
|
||||
});
|
||||
|
||||
it('handles a fully-wrapped email body without leaking CSS rules', () => {
|
||||
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
|
||||
// CSS rules followed by the actual content.
|
||||
const wrapped = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
|
||||
.email-container { max-width: 600px; }
|
||||
.button { background-color: #5C8762; color: white !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) Natalie,</p>
|
||||
</body>
|
||||
</html>`;
|
||||
const out = htmlToText(wrapped);
|
||||
expect(out).toContain('Galerie erfolgreich erstellt');
|
||||
expect(out).toContain('Liebe(r) Natalie');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/font-family/);
|
||||
expect(out).not.toMatch(/background-color/);
|
||||
expect(out).not.toMatch(/\.button/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.safeTemplateReplace.
|
||||
*
|
||||
* Covers the two regressions that hit picpeak.nothaft.cloud on the
|
||||
* 3.32.x betas:
|
||||
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
|
||||
* because the renderer only handled {{var}} substitution and the
|
||||
* shipped templates use Handlebars-style conditionals.
|
||||
* - {{var}} substitution inside a kept conditional block.
|
||||
*
|
||||
* The publish-from-draft password localisation lives inside the wider
|
||||
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
|
||||
* sentinel string '(set at creation)' is asserted only at the i18n-map
|
||||
* level by integration in adminEvents.js.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('safeTemplateReplace', () => {
|
||||
describe('flat variable substitution', () => {
|
||||
it('replaces {{var}} with the variable value', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
|
||||
.toBe('Hello Paul!');
|
||||
});
|
||||
|
||||
it('leaves unknown variables untouched', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', {}))
|
||||
.toBe('Hello {{name}}!');
|
||||
});
|
||||
|
||||
it('coerces non-string values to string', () => {
|
||||
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
|
||||
.toBe('Count: 42');
|
||||
});
|
||||
|
||||
it('handles empty templates and missing variables map', () => {
|
||||
expect(safeTemplateReplace('', { x: 1 })).toBe('');
|
||||
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
|
||||
expect(safeTemplateReplace(null, {})).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('{{#if VAR}}…{{/if}} blocks', () => {
|
||||
it('strips the block when the variable is missing', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, {})).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is an empty string', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is null', () => {
|
||||
const tpl = '{{#if x}}kept{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the block and substitutes inside it when truthy', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
|
||||
.toBe('before HELLO world after');
|
||||
});
|
||||
|
||||
it('handles multi-line conditional blocks', () => {
|
||||
const tpl = [
|
||||
'Liebe(r) {{host_name}},',
|
||||
'',
|
||||
'{{#if welcome_message}}',
|
||||
'Persönliche Nachricht:',
|
||||
'{{welcome_message}}',
|
||||
'{{/if}}',
|
||||
'Galerie-Details:',
|
||||
].join('\n');
|
||||
|
||||
const withMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: 'Schön, dass ihr da seid!',
|
||||
});
|
||||
expect(withMsg).toContain('Persönliche Nachricht:');
|
||||
expect(withMsg).toContain('Schön, dass ihr da seid!');
|
||||
expect(withMsg).not.toContain('{{#if');
|
||||
expect(withMsg).not.toContain('{{/if');
|
||||
|
||||
const withoutMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: '',
|
||||
});
|
||||
expect(withoutMsg).not.toContain('Persönliche Nachricht');
|
||||
expect(withoutMsg).not.toContain('{{#if');
|
||||
expect(withoutMsg).not.toContain('{{/if');
|
||||
expect(withoutMsg).toContain('Liebe(r) Natalie,');
|
||||
expect(withoutMsg).toContain('Galerie-Details:');
|
||||
});
|
||||
|
||||
it('handles multiple sibling conditionals independently', () => {
|
||||
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
|
||||
});
|
||||
|
||||
it('treats numeric 0 as falsy', () => {
|
||||
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML escaping (escapeHtml: true)', () => {
|
||||
it('does not escape by default', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
|
||||
.toBe('Welcome to Test <script>');
|
||||
});
|
||||
|
||||
it('escapes admin-supplied values when opted in', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
|
||||
.toBe('Welcome to Test <script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('escapes both the < > and & characters and quotes', () => {
|
||||
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
|
||||
.toBe('<a href="evil">A & B's</a>');
|
||||
});
|
||||
|
||||
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
|
||||
const tpl = '<p>{{welcome_message}}</p>';
|
||||
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
|
||||
.toBe('<p>Hi<br />there</p>');
|
||||
});
|
||||
|
||||
it('passes server-generated URLs through unescaped', () => {
|
||||
const tpl = '<a href="{{gallery_link}}">link</a>';
|
||||
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
|
||||
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,320 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Silence the logger so test output stays clean. Capture calls so the
|
||||
// "warning logged" assertions can still verify behaviour.
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
}));
|
||||
|
||||
const logger = require('../../src/utils/logger');
|
||||
// Required ONCE at module top so the jest.mock factory above applies to
|
||||
// the logger reference that fontsService captures. A previous version
|
||||
// re-required it inside beforeEach() with jest.resetModules() — that
|
||||
// silently bypassed the mock (logger calls went to the real logger),
|
||||
// so the "warning logged" assertions would resolve as 0 calls and
|
||||
// silently pass-as-noop. Module-level state in fontsService is just
|
||||
// the cache, which clearFontsCache() resets between tests.
|
||||
const fontsService = require('../../src/services/fontsService');
|
||||
|
||||
// Probe at load time: is the host filesystem case-sensitive?
|
||||
// macOS APFS and Windows NTFS treat "Inter" and "INTER" as the same
|
||||
// directory entry, which means the "two folders, same lowercase key"
|
||||
// dedup test below can't be set up via real folders on those platforms —
|
||||
// the second mkdir is a no-op. Skip that one test conditionally.
|
||||
const FS_IS_CASE_SENSITIVE = (() => {
|
||||
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fs-probe-'));
|
||||
fs.writeFileSync(path.join(probeDir, 'casetest'), '');
|
||||
let sensitive = true;
|
||||
try {
|
||||
fs.accessSync(path.join(probeDir, 'CASETEST'));
|
||||
sensitive = false;
|
||||
} catch { /* file not found → case-sensitive FS */ }
|
||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||
return sensitive;
|
||||
})();
|
||||
const testCaseSensitiveFS = FS_IS_CASE_SENSITIVE ? test : test.skip;
|
||||
|
||||
let bundledRoot;
|
||||
let userRoot;
|
||||
|
||||
/**
|
||||
* Create a font family folder with the given weights (and optional meta.json).
|
||||
* @param {string} root absolute path to the bundled or user root
|
||||
* @param {string} folderName e.g. "Inter" or "Playfair-Display"
|
||||
* @param {Array<number>|Array<string>} weights numeric weights (creates `<w>.woff2`)
|
||||
* or filenames to create directly
|
||||
* @param {Object|null} meta optional meta.json contents (object) or null
|
||||
*/
|
||||
async function makeFamily(root, folderName, weights, meta = null) {
|
||||
const dir = path.join(root, folderName);
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
for (const w of weights) {
|
||||
const fname = typeof w === 'number' ? `${w}.woff2` : w;
|
||||
await fsPromises.writeFile(path.join(dir, fname), Buffer.from([]));
|
||||
}
|
||||
if (meta !== null) {
|
||||
await fsPromises.writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
typeof meta === 'string' ? meta : JSON.stringify(meta)
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
bundledRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-bundled-'));
|
||||
userRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-user-'));
|
||||
|
||||
process.env.PICPEAK_BUNDLED_FONTS_ROOT = bundledRoot;
|
||||
// The user root resolves under STORAGE_PATH/fonts, so STORAGE_PATH must
|
||||
// point at the parent of userRoot — we name the leaf "fonts" ourselves.
|
||||
const storageParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-storage-'));
|
||||
await fsPromises.rename(userRoot, path.join(storageParent, 'fonts'));
|
||||
userRoot = path.join(storageParent, 'fonts');
|
||||
process.env.STORAGE_PATH = storageParent;
|
||||
|
||||
// Reset the module-level cache so each test sees a fresh scan.
|
||||
// (Both getBundledFontsRoot and getUserFontsRoot read process.env at
|
||||
// call-time, so the env vars set above are picked up without needing
|
||||
// to re-require the module — see fontsService.js getBundledFontsRoot /
|
||||
// getUserFontsRoot.)
|
||||
fontsService.clearFontsCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fontsService.clearFontsCache();
|
||||
await fsPromises.rm(bundledRoot, { recursive: true, force: true }).catch(() => {});
|
||||
// userRoot's parent is the actual mkdtemp; remove it.
|
||||
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.PICPEAK_BUNDLED_FONTS_ROOT;
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
describe('fontsService.listFonts', () => {
|
||||
describe('roots', () => {
|
||||
test('empty bundled root + missing user root → []', async () => {
|
||||
// delete user root so it triggers ENOENT
|
||||
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true });
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing bundled root (ENOENT) → [], does not throw', async () => {
|
||||
await fsPromises.rm(bundledRoot, { recursive: true, force: true });
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toEqual([]);
|
||||
});
|
||||
|
||||
test('non-directory entries at the root are skipped', async () => {
|
||||
await fsPromises.writeFile(path.join(bundledRoot, 'README.md'), 'hi');
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 700]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
});
|
||||
|
||||
test('hidden folders are skipped', async () => {
|
||||
await makeFamily(bundledRoot, '.git', [400]);
|
||||
await makeFamily(bundledRoot, '.DS_Store', [400]);
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weight parsing', () => {
|
||||
test('three weight files → sorted ascending', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [700, 400, 600]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 600, 700]);
|
||||
});
|
||||
|
||||
test('non-numeric filenames are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', ['bold.woff2', 'regular.woff2', '400.woff2', '700.woff2']);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 700]);
|
||||
});
|
||||
|
||||
test('non-.woff2 files are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', ['400.ttf', '400.woff', '400.woff2', '700.otf']);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400]);
|
||||
});
|
||||
|
||||
test('weight values out of range (sub-1 / over-1000) are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [0, 400, 1001, 700]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 700]);
|
||||
});
|
||||
|
||||
test('family folder with no usable .woff2 files is silently skipped', async () => {
|
||||
await makeFamily(bundledRoot, 'NoWeights', ['readme.txt', 'bold.ttf']);
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipping NoWeights')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder name → display family', () => {
|
||||
test('hyphens become spaces', async () => {
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400]);
|
||||
const [pd] = await fontsService.listFonts();
|
||||
expect(pd.family).toBe('Playfair Display');
|
||||
});
|
||||
|
||||
test('case is preserved', async () => {
|
||||
await makeFamily(bundledRoot, 'IBM-Plex-Sans', [400]);
|
||||
const [ibm] = await fontsService.listFonts();
|
||||
expect(ibm.family).toBe('IBM Plex Sans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user-overrides-bundled', () => {
|
||||
test('user folder of the same family wins; weights come from user', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 600, 700]);
|
||||
await makeFamily(userRoot, 'Inter', [400, 900]); // different weights
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 900]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('overrides bundled default')
|
||||
);
|
||||
});
|
||||
|
||||
test('user-only family is included', async () => {
|
||||
await makeFamily(userRoot, 'Lobster', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Lobster']);
|
||||
});
|
||||
|
||||
testCaseSensitiveFS('case-insensitive duplicate within the same root → second skipped, warning', async () => {
|
||||
// Two folder names whose lowercase keys collide. On a case-sensitive
|
||||
// FS (Linux ext4) we can create both `Inter/` and `INTER/`; on a
|
||||
// case-insensitive FS (macOS APFS, Windows NTFS) the second mkdir
|
||||
// resolves to the same directory as the first and the dedup branch
|
||||
// is unreachable from this test setup — see testCaseSensitiveFS above.
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
await makeFamily(bundledRoot, 'INTER', [700]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toHaveLength(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Duplicate family')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta.json — generic fallback', () => {
|
||||
test('valid generic="serif"', async () => {
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
|
||||
const [pd] = await fontsService.listFonts();
|
||||
expect(pd.generic).toBe('serif');
|
||||
});
|
||||
|
||||
test('valid generic="cursive"', async () => {
|
||||
await makeFamily(bundledRoot, 'Comic-Neue', [400], { generic: 'cursive' });
|
||||
const [cn] = await fontsService.listFonts();
|
||||
expect(cn.generic).toBe('cursive');
|
||||
});
|
||||
|
||||
test('valid generic="monospace"', async () => {
|
||||
await makeFamily(bundledRoot, 'Fira-Mono', [400], { generic: 'monospace' });
|
||||
const [fm] = await fontsService.listFonts();
|
||||
expect(fm.generic).toBe('monospace');
|
||||
});
|
||||
|
||||
test('missing meta.json → defaults to sans-serif (no warning)', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
// No warning for the missing-file case (it's the normal path).
|
||||
const noisy = (logger.warn.mock.calls || []).filter((c) =>
|
||||
String(c[0]).includes('meta.json')
|
||||
);
|
||||
expect(noisy).toEqual([]);
|
||||
});
|
||||
|
||||
test('invalid generic value → defaults to sans-serif, warning logged', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400], { generic: 'bogus' });
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('invalid generic "bogus"')
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed JSON → defaults to sans-serif, warning logged', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400], '{ this is not json');
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not valid JSON')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('result shape', () => {
|
||||
test('every family is { family, weights, generic }', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 700]);
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
|
||||
const fonts = await fontsService.listFonts();
|
||||
for (const f of fonts) {
|
||||
expect(f).toEqual({
|
||||
family: expect.any(String),
|
||||
weights: expect.any(Array),
|
||||
generic: expect.stringMatching(/^(sans-serif|serif|cursive|monospace)$/)
|
||||
});
|
||||
expect(f.weights.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('output sorted alphabetically by family', async () => {
|
||||
await makeFamily(bundledRoot, 'Zilla-Slab', [400]);
|
||||
await makeFamily(bundledRoot, 'Alpha-Sans', [400]);
|
||||
await makeFamily(bundledRoot, 'Mid-Pack', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual([
|
||||
'Alpha Sans',
|
||||
'Mid Pack',
|
||||
'Zilla Slab'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache', () => {
|
||||
test('cache hit: second call within TTL does not re-readdir', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const spy = jest.spyOn(fsPromises, 'readdir');
|
||||
await fontsService.listFonts();
|
||||
const callsAfterFirst = spy.mock.calls.length;
|
||||
await fontsService.listFonts();
|
||||
expect(spy.mock.calls.length).toBe(callsAfterFirst);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('clearFontsCache forces a fresh scan on the next call', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
await fontsService.listFonts();
|
||||
|
||||
// Add a new family AFTER the cache was populated.
|
||||
await makeFamily(bundledRoot, 'Roboto', [400]);
|
||||
|
||||
// Without clearing, listFonts returns the stale cache.
|
||||
const stale = await fontsService.listFonts();
|
||||
expect(stale.map((f) => f.family)).toEqual(['Inter']);
|
||||
|
||||
// After clear, the new family appears.
|
||||
fontsService.clearFontsCache();
|
||||
const fresh = await fontsService.listFonts();
|
||||
expect(fresh.map((f) => f.family)).toEqual(['Inter', 'Roboto']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,216 @@
|
||||
/**
|
||||
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
|
||||
* point that runs after a row has been claimed by the background
|
||||
* processor. Mocks every external dependency and validates the
|
||||
* happy-path DB updates and side-effect ordering.
|
||||
*
|
||||
* jest.mock factories are evaluated before any local variables exist,
|
||||
* so collaborators are kept inside the mock factories themselves and
|
||||
* the test reaches into them via require() once they're set up.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const recorded = { whereCalls: [], updateCalls: [] };
|
||||
let pendingWhere = null;
|
||||
const photosState = { row: null };
|
||||
const eventsState = { row: null };
|
||||
|
||||
function makePhotoQuery() {
|
||||
return {
|
||||
where(args) {
|
||||
pendingWhere = args;
|
||||
recorded.whereCalls.push(args);
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return photosState.row;
|
||||
},
|
||||
async update(data) {
|
||||
recorded.updateCalls.push({ where: pendingWhere, data });
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEventsQuery() {
|
||||
return {
|
||||
where() {
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return eventsState.row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dbFn(table) {
|
||||
if (table === 'photos') return makePhotoQuery();
|
||||
if (table === 'events') return makeEventsQuery();
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
dbFn.client = { config: { client: 'pg' } };
|
||||
|
||||
return {
|
||||
db: dbFn,
|
||||
__setPhoto: (row) => { photosState.row = row; },
|
||||
__setEvent: (row) => { eventsState.row = row; },
|
||||
__reset: () => {
|
||||
recorded.whereCalls = [];
|
||||
recorded.updateCalls = [];
|
||||
photosState.row = null;
|
||||
eventsState.row = null;
|
||||
},
|
||||
__recorded: () => recorded,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => {
|
||||
const mockGenerateThumbnail = jest.fn();
|
||||
const mockExtractCaptureDate = jest.fn();
|
||||
return {
|
||||
generateThumbnail: mockGenerateThumbnail,
|
||||
extractCaptureDate: mockExtractCaptureDate,
|
||||
withLocalCopy: jest.fn(async (key, fn) =>
|
||||
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/videoProcessor', () => ({
|
||||
processUploadedVideo: jest.fn(),
|
||||
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
|
||||
|
||||
jest.mock('../../src/services/photoResolver', () => ({
|
||||
resolvePhotoStorageKey: jest.fn(
|
||||
(event, photo) => `events/active/${event.slug}/${photo.filename}`
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/filenameSanitizer', () => ({
|
||||
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/watermarkGeneratorService', () => ({
|
||||
generateForPhoto: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/webhookService', () => ({
|
||||
fire: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub sharp so we don't actually read any image off disk.
|
||||
jest.mock('sharp', () => {
|
||||
const mock = jest.fn(() => ({
|
||||
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
|
||||
}));
|
||||
return mock;
|
||||
});
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
const imageProcessor = require('../../src/services/imageProcessor');
|
||||
const videoProcessor = require('../../src/services/videoProcessor');
|
||||
const watermarkService = require('../../src/services/watermarkGeneratorService');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
|
||||
beforeEach(() => {
|
||||
dbModule.__reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('photoProcessor.processPhoto', () => {
|
||||
it('marks an image complete with thumbnail and dimensions', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 101,
|
||||
event_id: 5,
|
||||
filename: 'wedding-001.jpg',
|
||||
original_filename: 'IMG_0001.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
media_type: 'image',
|
||||
size_bytes: 12345,
|
||||
captured_at: null,
|
||||
processing_status: 'processing',
|
||||
});
|
||||
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
|
||||
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(101);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
expect(finalUpdate.data.processing_error).toBeNull();
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
|
||||
expect(finalUpdate.data.width).toBe(1920);
|
||||
expect(finalUpdate.data.height).toBe(1080);
|
||||
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
|
||||
|
||||
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
|
||||
expect(webhookService.fire).toHaveBeenCalledWith(
|
||||
'photo.uploaded',
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({ slug: 'wedding' }),
|
||||
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles videos with ffmpeg metadata path', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 202,
|
||||
event_id: 9,
|
||||
filename: 'wedding-video-001.mp4',
|
||||
original_filename: 'movie.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
media_type: 'video',
|
||||
size_bytes: 99999,
|
||||
captured_at: null,
|
||||
});
|
||||
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
|
||||
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
|
||||
metadata: {
|
||||
duration: 12.5,
|
||||
videoCodec: 'h264',
|
||||
audioCodec: 'aac',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
});
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(202);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
expect(finalUpdate.data.duration).toBe(12.5);
|
||||
expect(finalUpdate.data.video_codec).toBe('h264');
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
|
||||
|
||||
// Watermark queue is image-only.
|
||||
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the photo row no longer exists', async () => {
|
||||
dbModule.__setPhoto(null);
|
||||
dbModule.__setEvent({ id: 1 });
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
|
||||
});
|
||||
});
|
||||
|
||||
void path; // referenced indirectly via mocks
|
||||
@@ -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';
|
||||
|
||||
// Mon–Fri 09:00–18: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': [],
|
||||
};
|
||||
|
||||
// Mon–Fri with a lunch break (09:00–12:00, 13:00–18: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 (Mon–Fri 09:00–18: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:00–12:00, 13:00–18: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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user