Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e94e440858 |
+30
-14
@@ -4,8 +4,11 @@
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker: the secrets-init service writes it to a private volume and reuses it
|
||||
# across restarts). Set it explicitly only to pin your own value.
|
||||
# Generate one with: openssl rand -base64 64
|
||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
@@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
|
||||
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||
DB_PASSWORD=your_secure_postgres_password_here
|
||||
#DB_PASSWORD=your_secure_postgres_password_here
|
||||
DB_NAME=picpeak_prod
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
REDIS_PASSWORD=your_secure_redis_password_here
|
||||
#REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
# Admin Account (initial setup) — OPTIONAL
|
||||
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
||||
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
||||
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
|
||||
# and saved to data/SETUP_TOKEN.
|
||||
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
|
||||
# credentials written to data/ADMIN_CREDENTIALS.txt).
|
||||
#ADMIN_USERNAME=admin
|
||||
#ADMIN_EMAIL=admin@yourdomain.com
|
||||
#ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration
|
||||
# For Gmail: use app-specific password
|
||||
@@ -68,6 +80,16 @@ EMAIL_FROM=noreply@yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Static HTML title + description used for social link previews when the
|
||||
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
||||
# WhatsApp Business API and various 3rd-party preview-service caches
|
||||
# (#521). Set these to your brand so link previews aren't generic.
|
||||
# Substituted into index.html at frontend-container start, so changes
|
||||
# take effect on the next `docker compose up -d frontend` — no rebuild
|
||||
# required.
|
||||
BRAND_TITLE=PicPeak
|
||||
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# This must be the publicly accessible URL where email recipients can load images.
|
||||
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
|
||||
@@ -97,12 +119,6 @@ UPDATE_CHECK_ENABLED=true
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Runtime user mapping for Docker (optional)
|
||||
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
|
||||
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
|
||||
PUID=1001
|
||||
PGID=1001
|
||||
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/the-luap/picpeak/discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
@@ -9,7 +9,7 @@ assignees: ''
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
# This workflow is triggered by:
|
||||
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
|
||||
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
|
||||
# builds; stable → ':stable' + ':latest' for the curated channel)
|
||||
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
|
||||
# - GitHub Releases (created by Release Please)
|
||||
# - Pull requests (build verification only, no push by default)
|
||||
@@ -20,10 +21,10 @@ name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, beta ]
|
||||
branches: [ main, stable ]
|
||||
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
|
||||
pull_request:
|
||||
branches: [ main, beta ]
|
||||
branches: [ main, stable ]
|
||||
release:
|
||||
types: [ published ] # Triggered when Release Please creates a release
|
||||
workflow_dispatch:
|
||||
@@ -37,6 +38,16 @@ on:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
# Once release-please authors releases with a PAT (#719), a new version fires
|
||||
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
|
||||
# suppress them). They build the same immutable version, so collapse them into a
|
||||
# single run by grouping on the ref. Branch and PR builds use different refs and
|
||||
# still run independently; a superseding push cancels an in-flight run for the
|
||||
# same ref (only the newest build per ref is kept).
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
|
||||
@@ -69,6 +80,11 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Trivy uploads its SARIF to the Security tab from this job — see
|
||||
# the "Run Trivy" step below. Scanning per-arch by digest (#476)
|
||||
# is reliable; scanning the multi-arch index by tag from the
|
||||
# merge-* job was not.
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -129,7 +145,10 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
# ignore-error: a flaky GitHub Actions cache write ("error writing
|
||||
# layer blob: not_found") must not fail an otherwise-successful build
|
||||
# that already pushed the image.
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
@@ -153,13 +172,58 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Per-arch vulnerability scan (#476). Scanning the multi-arch
|
||||
# manifest from the merge-* job by tag is unreliable — Trivy's
|
||||
# remote resolver crashes intermittently with "no child with
|
||||
# platform linux/amd64 in index". The fix is to scan each leg
|
||||
# by its single-platform digest right here, where it just landed
|
||||
# in GHCR. Tag pinned (was @master) so the action + bundled
|
||||
# Trivy binary don't float between runs.
|
||||
#
|
||||
# exit-code is left unset (=0) for now: Trivy reports findings
|
||||
# to the Security tab but doesn't fail the build. Flipping that
|
||||
# to '1' to actually gate CI is a deliberate follow-up — needs an
|
||||
# audit pass first so the next beta build doesn't surprise red.
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
env:
|
||||
# docker/build-push-action wraps every push in an OCI index
|
||||
# (carries the SLSA provenance attestation alongside the
|
||||
# actual image). Trivy's remote backend defaults to
|
||||
# linux/amd64 regardless of host arch when resolving an
|
||||
# index, which makes the arm64 leg crash with "no child
|
||||
# with platform linux/amd64". Telling Trivy which child to
|
||||
# scan keeps the provenance attestation intact and fixes
|
||||
# the resolver crash. Pin to matrix.platform so each leg
|
||||
# scans its own arch.
|
||||
TRIVY_PLATFORM: ${{ matrix.platform }}
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
# Distinct category per arch so the Security tab surfaces
|
||||
# per-platform findings independently — an amd64-only CVE in
|
||||
# a base layer doesn't get masked by the arm64 scan.
|
||||
category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||||
|
||||
merge-backend:
|
||||
needs: build-backend
|
||||
runs-on: ubuntu-latest
|
||||
# No security-events permission here — vulnerability scanning moved
|
||||
# to per-arch build-backend jobs (#476). This job's only job is to
|
||||
# combine the per-arch digests into a multi-arch manifest.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
@@ -192,7 +256,9 @@ jobs:
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
|
||||
# Active-dev branch (`main`, renamed from `beta` per #669) produces
|
||||
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -217,9 +283,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
# so `is_default_branch` no longer maps to "stable" — be explicit.
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
|
||||
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
|
||||
# tag remains frozen at its last build — operators should update.
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
@@ -231,23 +302,6 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Frontend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -264,6 +318,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# See build-backend for the rationale (#476). Same pattern: per-arch
|
||||
# vulnerability scan by digest, SARIF uploaded to the Security tab.
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -324,7 +381,10 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
# ignore-error: a flaky GitHub Actions cache write ("error writing
|
||||
# layer blob: not_found") must not fail an otherwise-successful build
|
||||
# that already pushed the image.
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
@@ -348,13 +408,40 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Per-arch vulnerability scan (#476). See build-backend for the
|
||||
# full rationale; identical pattern here, only the image-ref +
|
||||
# SARIF filename + category change.
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
env:
|
||||
# See build-backend for the rationale — pin Trivy's platform
|
||||
# to the matrix arch so its remote-index resolver picks the
|
||||
# right child instead of defaulting to linux/amd64 and
|
||||
# crashing on the arm64 leg.
|
||||
TRIVY_PLATFORM: ${{ matrix.platform }}
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||||
|
||||
merge-frontend:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
# See merge-backend — vulnerability scanning moved to the per-arch
|
||||
# build-frontend matrix (#476). This job only publishes the manifest.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
steps:
|
||||
@@ -386,7 +473,9 @@ jobs:
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
|
||||
# Active-dev branch (`main`, renamed from `beta` per #669) produces
|
||||
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -411,9 +500,14 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
# so `is_default_branch` no longer maps to "stable" — be explicit.
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
|
||||
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
|
||||
# tag remains frozen at its last build — operators should update.
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
@@ -425,23 +519,6 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
summary:
|
||||
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
|
||||
if: always()
|
||||
|
||||
@@ -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")"
|
||||
@@ -129,3 +129,6 @@ docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.42.5-beta.0"
|
||||
".": "3.82.4-beta.0"
|
||||
}
|
||||
|
||||
+1060
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,5 +1,13 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.**
|
||||
>
|
||||
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
|
||||
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
|
||||
>
|
||||
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
@@ -49,6 +57,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
@@ -68,40 +77,57 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
### For Studios — CRM & Accounting (Beta · off by default)
|
||||
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
|
||||
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
|
||||
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
|
||||
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
|
||||
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
|
||||
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
git clone https://github.com/PicPeak/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
# Copy the environment template — the defaults work out of the box.
|
||||
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
|
||||
# admin account is created in the browser (see below). Edit .env only to
|
||||
# customise (domain, SMTP, storage paths, …) — nothing is required.
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a 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
|
||||
@@ -150,7 +176,8 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
|
||||
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
|
||||
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
|
||||
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
|
||||
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
|
||||
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
|
||||
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
|
||||
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
|
||||
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
|
||||
|
||||
@@ -181,6 +208,7 @@ Perfect for:
|
||||
- 📸 **Portrait Studios** - Client galleries with download limits
|
||||
- 🏢 **Corporate Events** - Internal photo sharing with branding
|
||||
- 🎓 **School Photography** - Secure parent access with expiration
|
||||
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
@@ -295,7 +323,12 @@ For local development with a receiver on the same machine or docker network, set
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 CPU cores
|
||||
- **RAM**: 2GB minimum
|
||||
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
|
||||
decodes the full uncompressed frame before resize, and the default two
|
||||
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
|
||||
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
|
||||
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
|
||||
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
|
||||
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||
- **Node.js**: v18.0.0 or higher
|
||||
@@ -305,6 +338,26 @@ For local development with a receiver on the same machine or docker network, set
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Low-memory hosts
|
||||
|
||||
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
|
||||
tuning the upload-processor concurrency down. The backend auto-detects
|
||||
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
|
||||
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
|
||||
a one-shot warning. You can pin the value explicitly in `.env`:
|
||||
|
||||
```env
|
||||
# Single worker loop — slower batch processing, lower peak RSS
|
||||
UPLOAD_PROCESSOR_CONCURRENCY=1
|
||||
```
|
||||
|
||||
The trade-off is throughput: a single worker processes one photo at a
|
||||
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
|
||||
note**: if the backend dies under memory pressure, the gallery serves
|
||||
`503 Service Unavailable` on thumbnails until Docker's
|
||||
`restart: unless-stopped` brings the container back. Persistent 503s
|
||||
during/after an upload batch on a low-memory host are almost always this.
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
@@ -338,17 +391,23 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|
||||
|---------|---------|---------|--------------|----------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
|
||||
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
|
||||
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 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
|
||||
|
||||
@@ -360,7 +419,7 @@ PicPeak takes security seriously:
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
@@ -405,6 +464,7 @@ These features are currently in beta testing and may have limited functionality
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
@@ -443,7 +503,8 @@ PicPeak is inspired by the best features of commercial platforms while remaining
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
|
||||
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
@@ -458,6 +519,34 @@ This project was generated with the assistance of AI technology, but has been:
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
|
||||
|
||||
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
|
||||
report and the accountant exports) ship seeded content and computed
|
||||
figures that are intended as a **starting point only**:
|
||||
|
||||
- **Contract blocks** (image rights, NDA, model release, cancellation,
|
||||
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
|
||||
Every operator must have their lawyer review and adapt them before
|
||||
sending any contract to a customer.
|
||||
- **QR-bills and SEPA EPC payloads** are rendered from the data you
|
||||
typed. Picpeak is open source — please scan a test invoice with your
|
||||
bank's app to check the QR actually works. We are not responsible for
|
||||
any mistakes that come from sending an invoice with bad data on it.
|
||||
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
|
||||
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
|
||||
from the data you enter and the defaults you configure. They are
|
||||
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
|
||||
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
|
||||
rate) and filing duties differ by country and change over time. **Every
|
||||
operator must check their own tax / VAT regulations and verify the
|
||||
numbers with their accountant / Treuhänder / tax authority before
|
||||
relying on any figure or export.** Picpeak makes no warranty that the
|
||||
output is correct for your jurisdiction or situation.
|
||||
|
||||
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
|
||||
enabling the Contracts, Invoices or Accounting features.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
@@ -476,7 +565,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
|
||||
<a href="https://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
|
||||
@@ -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.
|
||||
+3
-3
@@ -16,7 +16,7 @@ We take the security of PicPeak seriously. If you have discovered a security vul
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
+16
-3
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
@@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \
|
||||
- `picpeak-workers` - Background workers
|
||||
- `caddy` - Web server (optional)
|
||||
|
||||
## 🔑 First Login — Create Your Admin
|
||||
|
||||
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
|
||||
|
||||
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
|
||||
|
||||
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
|
||||
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
@@ -472,7 +485,7 @@ sudo -u picpeak node scripts/reset-admin-password.js
|
||||
- [Deployment Guide](https://docs.picpeak.app/deployment)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
@@ -550,4 +563,4 @@ sudo ./picpeak-setup.sh --native \
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
|
||||
|
||||
+32
-9
@@ -9,19 +9,42 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
# login appears to succeed but the browser silently drops the
|
||||
# cookie, leaving you in a redirect loop. Only set this if you
|
||||
# ALWAYS reach the site via HTTPS)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
|
||||
# req.secure from Express which respects X-Forwarded-Proto from a
|
||||
# trusted reverse proxy. This is the default and is the right
|
||||
# choice for most deployments.
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
# Why 'auto' is the default in production:
|
||||
# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
|
||||
# true → Secure flag is still emitted. No security regression vs. true.
|
||||
# - On plain HTTP (LAN access, first-time install before reverse proxy is
|
||||
# wired up), req.secure is false → Secure flag is omitted → login works
|
||||
# instead of silently looping back to /admin/login.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# When you'd set this explicitly:
|
||||
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
|
||||
# defense in depth against accidentally serving over HTTP.
|
||||
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
|
||||
# don't want the per-request check (rare).
|
||||
#
|
||||
# Requirements for 'auto' mode to detect HTTPS correctly:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
|
||||
+40
-8
@@ -7,7 +7,7 @@ ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
@@ -30,17 +30,39 @@ WORKDIR /app
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
|
||||
RUN npm install -g npm@10
|
||||
# Upgrade the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
|
||||
# dependencies come from the builder stage (COPY --from=builder node_modules
|
||||
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
|
||||
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
|
||||
# Node >=22.9, satisfied by node:22-alpine.
|
||||
RUN npm install -g npm@11
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
|
||||
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
# privilege drop in wait-for-db.sh (see #484: container starts as root so it
|
||||
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
|
||||
# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
|
||||
# `ffprobe` built natively against musl libc — the npm
|
||||
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
|
||||
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
|
||||
# pipeline calls via fluent-ffmpeg.ffprobe()).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg
|
||||
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
|
||||
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
|
||||
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
|
||||
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
|
||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
||||
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||
# logo's text renders in its actual typeface, not a fallback.
|
||||
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
|
||||
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
|
||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||
# documents (see docs/accounting-inbound-invoices.md).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
|
||||
fc-cache -f
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
@@ -52,11 +74,21 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-coverage.
|
||||
*
|
||||
* Pins the Stage C diagnostic that tells admins what the next
|
||||
* "Run Backup Now" will include, skip, or silently miss.
|
||||
*
|
||||
* Test surface:
|
||||
* 1. Empty / fresh install → default seed (7 paths), inline mode,
|
||||
* no DB dump on file yet, no drift
|
||||
* 2. Toggle `include_in_default=false` → coverage flips to
|
||||
* 'skipped-by-toggle'
|
||||
* 3. Feature_flag gating reflects the actual app_settings value
|
||||
* (events/archived ⇄ backup_include_archived)
|
||||
* 4. Drift detection: a top-level subdir on disk with no
|
||||
* `backup_paths` row is flagged in `unconfiguredOnDisk`
|
||||
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
|
||||
* 6. Scheduled-only mode + recent dump → `database.ok = true`
|
||||
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
|
||||
* and `lastDumpStale = true`
|
||||
*
|
||||
* Same auth/permission pass-through strategy as
|
||||
* adminBackupIntegrity.test.js — we exercise the route's logic,
|
||||
* not the auth middleware.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function mkdir(rel) {
|
||||
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
|
||||
}
|
||||
|
||||
function rmdir(rel) {
|
||||
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function restoreDefaultPaths() {
|
||||
await db('backup_paths').del();
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await restoreDefaultPaths();
|
||||
await db('database_backup_runs').del().catch(() => {});
|
||||
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns the canonical 7 paths + database block on a fresh install', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
|
||||
const { report } = res.body;
|
||||
expect(report.paths.map((p) => p.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
|
||||
// Default mode is inline — no inline_dump setting present means
|
||||
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
|
||||
expect(report.database.mode).toBe('inline');
|
||||
expect(report.database.ok).toBe(true);
|
||||
|
||||
expect(report.summary).toMatchObject({
|
||||
configuredCount: 7,
|
||||
tableMissingFallbackInUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
|
||||
expect(thumbnails.coverage).toBe('skipped-by-toggle');
|
||||
expect(thumbnails.includeInDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
|
||||
// backup_include_archived not set → archived skipped via flag
|
||||
const off = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
|
||||
expect(archivedOff.featureFlag).toBe('backup_include_archived');
|
||||
expect(archivedOff.featureFlagValue).toBe(null); // unset
|
||||
|
||||
// Now set the flag — but path is missing on disk, so coverage
|
||||
// resolves to 'missing-on-disk', proving the flag was honoured.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_include_archived',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const on = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOn.featureFlagValue).toBe(true);
|
||||
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
|
||||
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
|
||||
});
|
||||
|
||||
it('detects unconfigured top-level subdirs as drift', async () => {
|
||||
mkdir('events/active'); // configured
|
||||
mkdir('plugin-store/cache'); // DRIFT
|
||||
mkdir('shiny-new-feature/data'); // DRIFT
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
|
||||
'plugin-store',
|
||||
'shiny-new-feature',
|
||||
]));
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
|
||||
|
||||
rmdir('plugin-store');
|
||||
rmdir('shiny-new-feature');
|
||||
});
|
||||
|
||||
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
|
||||
mkdir('backups');
|
||||
mkdir('tmp');
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
|
||||
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
|
||||
expect.arrayContaining(['backups', 'tmp']),
|
||||
);
|
||||
|
||||
rmdir('backups');
|
||||
rmdir('tmp');
|
||||
});
|
||||
|
||||
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
|
||||
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
|
||||
fs.writeFileSync(recentDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(), // just now
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: recentDump,
|
||||
file_size_bytes: fs.statSync(recentDump).size,
|
||||
destination_path: recentDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.mode).toBe('scheduled-only');
|
||||
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
|
||||
expect(res.body.report.database.lastDumpStale).toBe(false);
|
||||
expect(res.body.report.database.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
|
||||
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
|
||||
fs.writeFileSync(oldDump, 'pretend old dump');
|
||||
// 48 hours ago — well past the 26h staleness threshold. ISO
|
||||
// string instead of a Date object because knex-sqlite's datetime
|
||||
// serialisation has a quirk where some Date instances coerce to
|
||||
// '[object Object]' on insert (the test 6 "recent dump" case
|
||||
// passes only because `new Date()` happens to round-trip safely;
|
||||
// arithmetic Dates don't).
|
||||
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: stale,
|
||||
completed_at: stale,
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: oldDump,
|
||||
file_size_bytes: fs.statSync(oldDump).size,
|
||||
destination_path: oldDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.lastDumpStale).toBe(true);
|
||||
expect(res.body.report.database.ok).toBe(false);
|
||||
// Top-level summary reflects the failed DB check.
|
||||
expect(res.body.report.summary.databaseOk).toBe(false);
|
||||
expect(res.body.report.summary.overallOk).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-integrity.
|
||||
*
|
||||
* Auth + permission middleware are mocked to pass-through so the test
|
||||
* focuses on the route's own behaviour: scope-param validation, the
|
||||
* successResponse envelope, and that the underlying service report
|
||||
* surfaces correctly in the JSON body.
|
||||
*
|
||||
* The verifier service itself is exercised against the real schema
|
||||
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Pass-through auth so we don't need to mint JWTs.
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Pass-through permissions so settings.view always allows.
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
let db;
|
||||
let customerId;
|
||||
let app;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
// Mount the route on a minimal Express app. Cold-require after
|
||||
// bootCrmDb so the route's downstream `require('../database/db')`
|
||||
// sees the same db instance.
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('contracts').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns a report envelope when nothing references any path', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
expect(res.body.report.summary).toMatchObject({
|
||||
totalRows: 0,
|
||||
missingFiles: 0,
|
||||
hashMismatches: 0,
|
||||
verifiedOk: 0,
|
||||
existsButNoHash: 0,
|
||||
});
|
||||
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
|
||||
it('surfaces a missing file in the response payload', async () => {
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.summary.missingFiles).toBe(1);
|
||||
expect(res.body.report.missing[0]).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the ?scope=invoice filter', async () => {
|
||||
// Seed both an invoice and a contract with missing files. With
|
||||
// scope=invoice the contract row must not appear.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'invoice' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.scopes).toEqual(['invoice']);
|
||||
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown scope with 400 + a code', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'gallery' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
|
||||
expect(res.body.validScopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Regression net for the business-docs coverage gap fixed in this PR.
|
||||
*
|
||||
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
|
||||
* list of storage subdirectories (events/active, events/archived,
|
||||
* thumbnails, previews, heroes, uploads) and silently omitted the
|
||||
* entire `business-docs/` tree. That meant every CRM PDF + signature
|
||||
* drawing — quotes, contracts (system-rendered + wet uploads),
|
||||
* invoices, Storno, imported historical invoices, and the customer
|
||||
* signature PNG/JPG drawn on the public signing page — fell outside
|
||||
* the in-app scheduled backup, leaving every `*_path` column on
|
||||
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
|
||||
*
|
||||
* The fix is a single `scanDirectory(business-docs, ...)` call. This
|
||||
* suite pins the contract so a future refactor of the walker cannot
|
||||
* silently drop business-docs again.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('backupService — business-docs is in the backup walker', () => {
|
||||
let cleanup;
|
||||
let backupService;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
// Cold-require after bootCrmDb so backupService picks up the same
|
||||
// db instance + STORAGE_PATH the test harness configured.
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function seed(relPath, content = 'dummy bytes for backup test') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
it('does not error when business-docs is absent', async () => {
|
||||
// Fresh harness has no business-docs/ tree at all. The walker
|
||||
// must short-circuit on ENOENT rather than throw — installs that
|
||||
// never used CRM features have to keep backing up fine.
|
||||
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
|
||||
});
|
||||
|
||||
it('picks up every CRM-relevant business-docs subdirectory', async () => {
|
||||
// Seed one file in each of the five subpaths the renderer + import
|
||||
// routes write to. The signature path is the one most prone to be
|
||||
// forgotten — it lives one level deeper than the others (per-
|
||||
// contract subfolder, not per-year).
|
||||
seed('business-docs/quote/2026/Q-001.pdf');
|
||||
seed('business-docs/contract/2026/C-001.pdf');
|
||||
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
|
||||
seed('business-docs/invoice/2026/INV-001.pdf');
|
||||
seed('business-docs/invoice-imports/2026/scan.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup(false);
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toEqual(expect.arrayContaining([
|
||||
'business-docs/quote/2026/Q-001.pdf',
|
||||
'business-docs/contract/2026/C-001.pdf',
|
||||
'business-docs/contract/signatures/42/customer-1700000000000.png',
|
||||
'business-docs/invoice/2026/INV-001.pdf',
|
||||
'business-docs/invoice-imports/2026/scan.pdf',
|
||||
]));
|
||||
});
|
||||
|
||||
it('walks newly-created business-docs files without needing a restart', async () => {
|
||||
// The walker reads the filesystem live on every call; this guards
|
||||
// against a future "cache the scan result at boot" optimisation
|
||||
// that would miss freshly-written PDFs (which is exactly what
|
||||
// happens during normal operation — every send writes a new file).
|
||||
seed('business-docs/invoice/2027/INV-NEW.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup(false);
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Pins the Stage-B refactor that lifted the file-backup walker's
|
||||
* subdirectory list out of hard-coded JS into the `backup_paths`
|
||||
* table seeded by migration 109.
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. Walker reads canonical seed → all 7 default subdirs walked
|
||||
* 2. include_in_default=false on one row → that subdir is skipped
|
||||
* 3. New row inserted at runtime → walker picks it up without restart
|
||||
* 4. feature_flag gating → row only walked when the named app_settings
|
||||
* boolean is truthy (mirrors historical `includeArchived` behavior)
|
||||
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
|
||||
* in depth — never silently scans nothing)
|
||||
*
|
||||
* Why not stub `db('backup_paths')`: the whole point of Stage B is
|
||||
* that the walker is now data-driven, so the test has to actually
|
||||
* mutate the table and observe the walker's output change. Stubs
|
||||
* would re-introduce the hard-coding the refactor is meant to remove.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — configurable walker (backup_paths)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Restore canonical seed before every test. Tests mutate this table
|
||||
// freely; the next test starts from a known state.
|
||||
await db('backup_paths').del();
|
||||
const {
|
||||
DEFAULT_PATHS,
|
||||
} = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
});
|
||||
|
||||
it('migration 109 seeds the canonical 7 paths', async () => {
|
||||
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
|
||||
expect(rows.map((r) => r.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
// Only events/archived is gated by a feature flag.
|
||||
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
|
||||
'events/archived',
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks every default subdir when files are present', async () => {
|
||||
seedFile('events/active/E1/a.jpg');
|
||||
seedFile('thumbnails/E1/a.jpg');
|
||||
seedFile('previews/E1/a.jpg');
|
||||
seedFile('heroes/E1/hero.jpg');
|
||||
seedFile('uploads/intake/x.bin');
|
||||
seedFile('business-docs/quote/2026/Q-001.pdf');
|
||||
// events/archived is gated — left out of this test; covered below.
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toEqual(expect.arrayContaining([
|
||||
'events/active/E1/a.jpg',
|
||||
'thumbnails/E1/a.jpg',
|
||||
'previews/E1/a.jpg',
|
||||
'heroes/E1/hero.jpg',
|
||||
'uploads/intake/x.bin',
|
||||
'business-docs/quote/2026/Q-001.pdf',
|
||||
]));
|
||||
});
|
||||
|
||||
it('skips a path when include_in_default is toggled off', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||
});
|
||||
|
||||
it('picks up a new path inserted at runtime — no restart needed', async () => {
|
||||
// Simulates a future feature shipping its own subdirectory and
|
||||
// self-healing a `backup_paths` row at boot.
|
||||
await db('backup_paths').insert({
|
||||
path: 'plugin-store',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 200,
|
||||
description: 'Hypothetical future feature payload',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
seedFile('plugin-store/cache/payload.bin');
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('plugin-store/cache/payload.bin');
|
||||
});
|
||||
|
||||
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
|
||||
seedFile('events/active/E1/active.jpg');
|
||||
seedFile('events/archived/E2/archived.jpg');
|
||||
|
||||
// backup_include_archived=false → archived/ is skipped.
|
||||
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
|
||||
const relsOff = filesOff.map((f) => f.relativePath);
|
||||
expect(relsOff).toContain('events/active/E1/active.jpg');
|
||||
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
|
||||
|
||||
// backup_include_archived=true → archived/ is included.
|
||||
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const relsOn = filesOn.map((f) => f.relativePath);
|
||||
expect(relsOn).toContain('events/archived/E2/archived.jpg');
|
||||
});
|
||||
|
||||
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
|
||||
// Defense in depth: even if seed-and-self-heal both failed, the
|
||||
// walker must still cover the historical set so "Run Backup Now"
|
||||
// cannot silently degrade to no-op.
|
||||
await db('backup_paths').del();
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
seedFile('business-docs/quote/2026/Q-002.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
|
||||
});
|
||||
|
||||
it('legacy boolean call signature still works (backward compat)', async () => {
|
||||
// Existing call sites (and the businessDocs regression test) pass
|
||||
// a boolean for `includeArchived`. Refactor must not break them.
|
||||
seedFile('events/archived/E3/legacy.jpg');
|
||||
|
||||
const filesOff = await backupService.getFilesToBackup(false);
|
||||
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
|
||||
|
||||
const filesOn = await backupService.getFilesToBackup(true);
|
||||
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
|
||||
*
|
||||
* The previous behaviour was: file-backup looked up an existing dump via
|
||||
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
|
||||
* when none was found. Admins clicking "Run Backup Now" got an apparent
|
||||
* success that omitted every customer / quote / invoice / contract row —
|
||||
* the data-loss footgun that this commit closes.
|
||||
*
|
||||
* Five scenarios under test:
|
||||
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
|
||||
* 2. Default, dump throws → run aborts, backup_runs row marked failed
|
||||
* 3. Opt-out + recent DB dump available → backup proceeds
|
||||
* 4. Opt-out + no DB dump available → fail loud
|
||||
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
|
||||
*
|
||||
* Mocking strategy: the underlying `databaseBackupService.backup()` and
|
||||
* the local-destination writer are stubbed so the test exercises just
|
||||
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
|
||||
* binaries being available in the test environment.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
|
||||
const mockBackupFn = jest.fn();
|
||||
jest.mock('../../src/services/databaseBackup', () => ({
|
||||
databaseBackupService: { backup: mockBackupFn },
|
||||
startScheduledBackups: jest.fn(),
|
||||
stopScheduledBackups: jest.fn(),
|
||||
DatabaseBackupService: class {},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — inline DB dump + fail-loud guard', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let dumpFileAbs;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
|
||||
// Seed backup destination settings so the run can proceed past the
|
||||
// "destination not configured" guard.
|
||||
const dest = path.join(storagePath, 'backups');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
// getBackupConfigInternal filters by setting_type='backup', so the
|
||||
// tests have to seed with that type or the resolver returns
|
||||
// `{ ... }` with the keys missing — runBackup then sees
|
||||
// `backup_destination_type === undefined` and bails before our
|
||||
// new guard runs.
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
|
||||
]).onConflict('setting_key').merge();
|
||||
|
||||
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
|
||||
// Reused/mutated per-test via the database_backup_runs seed below.
|
||||
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
|
||||
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
|
||||
|
||||
// Neutralise the file-scan step: we don't care which files would
|
||||
// be backed up, just whether the run reaches that stage at all.
|
||||
backupService.getFilesToBackup = jest.fn(async () => []);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockBackupFn.mockReset();
|
||||
// Default to "dump produced this file with this size" — the per-test
|
||||
// setup overrides as needed.
|
||||
mockBackupFn.mockResolvedValue({
|
||||
success: true,
|
||||
path: dumpFileAbs,
|
||||
size: fs.statSync(dumpFileAbs).size,
|
||||
duration: 1,
|
||||
checksum: 'abc',
|
||||
});
|
||||
|
||||
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
|
||||
// resolves against (its query is `status='completed'` + most recent).
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: dumpFileAbs,
|
||||
file_size_bytes: fs.statSync(dumpFileAbs).size,
|
||||
destination_path: dumpFileAbs,
|
||||
});
|
||||
});
|
||||
|
||||
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
|
||||
// Inline-dump setting is unset (undefined) — default is ON.
|
||||
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
expect(mockBackupFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
expect(run.error_message).toBeNull();
|
||||
});
|
||||
|
||||
it('aborts the run when the inline dump throws', async () => {
|
||||
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
|
||||
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/pg_dump segfaulted/);
|
||||
});
|
||||
|
||||
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
expect(mockBackupFn).not.toHaveBeenCalled();
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('opt-out + no recent dump: fails loud with a clear error', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/No database backup available/);
|
||||
});
|
||||
|
||||
it('opt-out + 0-byte dump file: fails loud', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
|
||||
fs.writeFileSync(emptyDump, '');
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: emptyDump,
|
||||
file_size_bytes: 0,
|
||||
destination_path: emptyDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/is empty/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
|
||||
*
|
||||
* Pins the new `computePerPathStats` logic that the Backup History
|
||||
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
|
||||
*
|
||||
* Three scenarios:
|
||||
* 1. Single file under one path — straightforward attribution
|
||||
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
|
||||
* (e.g. `events/active/E1/x.jpg` should attribute to
|
||||
* `events/active`, not `events`)
|
||||
* 3. File outside any configured path — silently dropped, doesn't
|
||||
* throw or contaminate other buckets
|
||||
*
|
||||
* Tests exercise the EXPORTED side: write a backup_runs row via the
|
||||
* service entry point and assert the statistics JSON shape. We don't
|
||||
* stub `computePerPathStats` directly — the integration view is what
|
||||
* the frontend actually consumes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — per-Stage-B-path statistics', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function mkFile(rel, content = 'x'.repeat(100)) {
|
||||
const abs = path.join(storagePath, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean slate of any artefacts from prior tests
|
||||
await db('backup_runs').del();
|
||||
await db('app_settings').where('setting_type', 'backup').del();
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
]).onConflict('setting_key').merge();
|
||||
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
|
||||
|
||||
// Restore canonical backup_paths from migration 109
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').del();
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
|
||||
// Wipe leftover files between tests
|
||||
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
|
||||
const p = path.join(storagePath, dir);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attributes files to their owning backup_paths row', async () => {
|
||||
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
|
||||
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
|
||||
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
|
||||
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
|
||||
|
||||
// Disable the inline DB dump so we don't need pg_dump in tests;
|
||||
// the file walker is what produces per_path.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
|
||||
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
|
||||
fs.writeFileSync(fakeDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: fakeDump,
|
||||
file_size_bytes: fs.statSync(fakeDump).size,
|
||||
destination_path: fakeDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
|
||||
const statsRaw = typeof run.statistics === 'string'
|
||||
? JSON.parse(run.statistics)
|
||||
: run.statistics;
|
||||
expect(statsRaw.per_path).toBeDefined();
|
||||
|
||||
// events/active should have 2 files (3000 bytes)
|
||||
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
|
||||
// business-docs should have 1 file (500 bytes)
|
||||
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
|
||||
// thumbnails should have 1 file (50 bytes)
|
||||
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
|
||||
|
||||
// No spurious buckets for paths that had nothing
|
||||
expect(statsRaw.per_path['previews']).toBeUndefined();
|
||||
expect(statsRaw.per_path['heroes']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('archived path attributed separately from active when both have files', async () => {
|
||||
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
|
||||
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
|
||||
|
||||
// backup_include_archived already set true in beforeEach so the
|
||||
// archived walker fires; same opt-out for inline DB dump.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
|
||||
fs.writeFileSync(fakeDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: fakeDump,
|
||||
file_size_bytes: fs.statSync(fakeDump).size,
|
||||
destination_path: fakeDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
const statsRaw = typeof run.statistics === 'string'
|
||||
? JSON.parse(run.statistics)
|
||||
: run.statistics;
|
||||
|
||||
// events/active and events/archived attribute separately —
|
||||
// longest-prefix match prevents `events/active/...` from claiming
|
||||
// an `events/archived/...` file or vice versa.
|
||||
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
|
||||
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE on walker duplication
|
||||
//
|
||||
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
|
||||
// another at `events/active`), the walker scans the same files twice
|
||||
// — once via each path. Per-path stats then attribute the file to the
|
||||
// longest-prefix-matching path BOTH times, producing inflated counts.
|
||||
//
|
||||
// The canonical seed in migration 109 contains no overlapping pairs,
|
||||
// so this isn't exercised in practice. But an admin who hand-adds a
|
||||
// broad row that overlaps an existing nested one will see double
|
||||
// counts in their next backup's statistics + the destination will
|
||||
// receive duplicate copies (wasting space). Worth flagging if anyone
|
||||
// reports it — the fix is to de-dupe `files` in
|
||||
// `getFilesToBackupInternal` before returning, OR to skip walking a
|
||||
// path if a longer one has already covered it.
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
|
||||
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
|
||||
* never auto-sends them before the workflow's review gate + explicit
|
||||
* send_document.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
quoteService = require('../../src/services/quoteService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function acceptedQuote() {
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [id] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-01-01',
|
||||
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
|
||||
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
|
||||
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
||||
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
||||
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
|
||||
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
|
||||
});
|
||||
|
||||
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled');
|
||||
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
|
||||
});
|
||||
|
||||
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
|
||||
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
||||
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
||||
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const ev = await db('events').where({ id: res.eventId }).first();
|
||||
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
|
||||
|
||||
// Every invoice the event scheduled is held (no auto-send before the gate).
|
||||
const invs = await db('invoices').whereIn('id', res.invoiceIds);
|
||||
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
|
||||
|
||||
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
|
||||
// again for it (the flow's prepare_invoice adopts these ids instead).
|
||||
const q = await db('quotes').where({ id: quoteId }).first();
|
||||
expect(q.converted_event_id).toBe(res.eventId);
|
||||
});
|
||||
|
||||
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
|
||||
// Reproduces the booking_invoice_only flow on a quote with no explicit
|
||||
// payment timing: the default installment is after_delivery, which would
|
||||
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [quoteId] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-01-01',
|
||||
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
|
||||
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
|
||||
// 100% after_delivery installment.
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
|
||||
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
|
||||
});
|
||||
|
||||
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
|
||||
const mk = async (lockOffsetMs) => {
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [id] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF', issue_date: '2026-01-01',
|
||||
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
|
||||
responded_at: new Date().toISOString(),
|
||||
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
|
||||
accepted_at: new Date().toISOString(),
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const openId = await mk(15 * 60 * 1000); // still inside the window
|
||||
const lockedId = await mk(-60 * 1000); // window already closed
|
||||
|
||||
const emitted = await quoteService.finalizeQuoteResponses();
|
||||
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const open = await db('quotes').where({ id: openId }).first();
|
||||
const locked = await db('quotes').where({ id: lockedId }).first();
|
||||
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
|
||||
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
|
||||
|
||||
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
|
||||
const again = await db('quotes').where({ id: lockedId })
|
||||
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
|
||||
expect(again).toBe(0);
|
||||
});
|
||||
|
||||
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
|
||||
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
||||
expect(res.invoiceIds).toEqual([]);
|
||||
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
|
||||
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
|
||||
});
|
||||
|
||||
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const newId = await quoteService.duplicateQuote(quoteId, adminId);
|
||||
expect(newId).toBeGreaterThanOrEqual(1);
|
||||
expect(newId).not.toBe(quoteId);
|
||||
const q = await db('quotes').where({ id: newId }).first();
|
||||
expect(q.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
|
||||
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
|
||||
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
|
||||
expect(typeof registry.getAction(a)).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
|
||||
const contractService = require('../../src/services/contractService');
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await contractService.createFromQuote(quoteId, adminId);
|
||||
expect(res.contractId).toBeGreaterThanOrEqual(1);
|
||||
expect(res.alreadyConverted).toBe(false);
|
||||
const c = await db('contracts').where({ id: res.contractId }).first();
|
||||
expect(c).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Schema-shape regression net for the CRM consolidated migration.
|
||||
*
|
||||
* Pins the table/column layout that the route + service layer expect
|
||||
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
|
||||
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
|
||||
* forward-reference deferral added in this PR), but it doesn't notice
|
||||
* if a future edit silently drops a column the service code reads —
|
||||
* SQLite would just return undefined and the broken behavior would
|
||||
* land on beta.
|
||||
*
|
||||
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
|
||||
* so a rename or removal there fails the test instead of silently
|
||||
* breaking the lineage card.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('CRM schema after core migrations', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('table layout', () => {
|
||||
const expectedTables = [
|
||||
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
|
||||
'events', 'document_sequences',
|
||||
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
|
||||
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
|
||||
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
|
||||
'customer_hour_entries',
|
||||
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
|
||||
'event_payment_plans',
|
||||
];
|
||||
|
||||
it.each(expectedTables)('has table %s', async (table) => {
|
||||
expect(await db.schema.hasTable(table)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deal_uuid lineage columns', () => {
|
||||
// Every document in one engagement shares a deal_uuid — the
|
||||
// lineage card joins on it. Drop the column anywhere in the chain
|
||||
// and the card silently returns partial data.
|
||||
it.each(['quotes', 'contracts', 'invoices'])(
|
||||
'%s has deal_uuid column',
|
||||
async (table) => {
|
||||
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// The back-pointer FKs were the source of the schema-drift bug
|
||||
// we fixed in this PR (forward references). Pin them.
|
||||
it('quotes has converted_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_quote_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storno discriminator columns', () => {
|
||||
// kind='storno' + cancels_invoice_id + negative totals are the
|
||||
// shape every aggregate filter relies on (feedback_storno_filter_
|
||||
// everywhere). Pin the columns so a rename doesn't silently break
|
||||
// every revenue report.
|
||||
it('invoices has kind discriminator', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
|
||||
});
|
||||
it('invoices has cancels_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
|
||||
});
|
||||
it('invoices has replaces_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event time columns (migration 137)', () => {
|
||||
// The admin calendar reads these to render timed vs. full-day
|
||||
// tiles. Per the feedback_migration_preserve_visuals rule, the
|
||||
// default has to be `is_full_day=true` so existing rows keep
|
||||
// their pre-migration visual.
|
||||
it('events has event_time_start', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
|
||||
});
|
||||
it('events has event_time_end', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
|
||||
});
|
||||
it('events has is_full_day', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seed paths', () => {
|
||||
it('admin + customer seed inserts cleanly', async () => {
|
||||
const { adminId, customerId } = await seedMinimal(db);
|
||||
expect(adminId).toBeTruthy();
|
||||
expect(customerId).toBeTruthy();
|
||||
|
||||
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(admin.email).toBe('tester@example.com');
|
||||
expect(customer.email).toBe('customer@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Negative line items (Rabatt / manual discount lines) are accepted
|
||||
* end-to-end as long as the resulting total stays ≥ 0. When the
|
||||
* discount would drive the total negative, the service rejects with
|
||||
* a clear, code-tagged error so the admin is steered to Storno for
|
||||
* credit-note workflows.
|
||||
*
|
||||
* Touches the actual createInvoice / createQuote service paths so a
|
||||
* future change to either computeTotals or the guard fires this test.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService,
|
||||
// nodemailer, etc.) on first use; the global 5 s per-test budget is
|
||||
// too tight for that. Bump it for this file only.
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let invoiceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// Quote-side coverage of the symmetric validator + guard is
|
||||
// deliberately omitted: createQuote's init path takes ~30 s under
|
||||
// this harness (something in pdfService / emailProcessor cold-
|
||||
// require), which would push the suite well past CI's per-test
|
||||
// budget. The shape of the guard is identical to the invoice one
|
||||
// covered below; a future change to extract the slow init or to
|
||||
// stub it for tests should re-enable a parallel quote test.
|
||||
|
||||
describe('invoices', () => {
|
||||
it('accepts a negative-price line and computes the net correctly', async () => {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
|
||||
expect(Array.isArray(invoiceIds)).toBe(true);
|
||||
expect(invoiceIds.length).toBe(1);
|
||||
|
||||
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
|
||||
expect(row.net_amount_minor).toBe(15000);
|
||||
expect(row.total_amount_minor).toBe(15000);
|
||||
});
|
||||
|
||||
it('rejects when the discount drives the total negative', async () => {
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId)).rejects.toMatchObject({
|
||||
code: 'INVOICE_TOTAL_NEGATIVE',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Boot-time email-template self-heal:
|
||||
* 1. Seeds the CRM / contract / event-reminder templates on an
|
||||
* install that's never had them before.
|
||||
* 2. Recovers email_queue rows that previously exhausted their
|
||||
* retries because their template was missing.
|
||||
*
|
||||
* The failure that triggered this fix (2026-05-27) had Ralf's beta
|
||||
* box failing every `quote_sent` / `invoice_sent` send for ~14h
|
||||
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
|
||||
* defined but never called. After 3 retries the rows sat in
|
||||
* status='pending' forever; nothing in the admin UI signalled the
|
||||
* problem. Both halves of that regression are covered here.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('email template self-heal at boot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
|
||||
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
|
||||
// 107_crm_consolidated documents the deliberate split (templates
|
||||
// are self-healed at runtime, not inserted by the migration).
|
||||
const before = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(before).toEqual([]);
|
||||
|
||||
// Seed a stuck queue row that mirrors what we found on Ralf's box:
|
||||
// quote_sent send attempted 3 times, each time failed because the
|
||||
// template didn't exist, queue processor gave up.
|
||||
const queueRowIds = await db('email_queue').insert({
|
||||
recipient_email: 'customer@example.com',
|
||||
email_type: 'quote_sent',
|
||||
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: "Email template 'quote_sent' not found",
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
|
||||
|
||||
// Also seed an UNRELATED stuck row (different template, NOT one
|
||||
// we're going to insert) to confirm the recovery is targeted —
|
||||
// it must not blanket-reset every retry-exhausted row.
|
||||
const unrelatedIds = await db('email_queue').insert({
|
||||
recipient_email: 'someone@example.com',
|
||||
email_type: 'some_other_template',
|
||||
email_data: JSON.stringify({}),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: 'SMTP timeout',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
|
||||
|
||||
// The seeders use module-level caches (`_seeded = true`). When
|
||||
// jest runs this test in isolation that cache starts fresh; in
|
||||
// the full suite no other test currently calls these seeders, so
|
||||
// the first call here also runs the real work. Reset the cache
|
||||
// defensively in case a future test changes that.
|
||||
jest.resetModules();
|
||||
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
|
||||
|
||||
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
|
||||
|
||||
// Templates landed.
|
||||
expect(result.seeded).toEqual(expect.arrayContaining([
|
||||
'quote_sent', 'invoice_sent', 'storno_issued',
|
||||
]));
|
||||
const after = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
|
||||
|
||||
// Stuck quote_sent row was recovered.
|
||||
expect(result.recovered).toBeGreaterThanOrEqual(1);
|
||||
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
|
||||
expect(recoveredRow.retry_count).toBe(0);
|
||||
expect(recoveredRow.error_message).toBeNull();
|
||||
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
|
||||
|
||||
// Unrelated stuck row was NOT touched.
|
||||
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
|
||||
expect(unrelatedRow.retry_count).toBe(3);
|
||||
expect(unrelatedRow.error_message).toBe('SMTP timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
|
||||
* old slug, so a rename behaves like a rename rather than silently detaching
|
||||
* existing events/quotes and orphaning the per-type pre-event reminder template.
|
||||
*/
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll.
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('event type slug rename cascade', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let customerId;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('re-points events + quotes + the reminder template from old slug to new', async () => {
|
||||
// A non-system event type with slug 'party'.
|
||||
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
|
||||
|
||||
// An authored per-type reminder template + an event + a quote, all on 'party'.
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
|
||||
await db('events').insert({
|
||||
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
|
||||
event_name: 'A party', event_date: '2026-09-01',
|
||||
});
|
||||
await db('quotes').insert({
|
||||
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
|
||||
});
|
||||
|
||||
// Rename the slug.
|
||||
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
|
||||
|
||||
// Event + quote follow the rename.
|
||||
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
|
||||
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
|
||||
// The authored reminder template moved (subject/body preserved), old key gone.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
|
||||
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
|
||||
expect(moved).toBeTruthy();
|
||||
expect(moved.subject_en).toBe('Party reminder');
|
||||
});
|
||||
|
||||
it('does not clobber an existing template for the new slug', async () => {
|
||||
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
|
||||
|
||||
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
|
||||
|
||||
// Target already existed → left intact; source not force-merged over it.
|
||||
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
|
||||
.toBe('existing soiree');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
|
||||
*
|
||||
* Verifies the contract the public route is expected to honour:
|
||||
* - Browser UA → 302 to target_path
|
||||
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
|
||||
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
|
||||
* - Unknown slug → 404 Not Found
|
||||
* - Hit count increments after successful resolutions (both shapes)
|
||||
*
|
||||
* Mirrors the production server.js wiring but doesn't load the whole
|
||||
* server — the surrounding middleware (CORS, helmet, rate limiters)
|
||||
* isn't part of this route's contract.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db; let cleanup; let service; let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Persist a business_profile + business_name so buildOgMetadata's
|
||||
// settings-based fields populate consistently.
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
|
||||
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
const {
|
||||
isSocialCrawler, buildOgMetadata, renderOgHtml,
|
||||
} = require('../../src/services/galleryOgService');
|
||||
|
||||
app = express();
|
||||
app.get('/s/:shortSlug', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findByShortSlug(req.params.shortSlug);
|
||||
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
|
||||
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
|
||||
|
||||
if (isSocialCrawler(req.get('user-agent'))) {
|
||||
const event = await db('events').where({ id: row.event_id }).first('slug');
|
||||
if (event?.slug) {
|
||||
const meta = await buildOgMetadata(event.slug, req.originalUrl);
|
||||
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
meta.url = `${base}/s/${row.short_slug}`;
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderOgHtml(meta));
|
||||
service.recordHit(row.id).catch(() => {});
|
||||
return;
|
||||
}
|
||||
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
|
||||
}
|
||||
|
||||
service.recordHit(row.id).catch(() => {});
|
||||
return res.redirect(302, row.target_path);
|
||||
} catch (err) {
|
||||
return res.status(500).type('text/plain').send(err.message);
|
||||
}
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [eventId] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Event',
|
||||
event_date: '2026-06-05',
|
||||
password_hash: 'x',
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
share_link: slug,
|
||||
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
|
||||
welcome_message: null,
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId, customSlug: shortSlug,
|
||||
});
|
||||
return { eventId, shortUrl: row };
|
||||
}
|
||||
|
||||
// User-agent strings the production `isSocialCrawler` helper matches.
|
||||
// Snapshot known-true samples here so the test stays in sync if the
|
||||
// helper's allowlist evolves.
|
||||
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
|
||||
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
|
||||
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
|
||||
|
||||
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
|
||||
it('redirects to the snapshotted target_path with a 302', async () => {
|
||||
const { shortUrl } = await seedEventAndShortUrl({
|
||||
slug: 'browser-redirect', shortSlug: 'go-here',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/go-here')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(shortUrl.target_path);
|
||||
expect(res.headers.location).toMatch(/^\/gallery\//);
|
||||
});
|
||||
|
||||
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'hit-browser', shortSlug: 'hit-from-browser',
|
||||
});
|
||||
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = await service.findByShortSlug('hit-from-browser');
|
||||
expect(row.hit_count).toBe(1);
|
||||
expect(row.last_hit_at).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
|
||||
it('returns 200 with OG HTML for WhatsApp UA', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'whatsapp-og', shortSlug: 'wa-preview',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/wa-preview')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.text).toContain('<meta');
|
||||
expect(res.text).toMatch(/og:title/);
|
||||
expect(res.text).toMatch(/og:url/);
|
||||
});
|
||||
|
||||
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'canonical-test', shortSlug: 'canonical-short',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/canonical-short')
|
||||
.set('User-Agent', BOT_UA_FACEBOOK);
|
||||
expect(res.status).toBe(200);
|
||||
// The og:url meta tag must contain the short-URL path, not the
|
||||
// /gallery/<slug> path — this is the cache-key invariant from #699.
|
||||
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
|
||||
expect(res.text).not.toMatch(
|
||||
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'cache-header', shortSlug: 'cache-test',
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/s/cache-test')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.headers['cache-control']).toMatch(/public/);
|
||||
expect(res.headers['cache-control']).toMatch(/max-age=300/);
|
||||
});
|
||||
|
||||
it('increments hit_count on a crawler hit as well', async () => {
|
||||
await seedEventAndShortUrl({
|
||||
slug: 'hit-bot', shortSlug: 'hit-from-bot',
|
||||
});
|
||||
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = await service.findByShortSlug('hit-from-bot');
|
||||
expect(row.hit_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /s/:shortSlug — error states', () => {
|
||||
it('404 for an unknown slug', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/never-existed')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
|
||||
const { shortUrl } = await seedEventAndShortUrl({
|
||||
slug: 'gone-test', shortSlug: 'gone-slug',
|
||||
});
|
||||
await service.softDelete(shortUrl.id, null);
|
||||
const res = await request(app)
|
||||
.get('/s/gone-slug')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
|
||||
const { eventId } = await seedEventAndShortUrl({
|
||||
slug: 'orphan-test', shortSlug: 'orphan-slug',
|
||||
});
|
||||
// Hard-delete the event row (FK CASCADE would normally clean up the
|
||||
// short URL too — but if CASCADE didn't fire for whatever reason
|
||||
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
|
||||
// resolver should still degrade safely).
|
||||
// SQLite's foreign_keys pragma is OFF by default; the migration
|
||||
// doesn't toggle it, so this delete leaves the short URL row.
|
||||
await db('events').where({ id: eventId }).delete();
|
||||
const res = await request(app)
|
||||
.get('/s/orphan-slug')
|
||||
.set('User-Agent', BOT_UA_WHATSAPP);
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/UPPER_CASE')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression — existing URL paths must still respond the same', () => {
|
||||
// The /s/* namespace is additive: it must NOT shadow /gallery/*
|
||||
// or any of the OG routes. We don't load the whole app here, but we
|
||||
// can at least pin that the route param doesn't accept slashes —
|
||||
// i.e. /s/foo/bar must NOT be matched by our handler.
|
||||
it('the /s/:shortSlug route does not match nested paths', async () => {
|
||||
const res = await request(app)
|
||||
.get('/s/foo/bar')
|
||||
.set('User-Agent', BROWSER_UA);
|
||||
// Express returns its default 404 when no route matches the path.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Integration tests for the branded short-URL service (#699).
|
||||
*
|
||||
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
|
||||
* + recordHit against a real SQLite DB, including the contracts that
|
||||
* matter for production correctness:
|
||||
*
|
||||
* - Custom slug + collision detection (409 with `suggested`)
|
||||
* - Auto-generated slug from event slug + year
|
||||
* - Soft-delete preserves the row (admin can audit)
|
||||
* - target_path snapshots at create time (toggling the global
|
||||
* "Use short gallery URLs" setting later doesn't change existing
|
||||
* short URLs — backward-compat invariant from #699)
|
||||
* - hit_count increments idempotently
|
||||
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
|
||||
*
|
||||
* Boots one DB for the whole file (cheap on SQLite); each test seeds
|
||||
* its own event row to keep scope clean.
|
||||
*/
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db; let cleanup; let service; let adminId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Minimal admin for created_by audit.
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'shorturl-test',
|
||||
email: 'shorturl@example.com',
|
||||
password_hash: 'x',
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
// Each test seeds a fresh event so collisions / counter state don't leak.
|
||||
async function seedEvent(overrides = {}) {
|
||||
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [id] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: overrides.event_name || 'Test Wedding',
|
||||
event_date: overrides.event_date || '2026-06-05',
|
||||
password_hash: 'x',
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
share_link: slug,
|
||||
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
|
||||
welcome_message: null,
|
||||
});
|
||||
const event = await db('events').where({ id }).first();
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('createShortUrl — custom slug', () => {
|
||||
it('creates with a custom slug', async () => {
|
||||
const event = await seedEvent({ slug: 'sofia-grad-1' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'sofia-graduation-1',
|
||||
createdBy: adminId,
|
||||
});
|
||||
expect(row.short_slug).toBe('sofia-graduation-1');
|
||||
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
||||
expect(row.event_id).toBe(event.id);
|
||||
expect(row.hit_count).toBe(0);
|
||||
});
|
||||
|
||||
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
|
||||
const event = await seedEvent({ slug: 'sofia-grad-2' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'Sofia-GraduAtion-2', // mixed case
|
||||
createdBy: adminId,
|
||||
});
|
||||
expect(row.short_slug).toBe('sofia-graduation-2');
|
||||
});
|
||||
|
||||
it('rejects an invalid slug with INVALID_SLUG code', async () => {
|
||||
const event = await seedEvent({ slug: 'invalid-test' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'invalid slug with spaces',
|
||||
createdBy: adminId,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
||||
});
|
||||
|
||||
it('rejects a reserved slug with INVALID_SLUG code', async () => {
|
||||
const event = await seedEvent({ slug: 'reserved-test' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: 'admin',
|
||||
createdBy: adminId,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
|
||||
});
|
||||
|
||||
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
|
||||
const event1 = await seedEvent({ slug: 'dup-test-1' });
|
||||
const event2 = await seedEvent({ slug: 'dup-test-2' });
|
||||
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
|
||||
await expect(service.createShortUrl({
|
||||
eventId: event2.id, customSlug: 'collide-me',
|
||||
})).rejects.toMatchObject({
|
||||
code: 'SLUG_TAKEN',
|
||||
suggested: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
|
||||
await expect(service.createShortUrl({
|
||||
eventId: 9999999, customSlug: 'no-event',
|
||||
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl — auto-generated slug', () => {
|
||||
it('uses event slug + year when no custom slug provided', async () => {
|
||||
const event = await seedEvent({
|
||||
slug: 'autogen-wedding', event_date: '2026-06-05',
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
createdBy: adminId,
|
||||
});
|
||||
// First-choice candidate is just the slug; takes that.
|
||||
expect(row.short_slug).toBe('autogen-wedding');
|
||||
});
|
||||
|
||||
it('falls back to slug-year when the bare slug is already taken', async () => {
|
||||
// Both events SHARE the same canonical slug so the first-choice
|
||||
// bare-slug candidate is burned, forcing autoGen to try the
|
||||
// year-suffixed variant.
|
||||
const event1 = await seedEvent({
|
||||
slug: 'collide-base', event_date: '2026-07-01',
|
||||
});
|
||||
await service.createShortUrl({
|
||||
eventId: event1.id, customSlug: 'collide-base',
|
||||
});
|
||||
const event2 = await seedEvent({
|
||||
slug: 'collide-base-2', event_date: '2026-07-01',
|
||||
});
|
||||
// Force the bare candidate of event2 to also collide by burning it.
|
||||
await service.createShortUrl({
|
||||
eventId: event1.id, customSlug: 'collide-base-2',
|
||||
});
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event2.id, // No custom — auto-gen from event2.slug
|
||||
});
|
||||
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
|
||||
expect(row.short_slug).toBe('collide-base-2-2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
|
||||
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
|
||||
const event = await seedEvent({ slug: 'snapshot-off' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'snap-off',
|
||||
});
|
||||
expect(row.target_path).toBe(`/gallery/${event.slug}`);
|
||||
});
|
||||
|
||||
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
|
||||
// Persist the setting.
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
|
||||
try {
|
||||
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'snap-on',
|
||||
});
|
||||
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
|
||||
|
||||
// CRITICAL backward-compat invariant: now flip the setting OFF.
|
||||
// Existing short URLs must still resolve to the same target_path
|
||||
// they were created with — operator's existing share links don't
|
||||
// silently change behaviour.
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
||||
const refetched = await service.findByShortSlug('snap-on');
|
||||
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
|
||||
} finally {
|
||||
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByShortSlug + listForEvent', () => {
|
||||
it('returns null for an unknown slug', async () => {
|
||||
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a malformed slug (no DB hit)', async () => {
|
||||
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
|
||||
expect(await service.findByShortSlug('with spaces')).toBeNull();
|
||||
expect(await service.findByShortSlug('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
|
||||
const event = await seedEvent({ slug: 'softdel-find' });
|
||||
const created = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'find-deleted',
|
||||
});
|
||||
await service.softDelete(created.id, adminId);
|
||||
const fetched = await service.findByShortSlug('find-deleted');
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('listForEvent excludes soft-deleted rows', async () => {
|
||||
const event = await seedEvent({ slug: 'list-test' });
|
||||
const live = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'list-live',
|
||||
});
|
||||
const deleted = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'list-deleted',
|
||||
});
|
||||
await service.softDelete(deleted.id, adminId);
|
||||
const list = await service.listForEvent(event.id);
|
||||
const ids = list.map((r) => r.id);
|
||||
expect(ids).toContain(live.id);
|
||||
expect(ids).not.toContain(deleted.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
|
||||
const event = await seedEvent({ slug: 'softdel-idem' });
|
||||
const created = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'idem-delete',
|
||||
});
|
||||
expect(await service.softDelete(created.id, adminId)).toBe(true);
|
||||
expect(await service.softDelete(created.id, adminId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unknown id (caller maps to 404)', async () => {
|
||||
expect(await service.softDelete(9999999, adminId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createShortUrl after soft-delete — slug rotation', () => {
|
||||
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
|
||||
const event = await seedEvent({ slug: 'rotate' });
|
||||
const first = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'rotate-me',
|
||||
});
|
||||
await service.softDelete(first.id, adminId);
|
||||
// The slug is now reclaimable for a fresh row.
|
||||
const second = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'rotate-me',
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.short_slug).toBe('rotate-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordHit', () => {
|
||||
it('increments hit_count + stamps last_hit_at', async () => {
|
||||
const event = await seedEvent({ slug: 'hit-counter' });
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id, customSlug: 'count-me',
|
||||
});
|
||||
await service.recordHit(row.id);
|
||||
await service.recordHit(row.id);
|
||||
const fetched = await service.findByShortSlug('count-me');
|
||||
expect(fetched.hit_count).toBe(2);
|
||||
expect(fetched.last_hit_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is fire-and-forget — invalid id does not throw', async () => {
|
||||
await expect(service.recordHit(9999999)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Test harness for CRM integration tests.
|
||||
*
|
||||
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
|
||||
* directly (bypassing knex's Migrator — its exclusive write lock
|
||||
* deadlocks 001_init's nested `initializeDatabase()` call), and
|
||||
* exposes a small helper for seeding the minimal row set that the
|
||||
* quote/contract/invoice services need to operate.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* ({ db, cleanup } = await bootCrmDb());
|
||||
* ({ adminId, customerId } = await seedMinimal(db));
|
||||
* });
|
||||
* afterAll(async () => { await cleanup(); });
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
async function runCoreMigrations(db) {
|
||||
await db.schema.createTable('migrations', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').unique().notNullable();
|
||||
t.timestamp('applied_at').defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
|
||||
const files = (await fs.promises.readdir(coreDir))
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.sort();
|
||||
|
||||
for (const f of files) {
|
||||
const mod = require(path.join(coreDir, f));
|
||||
if (typeof mod.up === 'function') {
|
||||
await mod.up(db);
|
||||
}
|
||||
await db('migrations').insert({ filename: f });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
|
||||
* Caller must invoke cleanup() in afterAll to release the SQLite file
|
||||
* and the temp directory.
|
||||
*/
|
||||
async function bootCrmDb() {
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
// No jest.resetModules() — every service the test later requires
|
||||
// must share THIS db instance. Two module copies on one SQLite file
|
||||
// each open their own knex pool and the SQLite write lock deadlocks
|
||||
// the second one acquiring a connection. Caller is responsible for
|
||||
// setting TEST_DATABASE_PATH before the first require of db.js
|
||||
// (which knexfile reads at module-init time); bootCrmDb only works
|
||||
// when invoked before any service import.
|
||||
const { db } = require('../../../src/database/db');
|
||||
|
||||
await runCoreMigrations(db);
|
||||
|
||||
return {
|
||||
db,
|
||||
tmpDir,
|
||||
cleanup: async () => {
|
||||
try { await db.destroy(); } catch (_) {}
|
||||
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the minimal row set that quote/contract/invoice services
|
||||
* dereference on creation: an admin user, an active customer, a
|
||||
* business_profile row, and the app_settings keys the services read.
|
||||
*
|
||||
* Returns the ids the caller will pass into service calls.
|
||||
*/
|
||||
async function seedMinimal(db) {
|
||||
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
|
||||
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'tester', email: 'tester@example.com',
|
||||
password_hash: passwordHash, must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
// business_profile is a singleton; the row is seeded by migration 107
|
||||
// for fresh installs. Defensive: insert if missing.
|
||||
const profile = await db('business_profile').first();
|
||||
if (!profile) {
|
||||
await db('business_profile').insert({
|
||||
legal_name: 'Test Studio',
|
||||
default_currency: 'CHF',
|
||||
default_locale: 'de',
|
||||
});
|
||||
}
|
||||
|
||||
const customerInsert = await db('customer_accounts').insert({
|
||||
email: 'customer@example.com',
|
||||
display_name: 'Test Customer',
|
||||
password_hash: passwordHash,
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const customerId = customerInsert[0]?.id ?? customerInsert[0];
|
||||
|
||||
return { adminId, customerId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
|
||||
// tests. Kept here so every supertest suite shares the same minting +
|
||||
// app-wiring shape and a refactor lands in one place.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
/**
|
||||
* Promote a seeded admin into a role (default `super_admin`) so
|
||||
* `requirePermission(...)` checks pass. seedMinimal creates an admin
|
||||
* without a role — that's good for negative tests (expect 403) but
|
||||
* happy-path tests need the role assignment.
|
||||
*
|
||||
* Returns the role id the admin was assigned to.
|
||||
*/
|
||||
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
if (!role) {
|
||||
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
|
||||
}
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
|
||||
return role.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an admin JWT in the same shape adminAuth middleware expects.
|
||||
* The tests inject this via `Authorization: Bearer <token>`.
|
||||
*/
|
||||
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
return jwt.sign(
|
||||
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn, issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row into one of the public-token tables for testing the
|
||||
* loadActionToken guard outcomes. Returns the generated 64-hex token.
|
||||
*
|
||||
* Usage:
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
|
||||
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
|
||||
*/
|
||||
async function createPublicToken(db, tableName, opts = {}) {
|
||||
const token = opts.token || crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = opts.expires_at === null
|
||||
? null
|
||||
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
|
||||
// Serialise Date → ISO string. Bare Date objects round-tripped
|
||||
// inconsistently through knex+SQLite — sometimes as epoch ms,
|
||||
// sometimes via .toString() → literal "[object Object]" which then
|
||||
// parses back to NaN and silently defeats the expiry guard.
|
||||
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
|
||||
const row = {
|
||||
...opts,
|
||||
token,
|
||||
expires_at: toStorable(expiresAt),
|
||||
created_at: toStorable(new Date()),
|
||||
};
|
||||
await db(tableName).insert(row);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Express app with the requested route file mounted. Mirrors
|
||||
* the production app's middleware shape (json + cookies) but skips
|
||||
* everything else (CORS, helmet, rate limiters) — route tests pin the
|
||||
* handler's contract, not the surrounding cross-cutting concerns.
|
||||
*
|
||||
* Example:
|
||||
* const app = buildRouteApp('/api/public/quotes',
|
||||
* require('../../src/routes/publicQuotes'));
|
||||
*/
|
||||
function buildRouteApp(mount, router) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(mount, router);
|
||||
// Catch-all error handler. Mirrors the real middleware/errorHandler:
|
||||
// AppError subclasses (ValidationError, NotFoundError, etc.) use
|
||||
// `.statusCode` (NOT `.status` — getting that wrong silently maps
|
||||
// every 400 / 404 / 410 to 500 in tests).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
res.status(statusCode).json({
|
||||
error: err.message || 'Internal error',
|
||||
code: err.code,
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bootCrmDb,
|
||||
seedMinimal,
|
||||
assignAdminRole,
|
||||
mintAdminToken,
|
||||
createPublicToken,
|
||||
buildRouteApp,
|
||||
};
|
||||
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src);
|
||||
expect(key).toBe('previews/preview_preview-source.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
// Source is 800x600 and default longEdge is 1920 with
|
||||
// withoutEnlargement: true → preview must NOT be upscaled.
|
||||
expect(meta.width).toBe(800);
|
||||
expect(meta.height).toBe(600);
|
||||
}
|
||||
});
|
||||
|
||||
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
// 800x600 → fit:'inside' inside 400×400 → 400×300.
|
||||
expect(meta.width).toBe(400);
|
||||
expect(meta.height).toBe(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
|
||||
const key = await imageProcessor.generatePreviewImage(src);
|
||||
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
|
||||
* real SQLite schema. Covers the bits unit tests can't: the disposition state
|
||||
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
|
||||
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
|
||||
* billPendingRebills actually mint / amend invoice rows correctly.
|
||||
*
|
||||
* No date-range comparisons are exercised here, so it's safe on SQLite (the
|
||||
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
|
||||
* doesn't apply to this path).
|
||||
*/
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
|
||||
// on first use; bump the budget for this file.
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe('incoming-invoice categorise / re-bill chain', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let expenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
|
||||
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
|
||||
// second write connection deadlocks against the held write lock on
|
||||
// SQLite. It's fire-and-forget audit noise, irrelevant to these
|
||||
// assertions, so stub it BEFORE the services destructure it at require
|
||||
// time. (Production runs Postgres, where the concurrent write is fine.)
|
||||
const dbModule = require('../../src/database/db');
|
||||
dbModule.logActivity = async () => {};
|
||||
({ adminId } = await seedMinimal(db));
|
||||
expenseService = require('../../src/services/expenseService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
|
||||
|
||||
async function captureDoc(overrides = {}) {
|
||||
const ins = await db('inbound_documents').insert({
|
||||
source: 'upload',
|
||||
status: 'unsorted',
|
||||
parse_status: 'pending',
|
||||
parse_method: 'none',
|
||||
supplier_name: 'ACME AG',
|
||||
currency: 'CHF',
|
||||
total_amount_minor: 10000,
|
||||
invoice_date: '2026-06-01',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
let customerSeq = 0;
|
||||
async function makeCustomer(billingCadence) {
|
||||
customerSeq += 1;
|
||||
const ins = await db('customer_accounts').insert({
|
||||
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
|
||||
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
|
||||
password_hash: 'x',
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
billing_cadence: billingCadence || null,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return unwrapId(ins);
|
||||
}
|
||||
|
||||
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
|
||||
const id = await captureDoc();
|
||||
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
|
||||
expect(doc.disposition).toBe('eigener_aufwand');
|
||||
expect(doc.status).toBe('categorized');
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
expect(doc.customerAccountId).toBeNull();
|
||||
});
|
||||
|
||||
it('rebill REQUIRES a customer', async () => {
|
||||
const id = await captureDoc();
|
||||
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
|
||||
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
|
||||
});
|
||||
|
||||
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc({ total_amount_minor: 10000 });
|
||||
const doc = await expenseService.categorizeInbound(id, {
|
||||
disposition: 'rebill', customerAccountId: customerId,
|
||||
markupType: 'percent', markupPercent: 10,
|
||||
}, adminId);
|
||||
expect(doc.disposition).toBe('rebill');
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
|
||||
expect(doc.markupType).toBe('percent');
|
||||
expect(Number(doc.markupPercent)).toBe(10);
|
||||
});
|
||||
|
||||
it('passthrough never carries a markup, even if one is sent', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc();
|
||||
const doc = await expenseService.categorizeInbound(id, {
|
||||
disposition: 'durchlaufend', customerAccountId: customerId,
|
||||
markupType: 'percent', markupPercent: 25, // should be ignored
|
||||
}, adminId);
|
||||
expect(doc.disposition).toBe('durchlaufend');
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.markupType).toBe('none');
|
||||
expect(doc.markupPercent).toBeNull();
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
});
|
||||
|
||||
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
|
||||
const customerId = await makeCustomer('monthly');
|
||||
await expect(expenseService.billPendingRebills(customerId, adminId))
|
||||
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
|
||||
});
|
||||
|
||||
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
|
||||
// customer's pool; monthly-customer immediate-bill onto the running draft)
|
||||
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
|
||||
// claims its sequence number via the global db, which DEADLOCKS against the
|
||||
// held write lock on a SQLite-backed harness (a second write connection blocks
|
||||
// — verified). Production runs Postgres where the concurrent write is fine, so
|
||||
// this is a harness limitation, not a product bug. The line-amount math is
|
||||
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
|
||||
// discountLineItems.test.js. Below we test the UNWIND path against a
|
||||
// hand-crafted billed state so we don't have to mint through createInvoice. ──
|
||||
|
||||
// Build a billed state directly: an invoice with two lines, with the inbound
|
||||
// doc stamped onto the first line as a prior re-bill.
|
||||
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
|
||||
const invIns = await db('invoices').insert({
|
||||
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
|
||||
customer_account_id: customerId,
|
||||
status,
|
||||
scheduled_send_at: scheduledSendAt,
|
||||
is_monthly_draft: isMonthlyDraft,
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-06-01',
|
||||
due_date: '2026-07-01',
|
||||
vat_rate: 0,
|
||||
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
|
||||
vat_amount_minor: 0,
|
||||
total_amount_minor: 7000,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const invoiceId = unwrapId(invIns);
|
||||
const rebillLineIns = await db('invoice_line_items').insert({
|
||||
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
|
||||
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
|
||||
}).returning('id');
|
||||
const rebillLineId = unwrapId(rebillLineIns);
|
||||
await db('invoice_line_items').insert({
|
||||
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
|
||||
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
|
||||
});
|
||||
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
|
||||
await db('inbound_documents').where({ id }).update({
|
||||
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
|
||||
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
|
||||
});
|
||||
return { id, invoiceId, rebillLineId };
|
||||
}
|
||||
|
||||
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
|
||||
|
||||
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
|
||||
expect(recat.disposition).toBe('eigener_aufwand');
|
||||
expect(recat.billedInvoiceId).toBeNull();
|
||||
expect(recat.customerAccountId).toBeNull();
|
||||
|
||||
// The re-bill line is gone; the sibling line remains and net recomputes.
|
||||
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
|
||||
const after = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(Number(after.net_amount_minor)).toBe(3000);
|
||||
});
|
||||
|
||||
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
|
||||
|
||||
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
|
||||
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
|
||||
// Nothing was touched — the line survives.
|
||||
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
|
||||
const customerId = await makeCustomer('per_event');
|
||||
const id = await captureDoc();
|
||||
// passthrough → pending
|
||||
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
|
||||
expect(doc.customerAccountId).toBe(customerId);
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
// → company expense: customer cleared, still no invoice
|
||||
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
|
||||
expect(doc.disposition).toBe('eigener_aufwand');
|
||||
expect(doc.customerAccountId).toBeNull();
|
||||
expect(doc.billedInvoiceId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Install-from-backup boot hook — pins the trigger-file convention.
|
||||
*
|
||||
* The hook itself depends on `restoreService.restore`, which is hard
|
||||
* to fully exercise in an integration test without a real PG cluster
|
||||
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
|
||||
* and verify the BOOT HOOK logic:
|
||||
*
|
||||
* - No trigger file → no-op, ran=false
|
||||
* - Empty trigger file → picks newest manifest from manifests/
|
||||
* - Non-empty trigger file → uses the path inside
|
||||
* - DB not empty → refuses (no restore call)
|
||||
* - DB not empty + FORCE env → proceeds
|
||||
* - Successful restore → deletes trigger file
|
||||
* - Failed restore → leaves trigger file in place
|
||||
*
|
||||
* These are the surfaces an admin will hit when actually using the
|
||||
* feature — the docker-compose-on-real-PG end-to-end test belongs in
|
||||
* the follow-up CI work captured as task #7 earlier today.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// Stub the heavy lifting so the test stays fast + portable.
|
||||
const mockRestore = jest.fn();
|
||||
jest.mock('../../src/services/restoreService', () => ({
|
||||
restoreService: {
|
||||
restore: (...args) => mockRestore(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('installFromBackupBoot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupRoot;
|
||||
let manifestsDir;
|
||||
let tryInstallFromBackup;
|
||||
let originalBackupRootEnv;
|
||||
let originalForceEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupRoot = path.join(storagePath, 'backup');
|
||||
manifestsDir = path.join(backupRoot, 'manifests');
|
||||
fs.mkdirSync(manifestsDir, { recursive: true });
|
||||
|
||||
originalBackupRootEnv = process.env.BACKUP_ROOT;
|
||||
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
process.env.BACKUP_ROOT = backupRoot;
|
||||
|
||||
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalBackupRootEnv === undefined) {
|
||||
delete process.env.BACKUP_ROOT;
|
||||
} else {
|
||||
process.env.BACKUP_ROOT = originalBackupRootEnv;
|
||||
}
|
||||
if (originalForceEnv === undefined) {
|
||||
delete process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
} else {
|
||||
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
|
||||
}
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRestore.mockReset();
|
||||
mockRestore.mockResolvedValue({ success: true });
|
||||
delete process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
|
||||
// Clean trigger files + manifests between tests
|
||||
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
|
||||
const p = path.join(backupRoot, name);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
}
|
||||
for (const f of fs.readdirSync(manifestsDir)) {
|
||||
fs.unlinkSync(path.join(manifestsDir, f));
|
||||
}
|
||||
|
||||
// Reset DB to fresh-install state
|
||||
await db('events').del();
|
||||
// Leave admin_users alone — fresh-install state has 1 row.
|
||||
});
|
||||
|
||||
it('no trigger file → no-op', async () => {
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(mockRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty trigger file picks the newest manifest from manifests/', async () => {
|
||||
const older = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
|
||||
fs.writeFileSync(older, '{}');
|
||||
// Set the newer file's mtime slightly later so it wins the sort
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(older, past, past);
|
||||
fs.writeFileSync(newer, '{}');
|
||||
|
||||
// Empty trigger
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.manifestPath).toBe(newer);
|
||||
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: 'local',
|
||||
manifestPath: newer,
|
||||
restoreType: 'full',
|
||||
force: true,
|
||||
skipPreBackup: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('non-empty trigger file uses the path inside', async () => {
|
||||
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
|
||||
fs.writeFileSync(specific, '{}');
|
||||
|
||||
// Relative to backupRoot
|
||||
fs.writeFileSync(
|
||||
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
|
||||
'manifests/backup-manifest-specific.json\n',
|
||||
);
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.manifestPath).toBe(specific);
|
||||
});
|
||||
|
||||
it('deletes the trigger file after a successful restore', async () => {
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
|
||||
fs.writeFileSync(triggerPath, '');
|
||||
|
||||
await tryInstallFromBackup(db);
|
||||
expect(fs.existsSync(triggerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the trigger file in place when restore throws', async () => {
|
||||
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
|
||||
fs.writeFileSync(triggerPath, '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.error).toMatch(/restore exploded/);
|
||||
expect(fs.existsSync(triggerPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to run when the install already has events', async () => {
|
||||
// Simulate an install with existing data
|
||||
await db('events').insert({
|
||||
slug: 'existing-event',
|
||||
event_name: 'Existing Event',
|
||||
event_type: 'wedding',
|
||||
event_date: new Date(),
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'host@example.com',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
share_link: 'existing-event-token',
|
||||
password_hash: 'dummy-hash-for-test',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.error).toMatch(/Database not empty/);
|
||||
expect(mockRestore).not.toHaveBeenCalled();
|
||||
|
||||
// Trigger file should NOT be deleted — admin needs to fix + retry
|
||||
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
|
||||
});
|
||||
|
||||
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
|
||||
await db('events').insert({
|
||||
slug: 'existing-event-2',
|
||||
event_name: 'Existing Event 2',
|
||||
event_type: 'wedding',
|
||||
event_date: new Date(),
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'host@example.com',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
share_link: 'existing-event-2-token',
|
||||
password_hash: 'dummy-hash-for-test-2',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
|
||||
const result = await tryInstallFromBackup(db);
|
||||
|
||||
expect(result.ran).toBe(true);
|
||||
expect(mockRestore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning
|
||||
* rework. Covers the fee math (flat / percent), the VAT toggle gating
|
||||
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
|
||||
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
|
||||
* changes the issued invoice total), and the 3-reminder cap.
|
||||
*
|
||||
* The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and
|
||||
* is verified manually; here we assert the data/immutability behaviour.
|
||||
*/
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let invoiceService;
|
||||
let ids;
|
||||
|
||||
async function setSetting(key, value) {
|
||||
const { upsertAppSetting } = require('../../src/utils/appSettings');
|
||||
await upsertAppSetting(key, JSON.stringify(value), 'crm');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
ids = await seedMinimal(db);
|
||||
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
// Stub the (flaky) PDF render so applyReminder exercises its data path.
|
||||
// eslint-disable-next-line global-require
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
describe('dunning fee resolvers', () => {
|
||||
test('flat fee, no VAT', async () => {
|
||||
await setSetting('crm_invoices_late_fee_enabled', true);
|
||||
await setSetting('crm_invoices_late_fee_type', 'flat');
|
||||
await setSetting('crm_invoices_late_fee_minor', 2000);
|
||||
await setSetting('crm_invoices_late_fee_vat_enabled', false);
|
||||
const inv = { total_amount_minor: 100000 };
|
||||
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
|
||||
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
|
||||
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
|
||||
});
|
||||
|
||||
test('percent fee = % of the invoice gross', async () => {
|
||||
await setSetting('crm_invoices_late_fee_type', 'percent');
|
||||
await setSetting('crm_invoices_late_fee_percent', 5);
|
||||
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
|
||||
});
|
||||
|
||||
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
|
||||
await setSetting('crm_invoices_late_fee_type', 'flat');
|
||||
await setSetting('crm_invoices_late_fee_minor', 2000);
|
||||
await setSetting('crm_invoices_late_fee_vat_enabled', true);
|
||||
|
||||
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
|
||||
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
|
||||
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
|
||||
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
|
||||
|
||||
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
|
||||
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
|
||||
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
|
||||
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyReminder — dunning-document model', () => {
|
||||
let invoiceId;
|
||||
let originalTotal;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setSetting('crm_invoices_late_fee_enabled', true);
|
||||
await setSetting('crm_invoices_late_fee_type', 'flat');
|
||||
await setSetting('crm_invoices_late_fee_minor', 2000);
|
||||
await setSetting('crm_invoices_late_fee_vat_enabled', false);
|
||||
const res = await invoiceService.createInvoice({
|
||||
customerAccountId: ids.customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
|
||||
}, ids.adminId);
|
||||
invoiceId = res.invoiceIds[0];
|
||||
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
|
||||
});
|
||||
|
||||
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
|
||||
const data = await invoiceService.getInvoiceById(invoiceId);
|
||||
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
|
||||
const inv = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(inv.reminder_level).toBe(2);
|
||||
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
|
||||
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
|
||||
});
|
||||
|
||||
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
|
||||
const data = await invoiceService.getInvoiceById(invoiceId);
|
||||
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
|
||||
const inv = await db('invoices').where({ id: invoiceId }).first();
|
||||
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
|
||||
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
|
||||
});
|
||||
|
||||
test('sendReminder refuses to exceed level 3', async () => {
|
||||
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
// Validates the engine-neutral .picpeak export: it must produce a real zip with
|
||||
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
|
||||
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
|
||||
// bootCrmDb MUST run before requiring the service (which transitively requires
|
||||
// db.js) so the export reads this test's DB, not the default path.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
async function readZip(filePath) {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const entries = Object.keys(await zip.entries());
|
||||
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
await zip.close();
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
describe('picpeak export (.picpeak logical export)', () => {
|
||||
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
expect(filePath.endsWith('.picpeak')).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
expect(manifest.format).toBe(1);
|
||||
expect(manifest.kind).toBe('picpeak-backup');
|
||||
expect(manifest.database.engine).toBe('sqlite');
|
||||
expect(manifest.options.includePhotos).toBe(false);
|
||||
expect(manifest.contains_secrets).toBe(true);
|
||||
// Migrations seed real tables (e.g. app_settings) — expect several.
|
||||
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(manifest.tables)).toContain('app_settings');
|
||||
|
||||
const { entries, manifest: zipped } = await readZip(filePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
|
||||
expect(entries).toContain('data/app_settings.ndjson');
|
||||
// Manifest inside the zip matches the returned one.
|
||||
expect(zipped.tables).toEqual(manifest.tables);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never exports knex bookkeeping tables', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const names = Object.keys(manifest.tables);
|
||||
expect(names).not.toContain('knex_migrations');
|
||||
expect(names).not.toContain('knex_migrations_lock');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('row counts in the manifest match the NDJSON line counts', async () => {
|
||||
// Insert a couple of settings so at least one table is non-empty.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const buf = await zip.entryData('data/app_settings.ndjson');
|
||||
await zip.close();
|
||||
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
|
||||
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
|
||||
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
// Full .picpeak roundtrip on a temp SQLite DB:
|
||||
// 1. seed a "backup" instance (admin A + a marker setting)
|
||||
// 2. export → .picpeak
|
||||
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
|
||||
// 4. import the backup with currentAdminId = B
|
||||
// 5. assert the backup data is restored AND the current account (B) survives,
|
||||
// while the backup's admin (A) is also present (different email → added).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
let importFromPicpeak;
|
||||
let validateManifest;
|
||||
let superAdminRoleId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
const adminRow = (email, hash) => ({
|
||||
username: email,
|
||||
email,
|
||||
password_hash: hash,
|
||||
role_id: superAdminRoleId,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
async function setMarker(value) {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
}
|
||||
async function getMarker() {
|
||||
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
|
||||
return row ? JSON.parse(row.setting_value) : null;
|
||||
}
|
||||
|
||||
describe('.picpeak roundtrip (export → import)', () => {
|
||||
it('restores backup data and preserves the current account', async () => {
|
||||
// 1. Seed the "source" instance.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
|
||||
await setMarker('from_backup');
|
||||
|
||||
// 2. Export.
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// 3. Simulate a reinstall: fresh current admin B, mutated data.
|
||||
await db('admin_users').del();
|
||||
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
|
||||
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
|
||||
await setMarker('mutated_after_backup');
|
||||
|
||||
// 4. Import, preserving the current admin.
|
||||
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.tables).toBeGreaterThan(0);
|
||||
|
||||
// 5a. Backup data restored (marker reverted to the backup value).
|
||||
expect(await getMarker()).toBe('from_backup');
|
||||
|
||||
// 5b. The backup's admin is present (different email → added).
|
||||
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.password_hash).toBe('HASH_A');
|
||||
|
||||
// 5c. The current account SURVIVES the override, with its own credentials.
|
||||
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.password_hash).toBe('HASH_B');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites a backup admin that collides with the current account email', async () => {
|
||||
// Source has an admin at the SAME email the current operator will use.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
|
||||
await setMarker('collision_case');
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// Reinstall: current admin uses the same email but a NEW password.
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
|
||||
// Exactly one admin at that email, and it keeps the CURRENT password.
|
||||
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].password_hash).toBe('NEW_HASH');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores files/ and reports filesRestored', async () => {
|
||||
// A business-doc that lives in storage → travels in the backup.
|
||||
const docDir = path.join(tmpDir, 'business-docs');
|
||||
const marker = path.join(docDir, 'roundtrip-doc.txt');
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
fs.writeFileSync(marker, 'hello');
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
fs.rmSync(marker); // delete on disk so the restore must bring it back
|
||||
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
fs.rmSync(docDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('.picpeak manifest validation', () => {
|
||||
it('rejects a database-engine mismatch', async () => {
|
||||
// Harness runs on SQLite, so a pg manifest must be refused.
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a backup from a newer schema (forward-only)', async () => {
|
||||
// validateManifest reads knex_migrations for the target's latest migration;
|
||||
// the harness has none, so create it with an older migration than the backup.
|
||||
await db.schema.createTable('knex_migrations', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
t.integer('batch');
|
||||
t.timestamp('migration_time');
|
||||
});
|
||||
try {
|
||||
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1,
|
||||
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
|
||||
tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
|
||||
} finally {
|
||||
await db.schema.dropTableIfExists('knex_migrations');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PicPeak backup', async () => {
|
||||
const blockers = await validateManifest({ some: 'random-json' });
|
||||
expect(blockers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = 'reset-me@example.com';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = 'target@example.com';
|
||||
const bystanderEmail = 'bystander@example.com';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Pins the fix for the PR #596 review blocker.
|
||||
*
|
||||
* **The bug**
|
||||
*
|
||||
* `preservedMeta` was declared with `let` INSIDE the PostgreSQL
|
||||
* `else` branch of `performDatabaseRestore`, then read AFTER the
|
||||
* `else` block closed at the shared replay site (~L1030). On every
|
||||
* real PG restore:
|
||||
*
|
||||
* ReferenceError: preservedMeta is not defined
|
||||
*
|
||||
* would fire — psql had already completed the data restore, but
|
||||
* the operator-meta replay never ran, the trigger file was left
|
||||
* in place by `_installFromBackupBoot.js` because the restore
|
||||
* "failed", and `combined.log` got a loud FAILED line even though
|
||||
* the data was back. Caught on PR #596 review by the maintainer.
|
||||
*
|
||||
* **Why CI missed it**
|
||||
*
|
||||
* The integration tests around `performFullRestore` only exercise
|
||||
* the SQLite branch via `this.dbType === 'sqlite'`. The PG branch
|
||||
* (~L827-984) requires a real PG connection + real `psql` binary,
|
||||
* neither of which are in the test environment. So the scope leak
|
||||
* sat untested until the maintainer ran a real DR cycle.
|
||||
*
|
||||
* **What this test does**
|
||||
*
|
||||
* Reads the source of `restoreService.js` and asserts the scope
|
||||
* contract: the `preservedMeta` declaration sits ABOVE the
|
||||
* SQLite/PG branch split, so the replay block at the bottom of the
|
||||
* try{} can read it on either branch.
|
||||
*
|
||||
* Source-inspection is uglier than a runtime test but it has two
|
||||
* advantages here: (a) it doesn't require a real PG cluster + psql
|
||||
* binary in CI, (b) it pins the EXACT contract — "the declaration
|
||||
* must be visible to the replay block" — which is the property
|
||||
* that broke, more directly than a runtime test would.
|
||||
*
|
||||
* The follow-up "real-PG integration test in CI" (separate task)
|
||||
* would replace this with an end-to-end exercise, at which point
|
||||
* this can be deleted.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('restoreService — PG branch scope contract (PR #596 review)', () => {
|
||||
let src;
|
||||
let lines;
|
||||
|
||||
beforeAll(() => {
|
||||
src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'services', 'restoreService.js'),
|
||||
'utf8',
|
||||
);
|
||||
lines = src.split(/\r?\n/);
|
||||
});
|
||||
|
||||
/** Return the 1-based line number of the FIRST line matching `re`. */
|
||||
function findFirst(re) {
|
||||
const idx = lines.findIndex((l) => re.test(l));
|
||||
return idx >= 0 ? idx + 1 : -1;
|
||||
}
|
||||
|
||||
/** Return the 1-based line number of the LAST line matching `re`. */
|
||||
function findLast(re) {
|
||||
let last = -1;
|
||||
lines.forEach((l, i) => { if (re.test(l)) last = i + 1; });
|
||||
return last;
|
||||
}
|
||||
|
||||
it('preservedMetaSnapshot lives on `this` and is initialised in the constructor', () => {
|
||||
// PR #596 round 3 moved the snapshot from a block-scoped local to
|
||||
// an instance variable so the replay can happen in `restore()`
|
||||
// AFTER post-restore verification — preventing the replay row
|
||||
// from inflating the row-count check.
|
||||
//
|
||||
// Contract:
|
||||
// 1. The constructor initialises `this.preservedMetaSnapshot = []`
|
||||
// 2. The `restore()` entry point resets it per call (no leak
|
||||
// across consecutive runs in the singleton service instance)
|
||||
// 3. `performDatabaseRestore` assigns to `this.preservedMetaSnapshot`
|
||||
// inside the PG branch (must run before DROP)
|
||||
// 4. The replay reads `this.preservedMetaSnapshot` — NOT a bare
|
||||
// `preservedMeta` local — so a future refactor can't
|
||||
// accidentally drop the snapshot half on the floor again.
|
||||
const constructorInit = lines.some((l) =>
|
||||
/this\.preservedMetaSnapshot\s*=\s*\[\s*\]/.test(l)
|
||||
);
|
||||
expect(constructorInit).toBe(true);
|
||||
|
||||
const assignmentSites = lines.filter((l) =>
|
||||
/this\.preservedMetaSnapshot\s*=\s*(\[\s*\]|await\s+db)/.test(l)
|
||||
);
|
||||
// Constructor init + restore() per-run reset + the PG-branch
|
||||
// assignment from db query. Three writes.
|
||||
expect(assignmentSites.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// No stray bare `preservedMeta` local-scoped declaration in
|
||||
// performDatabaseRestore — would indicate someone re-introduced
|
||||
// the round-1 footgun.
|
||||
const dangerousLocalDecl = lines.filter((l) =>
|
||||
/^\s*(let|const)\s+preservedMeta\s*=/.test(l)
|
||||
);
|
||||
expect(dangerousLocalDecl).toEqual([]);
|
||||
});
|
||||
|
||||
it('every .count() result is coerced to Number before comparison', () => {
|
||||
// PR #596 review caught a second PG-only landmine: pg-driver
|
||||
// returns COUNT(*) as a string ("16" not 16) to preserve bigint
|
||||
// precision. The original code compared `result.count !==
|
||||
// expected.rowCount` and every match flagged as a mismatch on PG.
|
||||
//
|
||||
// The fix coerces with `Number(...)` at every comparison +
|
||||
// interpolation site. This test catches a future regression where
|
||||
// a refactor uses `.count` directly in a `===` / `!==` / `>` /
|
||||
// `<` comparison without coercing.
|
||||
//
|
||||
// Heuristic: find every `.count` access in the file and make sure
|
||||
// the line either:
|
||||
// (a) wraps it in `Number(...)`, or
|
||||
// (b) is purely an interpolation that already coerced upstream
|
||||
// (e.g. `validation.warnings.push(`... ${eventCountN} ...`)`
|
||||
// where eventCountN is the coerced local), or
|
||||
// (c) is the docstring/comment line (filtered separately).
|
||||
//
|
||||
// We approximate this by listing every `.count` reference site
|
||||
// and asserting that lines doing comparisons (`===`/`!==`/`>`/
|
||||
// `<`/`>=`/`<=`) on a raw `.count` access without `Number(...)`
|
||||
// around it are zero.
|
||||
const dangerousLines = lines
|
||||
.map((l, i) => ({ line: i + 1, text: l }))
|
||||
// Filter to lines that compare a .count result
|
||||
.filter(({ text }) => {
|
||||
// Skip comments
|
||||
if (/^\s*(\/\/|\*)/.test(text)) return false;
|
||||
// Detect a `.count` (followed by `)` for `?.count` or by space/operator)
|
||||
// being directly compared via ===/!==/>/<.
|
||||
// Match the BAD pattern: `<something>.count <op> <something>`
|
||||
// where <op> is === / !== / > / < / >= / <=
|
||||
const bareCountInComparison = /\w+\??\.count\s*(?:!==|===|>=?|<=?)\s+/;
|
||||
// ALLOW if the .count is preceded by `Number(` in the same line
|
||||
const wrappedInNumber = /Number\(\s*\w+\??\.count/;
|
||||
return bareCountInComparison.test(text) && !wrappedInNumber.test(text);
|
||||
});
|
||||
|
||||
expect(dangerousLines).toEqual([]);
|
||||
});
|
||||
|
||||
it('the completed-restore update sets was_successful=true', () => {
|
||||
// Without this, every successful restore ends up with
|
||||
// status='completed', was_successful=false — the dashboard's
|
||||
// "last successful restore" widget then filters out the row +
|
||||
// any future audit query gating on was_successful misses it.
|
||||
// Caught locally + maintainer PR #596 review.
|
||||
//
|
||||
// Contract: the update payload that writes status='completed' on
|
||||
// the SUCCESS branch ALSO includes was_successful: true. We pin
|
||||
// it by source inspection so any future refactor of the success
|
||||
// payload keeps both fields together.
|
||||
// The success-branch update lives AFTER performPostRestoreVerification.
|
||||
// There's also a `status: 'completed'` in the dry-run / early-return
|
||||
// path (failure handling has its own block too) — we want the
|
||||
// SUCCESS-branch one specifically.
|
||||
const verifyLine = findFirst(/performPostRestoreVerification\s*\(/);
|
||||
expect(verifyLine).toBeGreaterThan(0);
|
||||
|
||||
const completedStatusLineIdx = lines
|
||||
.map((l, i) => ({ line: i + 1, text: l }))
|
||||
.find(({ line, text }) =>
|
||||
line > verifyLine && /status:\s*['"]completed['"]/.test(text)
|
||||
);
|
||||
expect(completedStatusLineIdx).toBeDefined();
|
||||
|
||||
// Look in the next ~10 lines for was_successful: true. The actual
|
||||
// payload is small (no nested objects between status and the
|
||||
// closing })), so a fixed-window search is reliable.
|
||||
const window = lines.slice(
|
||||
completedStatusLineIdx.line - 1,
|
||||
completedStatusLineIdx.line + 10,
|
||||
).join('\n');
|
||||
expect(window).toMatch(/was_successful:\s*true/);
|
||||
});
|
||||
|
||||
it('npm run migrate:safe is invoked after the replay in restore()', () => {
|
||||
// Contract from PR #596 round 4: backups taken on older picpeak
|
||||
// versions must restore COMPLETELY on a newer image — even if new
|
||||
// migrations have been added since the backup was taken. The
|
||||
// restore() flow shells out to `npm run migrate:safe` AFTER the
|
||||
// operator-meta replay so the schema catches up to the running
|
||||
// code WITHIN the restore boundary (not on the next container
|
||||
// restart).
|
||||
//
|
||||
// Contract:
|
||||
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
|
||||
// 2. It sits AFTER the replay drain — verification → replay →
|
||||
// migrations is the documented order
|
||||
// 3. It does NOT sit inside performDatabaseRestore (must run
|
||||
// against the reinit'd pool from the parent restore())
|
||||
const migrateLine = findFirst(/['"]migrate:safe['"]/);
|
||||
expect(migrateLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
expect(replayLine).toBeGreaterThan(0);
|
||||
expect(migrateLine).toBeGreaterThan(replayLine);
|
||||
|
||||
// Must NOT live inside performDatabaseRestore (same scope as the
|
||||
// replay check above).
|
||||
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
|
||||
let dbRestoreEnd = -1;
|
||||
for (let i = dbRestoreStart; i < lines.length; i++) {
|
||||
if (/^ \}\s*$/.test(lines[i])) {
|
||||
dbRestoreEnd = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(migrateLine < dbRestoreStart || migrateLine > dbRestoreEnd).toBe(true);
|
||||
});
|
||||
|
||||
it('the replay site lives in restore() AFTER performPostRestoreVerification', () => {
|
||||
// PR #596 round 3 moved the replay out of performDatabaseRestore
|
||||
// and into the parent restore() method, sequenced AFTER the
|
||||
// post-restore verification. Otherwise the replay's upserted row
|
||||
// count was being flagged as a verification mismatch (e.g.
|
||||
// "expected 190, got 191" because the fresh-install seeded
|
||||
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
|
||||
//
|
||||
// Contract: the line that drains `this.preservedMetaSnapshot`
|
||||
// must come AFTER `performPostRestoreVerification` AND must NOT
|
||||
// sit inside `performDatabaseRestore`.
|
||||
const verificationLine = findFirst(/performPostRestoreVerification\s*\(/);
|
||||
expect(verificationLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
expect(replayLine).toBeGreaterThan(0);
|
||||
expect(replayLine).toBeGreaterThan(verificationLine);
|
||||
|
||||
// `performDatabaseRestore` must not contain the replay drain.
|
||||
// Find the function bounds + assert no drain line falls inside.
|
||||
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
|
||||
expect(dbRestoreStart).toBeGreaterThan(0);
|
||||
|
||||
// Find the closing brace of performDatabaseRestore. Lazy heuristic:
|
||||
// the first `^ \}\s*$` (two-space indent + }) after the function
|
||||
// start. Brittle to indent changes but unambiguous in this codebase.
|
||||
let dbRestoreEnd = -1;
|
||||
for (let i = dbRestoreStart; i < lines.length; i++) {
|
||||
if (/^ \}\s*$/.test(lines[i])) {
|
||||
dbRestoreEnd = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(dbRestoreEnd).toBeGreaterThan(dbRestoreStart);
|
||||
|
||||
// The replay drain line must be OUTSIDE [dbRestoreStart, dbRestoreEnd].
|
||||
expect(replayLine < dbRestoreStart || replayLine > dbRestoreEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
'use strict';
|
||||
|
||||
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
|
||||
// so setupService shares this test's db instance (see crmDb.js note).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let setupService;
|
||||
let getAppSetting;
|
||||
let upsertAppSetting;
|
||||
let app;
|
||||
|
||||
const VALID_PW = 'Str0ng-Passw0rd!';
|
||||
|
||||
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
|
||||
// service/util), or db.js binds to the default path instead of the temp one.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').del();
|
||||
await db('app_settings').where({ setting_key: 'setup_token' }).del();
|
||||
});
|
||||
|
||||
describe('setupService (first-run bootstrap)', () => {
|
||||
it('reports needsAdmin while no admin exists', async () => {
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('generates and persists a one-time token while no admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(token).toEqual(expect.any(String));
|
||||
expect(token.length).toBeGreaterThan(20);
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
// Idempotent — a second call returns the same token, not a fresh one.
|
||||
expect(await setupService.ensureSetupToken()).toBe(token);
|
||||
});
|
||||
|
||||
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
|
||||
// Regression guard for the SQLite-only miss: a bare token string is rejected
|
||||
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
|
||||
// value must be JSON-parseable and round-trip back to the token.
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
|
||||
expect(() => JSON.parse(row.setting_value)).not.toThrow();
|
||||
expect(JSON.parse(row.setting_value)).toBe(token);
|
||||
});
|
||||
|
||||
it('rejects a wrong token', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('rejects a weak password', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const result = await setupService.createInitialAdmin({
|
||||
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(result.user.email).toBe('owner@example.com'); // normalised
|
||||
expect(result.user.role.name).toBe('super_admin');
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
|
||||
const row = await db('admin_users').first();
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
expect(row.role_id).toBe(role.id);
|
||||
expect(row.password_hash).not.toBe(VALID_PW); // hashed
|
||||
|
||||
// One-time: token burned, status now complete.
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
|
||||
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
|
||||
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
|
||||
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
||||
});
|
||||
|
||||
it('refuses to create a second admin (setup already complete)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const results = await Promise.allSettled([
|
||||
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
|
||||
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
|
||||
]);
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
|
||||
const count = await db('admin_users').count({ c: '*' }).first();
|
||||
expect(Number(count.c)).toBe(1);
|
||||
});
|
||||
|
||||
it('ensureSetupToken clears any stale token once an admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
// Simulate a stale token left in settings, then re-run the boot hook.
|
||||
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
|
||||
expect(await setupService.ensureSetupToken()).toBeNull();
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup routes', () => {
|
||||
it('GET /api/setup/status reports needsAdmin', async () => {
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ valid: true });
|
||||
// Token is NOT consumed — it still works for the actual create.
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.field).toBe('token');
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: 'owner@example.com', password: VALID_PW });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.user.role.name).toBe('super_admin');
|
||||
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: 'second@example.com', password: VALID_PW });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* Workflow engine — graph execution integration tests.
|
||||
*
|
||||
* Exercises the engine against a real (temp SQLite) DB with migration 142
|
||||
* applied: branching, bounded loops, wait pauses + scheduler-style resume,
|
||||
* gate pauses + confirm/deny resume, dedup idempotency, and step recording.
|
||||
*/
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let engine;
|
||||
|
||||
async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) {
|
||||
const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled });
|
||||
const workflowId = ins[0];
|
||||
for (const n of nodes) {
|
||||
await db('workflow_nodes').insert({
|
||||
workflow_id: workflowId, version: 1, node_key: n.key, type: n.type,
|
||||
config: JSON.stringify(n.config || {}),
|
||||
});
|
||||
}
|
||||
for (const e of edges) {
|
||||
await db('workflow_edges').insert({
|
||||
workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to,
|
||||
loop_back: e.loopBack || false,
|
||||
});
|
||||
}
|
||||
return workflowId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
|
||||
engine = require('../../src/services/workflows');
|
||||
// Enable the workflows flag so emitWorkflowEvent doesn't fail closed.
|
||||
await db('feature_flags').insert({ key: 'workflows', value: true });
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
describe('workflow engine', () => {
|
||||
test('condition + bounded loop + wait pauses, resumes to completion', async () => {
|
||||
// trigger → set paid=false → condition(paid?) --no--> loop(max2)
|
||||
// loop --loop--> reminder(noop) → wait → (back to condition)
|
||||
// loop --exit--> lateFee(noop) → end
|
||||
// condition --yes--> lateFee (paid path, not taken here)
|
||||
const wfId = await makeWorkflow({
|
||||
nodes: [
|
||||
{ key: 'n1', type: 'trigger' },
|
||||
{ key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } },
|
||||
{ key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } },
|
||||
{ key: 'n4', type: 'loop', config: { maxIterations: 2 } },
|
||||
{ key: 'n5', type: 'action', config: { action: 'noop' } },
|
||||
{ key: 'n6', type: 'wait', config: { delayMinutes: 0 } },
|
||||
{ key: 'n7', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'n1', to: 'n2' },
|
||||
{ from: 'n2', to: 'n3' },
|
||||
{ from: 'n3', handle: 'no', to: 'n4' },
|
||||
{ from: 'n3', handle: 'yes', to: 'n7' },
|
||||
{ from: 'n4', handle: 'loop', to: 'n5' },
|
||||
{ from: 'n4', handle: 'exit', to: 'n7' },
|
||||
{ from: 'n5', to: 'n6' },
|
||||
{ from: 'n6', to: 'n3', loopBack: true },
|
||||
],
|
||||
});
|
||||
|
||||
const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 });
|
||||
expect(runIds.length).toBe(1);
|
||||
const runId = runIds[0];
|
||||
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1)
|
||||
expect(run.current_node).toBe('n6');
|
||||
|
||||
await engine.resumeRun(runId);
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting'); // paused again (loop iter 2)
|
||||
|
||||
await engine.resumeRun(runId);
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done'); // loop exhausted → exit → end
|
||||
|
||||
const ctx = JSON.parse(run.context);
|
||||
expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap
|
||||
void wfId;
|
||||
|
||||
const steps = await db('workflow_run_steps').where({ run_id: runId });
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('emit is idempotent on dedup_key', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'dedup.event',
|
||||
nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'n1', to: 'n2' }],
|
||||
});
|
||||
const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
|
||||
const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
|
||||
expect(first.length).toBe(1);
|
||||
expect(second.length).toBe(0); // same entity → no duplicate run
|
||||
});
|
||||
|
||||
test('gate pauses and resumes via the confirm edge', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'gate.event',
|
||||
nodes: [
|
||||
{ key: 'g1', type: 'trigger' },
|
||||
{ key: 'g2', type: 'gate', config: { type: 'payment_confirm' } },
|
||||
{ key: 'g3', type: 'action', config: { action: 'noop' } },
|
||||
{ key: 'g4', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g1', to: 'g2' },
|
||||
{ from: 'g2', handle: 'confirm', to: 'g3' },
|
||||
{ from: 'g2', handle: 'deny', to: 'g4' },
|
||||
],
|
||||
});
|
||||
// create + start a run directly
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending',
|
||||
context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test',
|
||||
});
|
||||
const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first();
|
||||
await engine.startRun(run0.id);
|
||||
|
||||
let run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g2');
|
||||
|
||||
await engine.resumeRun(run0.id, { decisionHandle: 'confirm' });
|
||||
run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('runDueWaits resumes only elapsed wait nodes', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'wait.event',
|
||||
nodes: [
|
||||
{ key: 'w1', type: 'trigger' },
|
||||
{ key: 'w2', type: 'wait', config: { delayMinutes: 60 } },
|
||||
{ key: 'w3', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 });
|
||||
const runId = runIds[0];
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
|
||||
expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due
|
||||
|
||||
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
|
||||
const resumed = await engine.runDueWaits();
|
||||
expect(resumed).toBeGreaterThanOrEqual(1);
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('send_email queues a customer mail with business-hours routing', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'mail.event',
|
||||
nodes: [
|
||||
{ key: 'm1', type: 'trigger' },
|
||||
{ key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } },
|
||||
],
|
||||
edges: [{ from: 'm1', to: 'm2' }],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('mail.event', {
|
||||
entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' },
|
||||
});
|
||||
const run = await db('workflow_runs').where({ id: runIds[0] }).first();
|
||||
expect(run.status).toBe('done');
|
||||
const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first();
|
||||
expect(queued).toBeTruthy();
|
||||
const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first();
|
||||
expect(JSON.parse(step.result).respectBusinessHours).toBe(true);
|
||||
});
|
||||
|
||||
test('invoice_paid condition reads the entity', async () => {
|
||||
const registry = require('../../src/services/workflows/registry');
|
||||
const cond = registry.getCondition('invoice_paid');
|
||||
const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) });
|
||||
expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true);
|
||||
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
|
||||
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
|
||||
});
|
||||
|
||||
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'approval.event',
|
||||
nodes: [
|
||||
{ key: 'a1', type: 'trigger' },
|
||||
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
|
||||
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
|
||||
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
|
||||
],
|
||||
edges: [
|
||||
{ from: 'a1', to: 'a2' },
|
||||
{ from: 'a2', handle: 'confirm', to: 'a3' },
|
||||
{ from: 'a2', handle: 'deny', to: 'a4' },
|
||||
],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('approval.event', {
|
||||
entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' },
|
||||
});
|
||||
const runId = runIds[0];
|
||||
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('a2');
|
||||
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
|
||||
expect(approval).toBeTruthy();
|
||||
expect(approval.status).toBe('pending');
|
||||
|
||||
const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first();
|
||||
expect(adminMail).toBeTruthy();
|
||||
|
||||
// Extract the raw token from the emailed confirm link and act on it.
|
||||
const data = JSON.parse(adminMail.email_data);
|
||||
const rawToken = data.confirm_url.split('/').slice(-2)[0];
|
||||
const res = await engine.actByToken(rawToken, 'confirm');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.status).toBe('confirmed');
|
||||
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
|
||||
// A second click is idempotent (already recorded).
|
||||
const again = await engine.actByToken(rawToken, 'confirm');
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
|
||||
const noopLogger = { info() {}, warn() {} };
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
|
||||
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
|
||||
expect(wf).toBeTruthy();
|
||||
expect(!!wf.is_builtin).toBe(true);
|
||||
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
|
||||
|
||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
||||
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
|
||||
expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
|
||||
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
|
||||
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
|
||||
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
|
||||
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
|
||||
expect(all.length).toBe(1);
|
||||
});
|
||||
|
||||
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
|
||||
const noopLogger = { info() {}, warn() {} };
|
||||
|
||||
// Simulate an older, never-touched seed (v1, with a legacy gate node).
|
||||
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
|
||||
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
|
||||
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
|
||||
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
const reseeded = await db('workflows').where({ id: wf.id }).first();
|
||||
expect(reseeded.version).toBe(wf.version + 1); // bumped
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
|
||||
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
|
||||
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
|
||||
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
|
||||
|
||||
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
|
||||
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
|
||||
const before = await db('workflows').where({ id: wf.id }).first();
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
const after = await db('workflows').where({ id: wf.id }).first();
|
||||
expect(after.version).toBe(before.version); // unchanged
|
||||
expect(!!after.enabled).toBe(true); // admin's choice preserved
|
||||
});
|
||||
|
||||
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||
|
||||
// First beta: cutover flows ship DISABLED (legacy paths run until enabled);
|
||||
// they delegate to the proven send functions once turned on.
|
||||
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
|
||||
expect(expiring).toBeTruthy();
|
||||
expect(!!expiring.enabled).toBe(false);
|
||||
expect(expiring.trigger_type).toBe('gallery.expiring');
|
||||
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
|
||||
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
|
||||
|
||||
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
|
||||
expect(expired).toBeTruthy();
|
||||
expect(!!expired.enabled).toBe(false);
|
||||
expect(expired.trigger_type).toBe('gallery.expired');
|
||||
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
|
||||
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
|
||||
|
||||
// Invoice-only booking variant (quote → invoice, no gallery).
|
||||
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
|
||||
expect(invoiceOnly).toBeTruthy();
|
||||
expect(!!invoiceOnly.enabled).toBe(false);
|
||||
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
|
||||
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
|
||||
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
|
||||
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
|
||||
|
||||
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
|
||||
expect(bookingFull).toBeTruthy();
|
||||
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
|
||||
expect(bookingFull.trigger_type).toBe('quote.accepted');
|
||||
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
|
||||
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
|
||||
// Admin review gate guards BOTH document sends (adjust line items, then OK).
|
||||
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
|
||||
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
|
||||
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
|
||||
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
|
||||
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
|
||||
// dispatch is held until the event date after the admin's early OK.
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
|
||||
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
|
||||
expect(bookingSimple).toBeTruthy();
|
||||
expect(bookingSimple.trigger_type).toBe('quote.accepted');
|
||||
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
|
||||
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
|
||||
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
|
||||
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
|
||||
expect(preEvent).toBeTruthy();
|
||||
expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled
|
||||
expect(preEvent.trigger_type).toBe('event.date_approaching');
|
||||
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
|
||||
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
|
||||
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
|
||||
});
|
||||
|
||||
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'event.date_approaching',
|
||||
enabled: true,
|
||||
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'pe1', to: 'pe2' }],
|
||||
});
|
||||
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
|
||||
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
|
||||
|
||||
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
|
||||
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
|
||||
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
|
||||
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
|
||||
|
||||
const emitted = await engine.emitDueEventReminders();
|
||||
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
|
||||
expect(runs.length).toBe(1); // only the in-window event, not the far one
|
||||
|
||||
// Idempotent: a second pass dedups (no duplicate run for the same event).
|
||||
await engine.emitDueEventReminders();
|
||||
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
|
||||
expect(runs2.length).toBe(1);
|
||||
});
|
||||
|
||||
test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => {
|
||||
// Regression: the reminder query used events.customer_account_id, which does
|
||||
// not exist — so an event with only customer_email/host_email got no mail.
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x', expires_at: farFuture,
|
||||
is_active: true, is_archived: false,
|
||||
slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct',
|
||||
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
|
||||
customer_email: 'direct@x.test', // event-level email, NOT a customer_account
|
||||
});
|
||||
const ev = await db('events').where({ slug: 'rem-direct' }).first();
|
||||
|
||||
const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
|
||||
expect(res.sent).toBe(1);
|
||||
const mail = await db('email_queue').where({ event_id: ev.id }).first();
|
||||
expect(mail).toBeTruthy();
|
||||
expect(mail.recipient_email).toBe('direct@x.test');
|
||||
// Idempotent: sent_at stamped → a second call is a no-op.
|
||||
const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
|
||||
expect(again.sent).toBe(0);
|
||||
expect(again.reason).toBe('already_sent');
|
||||
});
|
||||
|
||||
test('reminder template resolves per event type within the chosen group, else group default', async () => {
|
||||
const { _internal } = require('../../src/services/eventReminderService');
|
||||
// Per-type template exists within a custom group → used.
|
||||
await db('email_templates').insert({ template_key: 'promo_wedding' });
|
||||
expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding');
|
||||
// A type with no authored template (in any group) → the group's default.
|
||||
expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default');
|
||||
// Blank group → the default event_reminder group.
|
||||
expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default');
|
||||
// Trailing underscore on the group is tolerated.
|
||||
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
|
||||
});
|
||||
|
||||
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
|
||||
const { _internal } = require('../../src/services/eventReminderService');
|
||||
const p = _internal.composePayload({
|
||||
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
|
||||
recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
|
||||
});
|
||||
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
|
||||
expect(p.event_date).not.toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
|
||||
const webhook = engine.registry.getAction('webhook');
|
||||
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
|
||||
const ctx = (config, vars = {}) => ({
|
||||
run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 },
|
||||
node: { config }, vars, db, logger: { warn() {} },
|
||||
});
|
||||
// No webhook selected → observable skip, not a crash.
|
||||
expect(await webhook(ctx({}))).toMatchObject({ skipped: true });
|
||||
|
||||
// A configured, active webhook subscription.
|
||||
const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' });
|
||||
const [whId] = await db('webhooks').insert({
|
||||
name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test',
|
||||
events: JSON.stringify([]), active: true, created_by: adminId,
|
||||
});
|
||||
|
||||
// Dry run does not enqueue.
|
||||
expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' });
|
||||
expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 });
|
||||
|
||||
// Real run → a pending delivery is enqueued for the worker (which does the
|
||||
// signing + SSRF re-validation + retries).
|
||||
const res = await webhook(ctx({ webhookId: whId }));
|
||||
expect(res.webhook_enqueued).toBe(whId);
|
||||
const del = await db('webhook_deliveries').where({ webhook_id: whId }).first();
|
||||
expect(del).toBeTruthy();
|
||||
expect(del.status).toBe('pending');
|
||||
expect(del.event_type).toBe('workflow.invoice.sent');
|
||||
|
||||
// Inactive / missing subscription → skip.
|
||||
await db('webhooks').where({ id: whId }).update({ active: false });
|
||||
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
|
||||
});
|
||||
|
||||
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
|
||||
const eventReminderService = require('../../src/services/eventReminderService');
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [custId] = await db('customer_accounts').insert({
|
||||
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
|
||||
});
|
||||
// Event with NO inline customer_email / host_email.
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
|
||||
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
|
||||
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
|
||||
});
|
||||
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
|
||||
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
|
||||
|
||||
const res = await eventReminderService.sendReminderForEvent(ev.id);
|
||||
expect(res.sent).toBe(1);
|
||||
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
|
||||
expect(mail).toBeTruthy();
|
||||
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
|
||||
expect(mail.event_id == null).toBe(true);
|
||||
});
|
||||
|
||||
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||
// All built-ins ship disabled → inactive until the admin enables one.
|
||||
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
|
||||
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
|
||||
// Enable one → now active.
|
||||
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true });
|
||||
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
|
||||
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore
|
||||
});
|
||||
|
||||
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
|
||||
// crm_event_reminders_enabled must be on to reach the mutex guard.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
|
||||
.onConflict('setting_key').merge();
|
||||
const eventReminderService = require('../../src/services/eventReminderService');
|
||||
|
||||
// Flow disabled → guard does NOT fire (legacy pass owns reminders).
|
||||
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false);
|
||||
|
||||
// Flow enabled → the pass stands down before doing any work (byWorkflow).
|
||||
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true });
|
||||
const after = await eventReminderService.runEventReminderPass();
|
||||
expect(after.byWorkflow).toBe(true);
|
||||
expect(after.sent).toBe(0);
|
||||
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore
|
||||
});
|
||||
|
||||
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
|
||||
// Two enabled flows on the same trigger — the quote picks one.
|
||||
const chosen = await makeWorkflow({
|
||||
trigger: 'pick.event', enabled: true,
|
||||
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'c1', to: 'c2' }],
|
||||
});
|
||||
const other = await makeWorkflow({
|
||||
trigger: 'pick.event', enabled: true,
|
||||
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'o1', to: 'o2' }],
|
||||
});
|
||||
|
||||
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
|
||||
expect(runIds.length).toBe(1);
|
||||
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
|
||||
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
|
||||
expect(chosenRuns.length).toBe(1); // only the selected flow ran
|
||||
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
|
||||
});
|
||||
|
||||
test('gate decision with no matching edge FAILS the run (not a silent done)', async () => {
|
||||
// Gate has a confirm edge but the deny edge was lost (e.g. a bad import).
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'noedge.event', enabled: true,
|
||||
nodes: [
|
||||
{ key: 'g0', type: 'trigger' },
|
||||
{ key: 'g1', type: 'gate', config: {} },
|
||||
{ key: 'g2', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g0', to: 'g1' },
|
||||
{ from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge
|
||||
],
|
||||
});
|
||||
const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 });
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
|
||||
await engine.actById(approval.id, 'deny'); // deny has no edge
|
||||
const run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('failed'); // loud failure, not a green 'done'
|
||||
expect(run.error).toMatch(/deny.*no matching edge/i);
|
||||
});
|
||||
|
||||
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
|
||||
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
|
||||
// admin can approve at the gate whenever; the run then parks at the wait and
|
||||
// the scheduler dispatches when the date arrives.
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'gatewait.event',
|
||||
nodes: [
|
||||
{ key: 'g0', type: 'trigger' },
|
||||
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
|
||||
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
|
||||
{ key: 'g3', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g0', to: 'g1' },
|
||||
{ from: 'g1', handle: 'confirm', to: 'g2' },
|
||||
{ from: 'g2', to: 'g3' },
|
||||
],
|
||||
});
|
||||
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g1'); // parked at the review gate
|
||||
|
||||
// Admin confirms EARLY (before the wait date).
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
|
||||
await engine.actById(approval.id, 'confirm');
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
|
||||
|
||||
// Date arrives → scheduler dispatches.
|
||||
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
|
||||
await engine.runDueWaits();
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'recover.event',
|
||||
nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'r1', to: 'r2' }],
|
||||
});
|
||||
// Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
|
||||
context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
});
|
||||
const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
|
||||
const n = await engine.recoverStaleRuns({ staleMs: 1000 });
|
||||
expect(n).toBeGreaterThanOrEqual(1);
|
||||
const run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'crashloop.event',
|
||||
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
|
||||
edges: [{ from: 'c1', to: 'c2' }],
|
||||
});
|
||||
await db('workflow_runs').insert({
|
||||
workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
|
||||
context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
});
|
||||
const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
|
||||
await engine.recoverStaleRuns({ staleMs: 1000 });
|
||||
const run = await db('workflow_runs').where({ id: run0.id }).first();
|
||||
expect(run.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'testfire.event',
|
||||
nodes: [
|
||||
{ key: 't', type: 'trigger' },
|
||||
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
|
||||
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
|
||||
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
|
||||
{ key: 'end', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 't', to: 'w' },
|
||||
{ from: 'w', to: 'g' },
|
||||
{ from: 'g', handle: 'confirm', to: 'a' },
|
||||
{ from: 'g', handle: 'deny', to: 'end' },
|
||||
{ from: 'a', to: 'end' },
|
||||
],
|
||||
});
|
||||
const runId = await engine.testRun(wfId, { dryRun: true });
|
||||
const run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
|
||||
|
||||
const steps = await db('workflow_run_steps').where({ run_id: runId });
|
||||
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
|
||||
const emailStep = steps.find((s) => s.node_key === 'a');
|
||||
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals).
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
let noPermToken;
|
||||
|
||||
const sampleGraph = {
|
||||
name: 'Test flow',
|
||||
trigger_type: 'invoice.sent',
|
||||
enabled: false,
|
||||
nodes: [
|
||||
{ node_key: 'n1', type: 'trigger' },
|
||||
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [{ from_node: 'n1', to_node: 'n2' }],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'norole', email: 'nr@example.com', password_hash: 'x',
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
|
||||
|
||||
await db('feature_flags').insert({ key: 'workflows', value: true });
|
||||
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
|
||||
});
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (t) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
describe('admin workflows API', () => {
|
||||
let createdId;
|
||||
|
||||
test('create → 201 with id', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBeGreaterThan(0);
|
||||
createdId = res.body.id;
|
||||
});
|
||||
|
||||
test('rejects a graph without exactly one trigger', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token))
|
||||
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('rejects an unknown node type', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(token))
|
||||
.send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/unknown node type/i);
|
||||
});
|
||||
|
||||
test('refuses to enable a flow that uses an unregistered action', async () => {
|
||||
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
|
||||
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
|
||||
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }],
|
||||
edges: [{ from_node: 't', to_node: 'a' }],
|
||||
});
|
||||
expect(create.status).toBe(201);
|
||||
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i);
|
||||
});
|
||||
|
||||
test('allows enabling a flow using the now-implemented booking invoice actions', async () => {
|
||||
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
|
||||
name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false,
|
||||
nodes: [
|
||||
{ node_key: 't', type: 'trigger' },
|
||||
{ node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } },
|
||||
{ node_key: 'g', type: 'gate', config: {} },
|
||||
{ node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } },
|
||||
],
|
||||
edges: [
|
||||
{ from_node: 't', to_node: 'p' },
|
||||
{ from_node: 'p', to_node: 'g' },
|
||||
{ from_node: 'g', from_handle: 'confirm', to_node: 's' },
|
||||
],
|
||||
});
|
||||
expect(create.status).toBe(201);
|
||||
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('get one returns the graph', async () => {
|
||||
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodes).toHaveLength(2);
|
||||
expect(res.body.edges).toHaveLength(1);
|
||||
expect(res.body.version).toBe(1);
|
||||
});
|
||||
|
||||
test('list includes it', async () => {
|
||||
const res = await request(app).get('/api/admin/workflows').set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.some((w) => w.id === createdId)).toBe(true);
|
||||
});
|
||||
|
||||
test('update bumps the version', async () => {
|
||||
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
|
||||
.send({ ...sampleGraph, name: 'Renamed' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.version).toBe(2);
|
||||
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
|
||||
expect(get.body.name).toBe('Renamed');
|
||||
expect(get.body.version).toBe(2);
|
||||
});
|
||||
|
||||
test('enable toggle', async () => {
|
||||
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('approvals inbox returns an array', async () => {
|
||||
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
test('a role without workflows.manage is forbidden from writing', async () => {
|
||||
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Regression test for the bulk archive/delete ownership bypass.
|
||||
*
|
||||
* bulk-archive and bulk-delete acted on body-supplied event ids with no
|
||||
* ownership filter, so an admin/editor scoped to their own events (the
|
||||
* single-event routes enforce requireEventOwnership) could archive or
|
||||
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
|
||||
* routes now use to drop foreign/non-existent ids.
|
||||
*/
|
||||
|
||||
// events owned by admin 7; event 3 owned by someone else; event 4 is
|
||||
// ownerless (legacy). The mock models:
|
||||
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
|
||||
const EVENTS = [
|
||||
{ id: 1, created_by: 7 },
|
||||
{ id: 2, created_by: 7 },
|
||||
{ id: 3, created_by: 99 }, // foreign
|
||||
{ id: 4, created_by: null }, // ownerless/legacy
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
const q = {
|
||||
_ids: null,
|
||||
_adminId: null,
|
||||
whereIn(_col, ids) { this._ids = ids; return this; },
|
||||
andWhere(cb) {
|
||||
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
|
||||
// by capturing the admin id the callback closes over via a probe.
|
||||
const probe = {
|
||||
_adminId: null,
|
||||
whereNull() { return this; },
|
||||
orWhere(_col, id) { this._adminId = id; return this; },
|
||||
};
|
||||
cb(probe);
|
||||
this._adminId = probe._adminId;
|
||||
return this;
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve(
|
||||
EVENTS
|
||||
.filter((e) => this._ids.includes(e.id))
|
||||
.filter((e) => e.created_by === null || e.created_by === this._adminId)
|
||||
.map((e) => ({ id: e.id }))
|
||||
);
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
}));
|
||||
|
||||
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
|
||||
|
||||
describe('filterOwnedEventIds', () => {
|
||||
it('super_admin gets every id, nothing denied', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
|
||||
);
|
||||
expect(allowed).toEqual([1, 3, 4, 999]);
|
||||
expect(denied).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
|
||||
);
|
||||
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
|
||||
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
|
||||
});
|
||||
|
||||
it('foreign-only request yields empty allowed', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'editor' }, [3]
|
||||
);
|
||||
expect(allowed).toEqual([]);
|
||||
expect(denied).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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,345 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-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 || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Pin the date-field normalisation in adminUsers transformer (#485).
|
||||
*
|
||||
* The Users page crashed on native/SQLite installs because Postgres
|
||||
* returned ISO strings while SQLite returned epoch-millisecond
|
||||
* integers, and the frontend `parseISO()` blew up on numbers with
|
||||
* "e.split is not a function". The transformer now coerces every
|
||||
* shape to an ISO 8601 string before serialising.
|
||||
*
|
||||
* These tests guard the contract so a future refactor can't quietly
|
||||
* regress and re-break the same page on the same DB.
|
||||
*/
|
||||
|
||||
const adminUsersRoute = require('../../src/routes/adminUsers');
|
||||
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
|
||||
|
||||
describe('toIso', () => {
|
||||
it('passes null and undefined through unchanged', () => {
|
||||
expect(toIso(null)).toBeNull();
|
||||
expect(toIso(undefined)).toBeUndefined();
|
||||
// Empty string also short-circuits — important so an unset
|
||||
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
|
||||
expect(toIso('')).toBe('');
|
||||
});
|
||||
|
||||
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
|
||||
// 2026-05-14T10:00:00.000Z, in epoch ms.
|
||||
const epochMs = 1778752800000;
|
||||
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('coerces a stringified large integer to an ISO 8601 string', () => {
|
||||
// Some SQLite drivers stringify large integers because they
|
||||
// overflow JS safe-integer in the driver's serialiser. Re-coerce
|
||||
// so the frontend doesn't try to parseISO('1778752800000').
|
||||
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('coerces a Date instance via toISOString', () => {
|
||||
const d = new Date('2026-01-01T12:34:56.000Z');
|
||||
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
|
||||
});
|
||||
|
||||
it('passes an existing ISO string through unchanged', () => {
|
||||
const iso = '2026-05-14T10:00:00.000Z';
|
||||
expect(toIso(iso)).toBe(iso);
|
||||
});
|
||||
|
||||
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
|
||||
// Defensive: anything that isn't a 10+ digit integer string is
|
||||
// treated as already-stringified — the date library will surface
|
||||
// the failure cleanly if it's malformed, rather than the
|
||||
// transformer silently rewriting it.
|
||||
expect(toIso('2026-05-14')).toBe('2026-05-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformUser', () => {
|
||||
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
|
||||
const sqliteRow = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
is_active: 1,
|
||||
last_login: 1778752800000, // epoch ms
|
||||
last_login_ip: '127.0.0.1',
|
||||
created_at: 1778751144600, // epoch ms
|
||||
updated_at: 1778751242320, // epoch ms
|
||||
role_id: 1,
|
||||
role_name: 'super_admin',
|
||||
role_display_name: 'Super Admin',
|
||||
created_by_username: null,
|
||||
};
|
||||
|
||||
const out = transformUser(sqliteRow);
|
||||
|
||||
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
||||
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
// Other fields untouched.
|
||||
expect(out.username).toBe('admin');
|
||||
expect(out.lastLoginIp).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('leaves Postgres ISO strings intact', () => {
|
||||
const pgRow = {
|
||||
id: 2,
|
||||
username: 'second',
|
||||
email: 'second@example.com',
|
||||
is_active: true,
|
||||
last_login: '2026-05-14T10:00:00.000Z',
|
||||
created_at: '2026-05-13T08:00:00.000Z',
|
||||
updated_at: '2026-05-14T09:00:00.000Z',
|
||||
};
|
||||
const out = transformUser(pgRow);
|
||||
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
||||
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
|
||||
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps last_login null when the user has never logged in', () => {
|
||||
const out = transformUser({
|
||||
id: 3, username: 'fresh', email: 'fresh@example.com',
|
||||
is_active: 1, last_login: null,
|
||||
});
|
||||
expect(out.lastLogin).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInvitation', () => {
|
||||
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
|
||||
const out = transformInvitation({
|
||||
id: 9,
|
||||
email: 'invitee@example.com',
|
||||
expires_at: 1779357600000,
|
||||
created_at: 1778752800000,
|
||||
role_name: 'admin',
|
||||
invited_by: 'admin',
|
||||
});
|
||||
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
|
||||
*
|
||||
* Four endpoints on the customer-facing surface:
|
||||
* GET /:token — load contract for signing
|
||||
* POST /:token/sign — in-browser canvas signature submission
|
||||
* POST /:token/upload-signed-pdf — wet-signed PDF upload
|
||||
* GET /:token/pdf — download the contract PDF
|
||||
*
|
||||
* Tests pin the publicTokenGuards.loadActionToken contract per
|
||||
* endpoint and a few endpoint-specific shape assertions. Deeper
|
||||
* service-layer behaviour (PDF generation, signature attachment,
|
||||
* integrity-hash compute) is covered by the contractService unit
|
||||
* tests; here we only assert the HTTP contract.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
|
||||
describe('publicContracts routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let customerId;
|
||||
let contractId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
const inserted = await db('contracts').insert({
|
||||
contract_number: 'K-TEST-0001',
|
||||
customer_account_id: customerId,
|
||||
title: 'Test Booking Confirmation',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
|
||||
});
|
||||
|
||||
describe('GET /:token', () => {
|
||||
it('returns 404 for an unknown well-formed token', async () => {
|
||||
const fakeToken = 'a'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects malformed tokens with 400 before reaching the guard', async () => {
|
||||
const res = await request(app).get('/api/public/contracts/short');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 410 for an expired token', async () => {
|
||||
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId, expires_at: past,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/contracts/${token}`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
||||
});
|
||||
|
||||
it('returns 200 with the contract payload for a valid token', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/contracts/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.contract).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:token/sign', () => {
|
||||
it('rejects missing required fields (name, accepted) with 400', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${token}/sign`)
|
||||
.send({}); // missing name + accepted
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown token on sign', async () => {
|
||||
const fakeToken = 'b'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${fakeToken}/sign`)
|
||||
.send({ name: 'Jane Doe', accepted: true });
|
||||
// Either 404 (token not found) or service-level error mapped to
|
||||
// 4xx — what matters is the request didn't slip past validation.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:token/upload-signed-pdf', () => {
|
||||
it('rejects malformed tokens with 400 before multer runs', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/public/contracts/bad-token/upload-signed-pdf')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown but well-formed token', async () => {
|
||||
const fakeToken = 'c'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:token/pdf', () => {
|
||||
it('returns 404 for an unknown token on PDF download', async () => {
|
||||
const fakeToken = 'd'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 410 for an expired token on PDF download', async () => {
|
||||
const past = new Date(Date.now() - 1000);
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId, expires_at: past,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* HTTP route tests for backend/src/routes/publicPaymentCheck (P0 — #570).
|
||||
*
|
||||
* Two endpoints:
|
||||
* GET /:token — load invoice payment-check view
|
||||
* POST /:token — record customer's "paid / unpaid / partial" claim
|
||||
*
|
||||
* Unlike the quote / contract public routes, payment-check goes
|
||||
* through invoiceService rather than the shared publicTokenGuards.
|
||||
* Tests focus on the validator gates and the unknown-token edge.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paymentcheck-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('publicPaymentCheck routes', () => {
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
let db;
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('GET /:token', () => {
|
||||
it('rejects malformed tokens with 400', async () => {
|
||||
const res = await request(app).get('/api/public/payment-check/short');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns a service-level error for an unknown well-formed token (4xx, not 500)', async () => {
|
||||
const fakeToken = 'a'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/payment-check/${fakeToken}`);
|
||||
// Service throws NotFound or similar — what matters is the
|
||||
// request reaches the service AND isn't an unhandled 500.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:token', () => {
|
||||
it('rejects malformed tokens with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/public/payment-check/short')
|
||||
.send({ action: 'paid_full' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an invalid action with 400', async () => {
|
||||
const validToken = 'b'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/payment-check/${validToken}`)
|
||||
.send({ action: 'maybe' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts the canonical four actions through the validator', async () => {
|
||||
// Each action passes validator (token is well-formed); service
|
||||
// then rejects unknown token with a 4xx — what we're pinning is
|
||||
// the validator doesn't reject any of the canonical actions.
|
||||
const validToken = 'c'.repeat(64);
|
||||
for (const action of ['paid_full', 'paid_with_skonto', 'partial', 'unpaid']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await request(app)
|
||||
.post(`/api/public/payment-check/${validToken}`)
|
||||
.send({ action });
|
||||
// Either succeeds (rare — no real invoice) or service-level
|
||||
// 4xx for unknown token. Must NOT be 400 (which would mean
|
||||
// the validator rejected the action).
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(600);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects negative amountMinor with 400', async () => {
|
||||
// Validator chain: optional({ values: 'falsy' }) means
|
||||
// amountMinor=0 / null / undefined gets skipped (allowed). For
|
||||
// any actually-supplied integer, isInt({ min: 1 }) takes over —
|
||||
// pin the negative-rejection so a future refactor can't loosen
|
||||
// the lower bound silently.
|
||||
const validToken = 'd'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/payment-check/${validToken}`)
|
||||
.send({ action: 'partial', amountMinor: -100 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* HTTP route tests for backend/src/routes/publicQuotes (P0 — #570).
|
||||
*
|
||||
* Public token guards (publicTokenGuards.loadActionToken) are the most
|
||||
* security-sensitive surface in the CRM module — these are the routes
|
||||
* a customer hits via the link in the quote email, reachable from any
|
||||
* IP with the raw token. A regression here means leaked tokens become
|
||||
* permanently usable, or worse, an expired token starts working again.
|
||||
*
|
||||
* Tests pin the contract documented in publicTokenGuards.js:
|
||||
* - 404 on unknown token (and IP bad-attempt counter ticks)
|
||||
* - 410 on expired token
|
||||
* - 410 on NULL expiry (defensive — historical bug)
|
||||
* - 429 after 20 invalid attempts from one IP
|
||||
* - 200 + sanitised payload on valid token
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// MUST set the test DB env BEFORE the first require of anything that
|
||||
// pulls in db.js — knexfile reads TEST_DATABASE_PATH at module-init
|
||||
// time. The helper's bootCrmDb also has to be called once per file
|
||||
// because the db module is cached; calling it from a second describe
|
||||
// would silently reuse (or kill) the first instance's connection pool.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubquotes-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
|
||||
describe('publicQuotes routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let customerId;
|
||||
let quoteId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
const inserted = await db('quotes').insert({
|
||||
quote_number: 'Q-TEST-0001',
|
||||
customer_account_id: customerId,
|
||||
currency: 'CHF',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
net_amount_minor: 10000,
|
||||
vat_amount_minor: 0,
|
||||
total_amount_minor: 10000,
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
quoteId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// Clear the in-memory IP bad-attempts map between scenarios so the
|
||||
// lockout test starts from a known state — and so it doesn't bleed
|
||||
// 429s into the unrelated tests that follow.
|
||||
beforeEach(() => {
|
||||
if (tokenGuards._internal?.badAttempts) {
|
||||
tokenGuards._internal.badAttempts.clear();
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /:token', () => {
|
||||
it('returns 404 for an unknown but well-formed token', async () => {
|
||||
const fakeToken = 'a'.repeat(64);
|
||||
const res = await request(app).get(`/api/public/quotes/${fakeToken}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects malformed (non-64-hex) tokens with 400', async () => {
|
||||
const res = await request(app).get('/api/public/quotes/not-a-real-token');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 410 for a token whose expires_at is in the past', async () => {
|
||||
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', {
|
||||
quote_id: quoteId, expires_at: past,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/quotes/${token}`);
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
||||
});
|
||||
|
||||
// The NULL-expiry guard in loadActionToken is intentionally
|
||||
// defensive but the current schema declares
|
||||
// quote_action_tokens.expires_at NOT NULL — so the defensive
|
||||
// branch is unreachable at the route level. Test it directly
|
||||
// against loadActionToken in a unit suite if you want coverage.
|
||||
|
||||
it('returns 200 with a sanitised quote payload for a valid token', async () => {
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', {
|
||||
quote_id: quoteId,
|
||||
});
|
||||
const res = await request(app).get(`/api/public/quotes/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.quote).toBeDefined();
|
||||
// API uses camelCase on the public view (see publicQuoteView in
|
||||
// the route handler).
|
||||
expect(res.body.quote.quoteNumber).toBe('Q-TEST-0001');
|
||||
// Internal IDs / admin metadata must NOT appear on the public payload
|
||||
expect(res.body.quote.customer_account_id).toBeUndefined();
|
||||
expect(res.body.quote.customerAccountId).toBeUndefined();
|
||||
expect(res.body.quote.createdByAdminId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('locks the IP after 20 invalid token lookups (429 TOKEN_LOOKUP_LOCKED)', async () => {
|
||||
const fakeToken = 'b'.repeat(64);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const r = await request(app)
|
||||
.get(`/api/public/quotes/${fakeToken}`)
|
||||
.set('X-Forwarded-For', '203.0.113.10');
|
||||
expect(r.status).toBe(404);
|
||||
}
|
||||
const locked = await request(app)
|
||||
.get(`/api/public/quotes/${fakeToken}`)
|
||||
.set('X-Forwarded-For', '203.0.113.10');
|
||||
expect(locked.status).toBe(429);
|
||||
expect(locked.body.code).toBe('TOKEN_LOOKUP_LOCKED');
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('POST /:token/respond', () => {
|
||||
it('rejects an invalid action (must be accept|decline) with 400', async () => {
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', { quote_id: quoteId });
|
||||
const res = await request(app)
|
||||
.post(`/api/public/quotes/${token}/respond`)
|
||||
.send({ action: 'maybe' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown token on respond', async () => {
|
||||
const fakeToken = 'c'.repeat(64);
|
||||
const res = await request(app)
|
||||
.post(`/api/public/quotes/${fakeToken}/respond`)
|
||||
.send({ action: 'accept' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 410 when the token has expired (service-side check)', async () => {
|
||||
// The POST path goes through quoteService.recordResponse rather
|
||||
// than loadActionToken, so the error shape can differ from the
|
||||
// GET expiry response — what matters is the HTTP status.
|
||||
const past = new Date(Date.now() - 1000);
|
||||
const token = await createPublicToken(db, 'quote_action_tokens', {
|
||||
quote_id: quoteId, expires_at: past,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/quotes/${token}/respond`)
|
||||
.send({ action: 'accept' });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* HTTP route tests for the ADMIN Live Slideshow endpoints:
|
||||
* POST /api/admin/events/:id/slideshow/generate
|
||||
* POST /api/admin/events/:id/slideshow/disable
|
||||
* PATCH /api/admin/events/:id/slideshow
|
||||
* PUT /api/admin/settings/slideshow (global preset + watermark + fit)
|
||||
*
|
||||
* Pins the contracts + the two regressions hit during the build:
|
||||
* - the events table has NO `updated_at` column, so these writes must NOT set
|
||||
* it (else every call 500s — that was the original "Generate" failure);
|
||||
* - the `slideshow` feature flag gates these endpoints (403 when off);
|
||||
* - PUT /admin/settings/slideshow validates + clamps every key.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-admin-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||
|
||||
async function setFlag(db, key, on) {
|
||||
await db('feature_flags').where({ key }).del();
|
||||
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
|
||||
invalidateFeatureFlagCache();
|
||||
}
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('admin Live Slideshow endpoints', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
// Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently
|
||||
// exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise
|
||||
// it so this doesn't block PRs.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').del();
|
||||
await db('app_settings').del();
|
||||
await setFlag(db, 'slideshow', true);
|
||||
});
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
describe('generate / disable', () => {
|
||||
it('mints a share token (no updated_at column → must not 500)', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.show_share_token).toBe('string');
|
||||
expect(res.body.show_share_token).toHaveLength(64);
|
||||
expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_share_token).toBe(res.body.show_share_token);
|
||||
});
|
||||
|
||||
it('regenerate rotates the token', async () => {
|
||||
const id = await insertEvent(db, adminId, { show_share_token: 'old-token' });
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.show_share_token).not.toBe('old-token');
|
||||
});
|
||||
|
||||
it('disable nulls the token', async () => {
|
||||
const id = await insertEvent(db, adminId, { show_share_token: 'live-token' });
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`));
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_share_token == null).toBe(true);
|
||||
});
|
||||
|
||||
it('403 when the slideshow feature is off', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
await setFlag(db, 'slideshow', false);
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('401 without an admin token', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /:id/slideshow', () => {
|
||||
it('persists display + watermark mode (no updated_at column → must not 500)', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({
|
||||
show_interval_ms: 9000,
|
||||
show_transition: 'cut',
|
||||
show_transition_ms: 300,
|
||||
show_watermark: true,
|
||||
show_colorfilter: 'bw',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_interval_ms).toBe(9000);
|
||||
expect(row.show_transition).toBe('cut');
|
||||
expect(row.show_transition_ms).toBe(300);
|
||||
expect(row.show_colorfilter).toBe('bw');
|
||||
expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true);
|
||||
});
|
||||
|
||||
it('show_watermark=null sets the column to NULL (inherit global)', async () => {
|
||||
const id = await insertEvent(db, adminId, { show_watermark: 1 });
|
||||
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.show_watermark == null).toBe(true);
|
||||
});
|
||||
|
||||
it('400 on an invalid transition', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/settings/slideshow', () => {
|
||||
const getSetting = async (key) => {
|
||||
const row = await db('app_settings').where({ setting_key: key }).first();
|
||||
return row ? JSON.parse(row.setting_value) : undefined;
|
||||
};
|
||||
|
||||
it('persists the global preset + watermark + fit, clamping out-of-range values', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
|
||||
slideshow_fit: 'contain',
|
||||
slideshow_interval_ms: 9000,
|
||||
slideshow_transition: 'slide',
|
||||
slideshow_transition_ms: 250,
|
||||
slideshow_colorfilter: 'sepia',
|
||||
slideshow_watermark_enabled: true,
|
||||
slideshow_watermark_opacity: 999, // clamp -> 100
|
||||
slideshow_watermark_size: 99, // clamp -> 40
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await getSetting('slideshow_fit')).toBe('contain');
|
||||
expect(await getSetting('slideshow_interval_ms')).toBe(9000);
|
||||
expect(await getSetting('slideshow_transition')).toBe('slide');
|
||||
expect(await getSetting('slideshow_transition_ms')).toBe(250);
|
||||
expect(await getSetting('slideshow_colorfilter')).toBe('sepia');
|
||||
expect(await getSetting('slideshow_watermark_enabled')).toBe(true);
|
||||
expect(await getSetting('slideshow_watermark_opacity')).toBe(100);
|
||||
expect(await getSetting('slideshow_watermark_size')).toBe(40);
|
||||
});
|
||||
|
||||
it('coerces an invalid fit / transition to the safe default', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
|
||||
slideshow_fit: 'banana',
|
||||
slideshow_transition: 'wormhole',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await getSetting('slideshow_fit')).toBe('cover');
|
||||
expect(await getSetting('slideshow_transition')).toBe('crossfade');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
|
||||
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
|
||||
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
|
||||
*
|
||||
* These pin the two pieces of logic where real bugs lived during the build:
|
||||
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
|
||||
* (404 when off), plus token / expiry / draft / archived / inactive guards.
|
||||
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
|
||||
* image fit, and the fact that globals are read from `app_settings`
|
||||
* (regression for the getSetting→nonexistent-`settings`-table bug).
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-pub-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
|
||||
|
||||
const SLUG = 'wedding-test';
|
||||
const TOKEN = 'show-tok-abcdef';
|
||||
|
||||
async function setFlag(db, key, on) {
|
||||
await db('feature_flags').where({ key }).del();
|
||||
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
|
||||
invalidateFeatureFlagCache();
|
||||
}
|
||||
|
||||
async function setSetting(db, key, value, type = 'slideshow') {
|
||||
await db('app_settings').where({ setting_key: key }).del();
|
||||
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
|
||||
}
|
||||
|
||||
async function insertEvent(db, over = {}) {
|
||||
const base = {
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
show_share_token: TOKEN,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('public Live Slideshow routes', () => {
|
||||
let db; let cleanup; let app;
|
||||
|
||||
// bootCrmDb runs the full migration set against a fresh SQLite file, which
|
||||
// takes <2s locally but has been observed to exceed Jest's default 5s
|
||||
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
|
||||
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
|
||||
// block PRs on CI; doesn't affect happy-path local runs.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
// Both routers mount under /api/gallery in production; the display-only
|
||||
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').del();
|
||||
await db('app_settings').del();
|
||||
await db('feature_flags').del();
|
||||
invalidateFeatureFlagCache();
|
||||
invalidateSlideshowGlobals();
|
||||
await setFlag(db, 'slideshow', true);
|
||||
});
|
||||
|
||||
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
|
||||
|
||||
describe('resolveSlideshow guards', () => {
|
||||
it('200 + per-event display settings on a live link', async () => {
|
||||
await insertEvent(db, {
|
||||
show_interval_ms: 8000,
|
||||
show_transition: 'kenburns',
|
||||
show_transition_ms: 1200,
|
||||
show_colorfilter: 'sepia',
|
||||
});
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
interval_ms: 8000,
|
||||
transition: 'kenburns',
|
||||
transition_ms: 1200,
|
||||
colorfilter: 'sepia',
|
||||
fit: 'cover',
|
||||
photo_count: 0,
|
||||
watermark: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
|
||||
await insertEvent(db);
|
||||
await setFlag(db, 'slideshow', false);
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 on an unknown token', async () => {
|
||||
await insertEvent(db);
|
||||
const res = await request(app).get(stateUrl('not-the-token'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the share token is null (link never minted / disabled)', async () => {
|
||||
await insertEvent(db, { show_share_token: null });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the event has expired', async () => {
|
||||
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the event is a draft', async () => {
|
||||
await insertEvent(db, { is_draft: 1 });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the event is archived', async () => {
|
||||
await insertEvent(db, { is_archived: 1 });
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideshowSettings — image fit (global, live)', () => {
|
||||
it('reflects the global slideshow_fit setting', async () => {
|
||||
await insertEvent(db);
|
||||
await setSetting(db, 'slideshow_fit', 'contain');
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.fit).toBe('contain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
|
||||
async function enableGlobalWatermark() {
|
||||
await setSetting(db, 'slideshow_watermark_enabled', true);
|
||||
await setSetting(db, 'slideshow_watermark_source', 'logo');
|
||||
await setSetting(db, 'slideshow_watermark_position', 'top-left');
|
||||
await setSetting(db, 'slideshow_watermark_opacity', 40);
|
||||
await setSetting(db, 'slideshow_watermark_style', 'original');
|
||||
await setSetting(db, 'slideshow_watermark_size', 9);
|
||||
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
|
||||
}
|
||||
|
||||
it('inherits the global watermark when show_watermark is NULL', async () => {
|
||||
await insertEvent(db, { show_watermark: null });
|
||||
await enableGlobalWatermark();
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).toEqual({
|
||||
url: '/uploads/logos/light.svg',
|
||||
position: 'top-left',
|
||||
opacity: 40,
|
||||
style: 'original',
|
||||
size: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the dark logo / favicon sources', async () => {
|
||||
await insertEvent(db, { show_watermark: null });
|
||||
await enableGlobalWatermark();
|
||||
await setSetting(db, 'slideshow_watermark_source', 'favicon');
|
||||
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
|
||||
});
|
||||
|
||||
it('per-event OFF override hides the watermark even when the global is on', async () => {
|
||||
await insertEvent(db, { show_watermark: 0 });
|
||||
await enableGlobalWatermark();
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).toBeNull();
|
||||
});
|
||||
|
||||
it('per-event ON override shows the watermark even when the global is off', async () => {
|
||||
await insertEvent(db, { show_watermark: 1 });
|
||||
await enableGlobalWatermark();
|
||||
await setSetting(db, 'slideshow_watermark_enabled', false);
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).not.toBeNull();
|
||||
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
|
||||
});
|
||||
|
||||
it('null when enabled but no logo URL is configured', async () => {
|
||||
await insertEvent(db, { show_watermark: null });
|
||||
await setSetting(db, 'slideshow_watermark_enabled', true);
|
||||
// no branding_logo_url set
|
||||
const res = await request(app).get(stateUrl());
|
||||
expect(res.body.watermark).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('display-only token guards (#646 review concern 1)', () => {
|
||||
// Mint a real slideshow JWT, then prove it is denied on the
|
||||
// download / upload / feedback routes (display-only contract).
|
||||
async function slideshowJwt() {
|
||||
await insertEvent(db);
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.token;
|
||||
}
|
||||
|
||||
it('403 on whole-gallery download', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 on single-photo download', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 on bulk download-selected', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 on feedback POST', async () => {
|
||||
const jwt = await slideshowJwt();
|
||||
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /session', () => {
|
||||
it('mints a token + sets the gallery cookie on a valid link', async () => {
|
||||
await insertEvent(db);
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.token.length).toBeGreaterThan(20);
|
||||
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
|
||||
expect(res.body).toHaveProperty('settings');
|
||||
expect(res.body).toHaveProperty('photo_count', 0);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
it('404 when the feature is off', async () => {
|
||||
await insertEvent(db);
|
||||
await setFlag(db, 'slideshow', false);
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Verifies the backup-integrity check covers every CRM document
|
||||
* artefact column and correctly buckets each row into:
|
||||
* - verifiedOk — file exists AND hash matches (when hash is stored)
|
||||
* - missing — `*_path` set but file is not on disk
|
||||
* - hashMismatches — file exists but bytes don't hash to `*_sha256`
|
||||
* - existsButNoHash — file exists, no `*_sha256` column for this row
|
||||
*
|
||||
* Uses the CRM integration harness (bootCrmDb) so the schema +
|
||||
* STORAGE_PATH wiring exactly mirrors production behaviour.
|
||||
*
|
||||
* Background: this service is the diagnostic for the
|
||||
* `storage/business-docs/` gap fixed in the same PR — without it,
|
||||
* a restored install would have audit-trail columns referencing
|
||||
* files that no longer exist, but admins would have no way to see
|
||||
* the breakage until a customer asked for their contract back.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let customerId;
|
||||
let storagePath;
|
||||
let backupIntegrityService;
|
||||
|
||||
function seedFile(relPath, content) {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return { abs, relPath, sha: sha256(content) };
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupIntegrityService = require('../../src/services/backupIntegrityService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Wipe CRM rows between tests so each scenario sees a clean slate.
|
||||
// Order matters: child tables before parents.
|
||||
await db('invoice_line_items').del().catch(() => {});
|
||||
await db('invoice_payment_log').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quote_line_items').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
await db('contracts').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns an empty report when no documents reference any path', async () => {
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts();
|
||||
expect(report.summary.totalRows).toBe(0);
|
||||
expect(report.summary.verifiedOk).toBe(0);
|
||||
expect(report.missing).toEqual([]);
|
||||
expect(report.hashMismatches).toEqual([]);
|
||||
expect(report.existsButNoHash).toEqual([]);
|
||||
expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice']));
|
||||
});
|
||||
|
||||
it('flags a contract whose signed_pdf_path file is missing', async () => {
|
||||
// Reference a file that we deliberately never create on disk.
|
||||
// knex's `.returning('id')` returns `[{ id: N }]` on Postgres and
|
||||
// newer SQLite, but `[N]` (plain int) on some SQLite versions —
|
||||
// unwrap both shapes the same way the crmDb test harness does.
|
||||
const inserted = await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
const hit = report.missing.find((m) => m.rowId === contractId);
|
||||
expect(hit).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf',
|
||||
});
|
||||
expect(report.summary.missingFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('verifies a contract whose file exists AND hash matches', async () => {
|
||||
const { relPath, sha } = seedFile(
|
||||
'business-docs/contract/2026/C-2026-OK.pdf',
|
||||
'this is the signed contract content',
|
||||
);
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-OK',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: relPath,
|
||||
signed_pdf_sha256: sha,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1);
|
||||
expect(report.summary.missingFiles).toBe(0);
|
||||
expect(report.summary.hashMismatches).toBe(0);
|
||||
});
|
||||
|
||||
it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => {
|
||||
const { relPath } = seedFile(
|
||||
'business-docs/contract/2026/C-2026-TAMPER.pdf',
|
||||
'tampered bytes on disk',
|
||||
);
|
||||
const inserted = await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-TAMPER',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: relPath,
|
||||
// Hash for completely different content — simulates tampering or
|
||||
// bit-rot between sign-time and now.
|
||||
signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
const hit = report.hashMismatches.find((m) => m.rowId === contractId);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit.expectedSha).not.toBe(hit.actualSha);
|
||||
expect(hit.column).toBe('signed_pdf_path');
|
||||
});
|
||||
|
||||
it('buckets signature PNGs into existsButNoHash (no hash column)', async () => {
|
||||
const { relPath } = seedFile(
|
||||
'business-docs/contract/signatures/99/customer-1700000000000.png',
|
||||
'\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes
|
||||
);
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-SIG',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_customer_signature_path: relPath,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({
|
||||
scope: ['contract-signature'],
|
||||
});
|
||||
expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1);
|
||||
expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok"
|
||||
expect(report.summary.missingFiles).toBe(0);
|
||||
const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path');
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects the scope filter — contract scope skips quote/invoice tables', async () => {
|
||||
// Seed an invoice with a missing pdf_path AND a contract with a
|
||||
// missing signed_pdf_path. Scoping to contract should only flag
|
||||
// the contract.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-2026-SCOPE',
|
||||
status: 'sent',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
expect(report.scopes).toEqual(['contract']);
|
||||
expect(report.missing.every((m) => m.table === 'contracts')).toBe(true);
|
||||
expect(report.missing.some((m) => m.table === 'invoices')).toBe(false);
|
||||
});
|
||||
|
||||
it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => {
|
||||
// Imported invoices are the most catastrophic case — there's no
|
||||
// renderer that can reproduce them. Verifier must check this column
|
||||
// alongside invoices.pdf_path.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'IMP-2025-001',
|
||||
status: 'sent',
|
||||
imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf',
|
||||
issue_date: '2025-06-01',
|
||||
due_date: '2025-07-01',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] });
|
||||
const hit = report.missing.find((m) => m.column === 'imported_pdf_path');
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Unit tests for the recipient resolver that routes invoice / Storno /
|
||||
* reminder emails to a bookkeeper address when one is configured,
|
||||
* while keeping the decision-maker (primary email) on CC.
|
||||
*
|
||||
* Pure helper, no DB, no side effects.
|
||||
*/
|
||||
|
||||
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
|
||||
|
||||
describe('resolveBillingRecipients', () => {
|
||||
it('routes to the primary email when no billing_email is set', () => {
|
||||
expect(resolveBillingRecipients({ email: 'bride@example.com' }, null))
|
||||
.toEqual({ to: 'bride@example.com', cc: undefined });
|
||||
});
|
||||
|
||||
it('routes to billing_email and CCs the primary when both are set', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
billing_email: 'books@example.com',
|
||||
}, null)).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['bride@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('folds the per-document cc_pdf_email into the CC list', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
billing_email: 'books@example.com',
|
||||
}, 'advisor@example.com')).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['bride@example.com', 'advisor@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses cc_pdf_email alone when there is no billing_email', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
}, 'advisor@example.com')).toEqual({
|
||||
to: 'bride@example.com',
|
||||
cc: ['advisor@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not CC the primary onto itself when billing_email equals email', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'same@example.com',
|
||||
billing_email: 'same@example.com',
|
||||
}, null)).toEqual({
|
||||
to: 'same@example.com',
|
||||
cc: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('is case-insensitive when deduping addresses', () => {
|
||||
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
|
||||
// practice every mail server treats them as insensitive — and the
|
||||
// admin entering "BRIDE@example.com" in one field and
|
||||
// "bride@example.com" in another should not produce two copies.
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'BRIDE@example.com',
|
||||
billing_email: 'books@example.com',
|
||||
}, 'bride@example.com')).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['BRIDE@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace around the addresses', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: ' bride@example.com ',
|
||||
billing_email: ' books@example.com\n',
|
||||
}, '\tadvisor@example.com ')).toEqual({
|
||||
to: 'books@example.com',
|
||||
cc: ['bride@example.com', 'advisor@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty-string billing_email as not set', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: 'bride@example.com',
|
||||
billing_email: '',
|
||||
}, null)).toEqual({
|
||||
to: 'bride@example.com',
|
||||
cc: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty To when neither email nor billing_email is set', () => {
|
||||
// Caller is responsible for surfacing this — emailProcessor's own
|
||||
// validation will reject the empty recipient. The helper just
|
||||
// refuses to crash.
|
||||
expect(resolveBillingRecipients({}, null))
|
||||
.toEqual({ to: '', cc: undefined });
|
||||
});
|
||||
|
||||
it('tolerates a null customer without throwing', () => {
|
||||
// Per-doc cc alone is never promoted to To: — it stays
|
||||
// supplemental. A missing customer is a caller bug; we just refuse
|
||||
// to crash and let emailProcessor reject the empty recipient.
|
||||
expect(resolveBillingRecipients(null, 'a@b.com'))
|
||||
.toEqual({ to: '', cc: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers in contractService (migration 130).
|
||||
*
|
||||
* The DB-bound CRUD paths (createContract / sendContract /
|
||||
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
|
||||
* manual QA via the admin + public routes. This file covers the
|
||||
* deterministic helpers so regressions in placeholder substitution or
|
||||
* section ordering surface before they leak into a rendered contract.
|
||||
*
|
||||
* The service pulls in DB-bound peers (businessProfileService,
|
||||
* pdfService, emailProcessor) at the top level. We stub the DB layer
|
||||
* + the side-effect peers so the require chain doesn't try to connect
|
||||
* to anything; the helpers under test are pure.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(),
|
||||
logActivity: jest.fn(),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderContractToBuffer: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const { _internal } = require(servicePath);
|
||||
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
|
||||
|
||||
describe('renderTemplatedBody', () => {
|
||||
it('substitutes simple {{var}} placeholders', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Hello {{name}}, due in {{net_days}} days.',
|
||||
{ name: 'Alice', net_days: 30 },
|
||||
)).toBe('Hello Alice, due in 30 days.');
|
||||
});
|
||||
|
||||
it('preserves unknown placeholders literally so admins notice missing fields', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Bill from {{issuer}} to {{customer_name}}',
|
||||
{ issuer: 'PicPeak GmbH' },
|
||||
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
|
||||
});
|
||||
|
||||
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
|
||||
{ has_skonto: true, pct: 2 },
|
||||
)).toBe('Skonto: 2 % on early payment');
|
||||
});
|
||||
|
||||
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
|
||||
{ net_days: 30, has_skonto: false, pct: 2 },
|
||||
)).toBe('Net 30 d.');
|
||||
});
|
||||
|
||||
it('treats missing variables in {{#if}} as falsy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'A{{#if missing}}B{{/if}}C',
|
||||
{ unrelated: 'foo' },
|
||||
)).toBe('AC');
|
||||
});
|
||||
|
||||
it('handles empty strings and missing variables map gracefully', () => {
|
||||
expect(renderTemplatedBody('', { x: 1 })).toBe('');
|
||||
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
|
||||
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('passes through non-string input unchanged', () => {
|
||||
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
|
||||
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('substitutes numeric and falsy variable values as strings', () => {
|
||||
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
|
||||
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECTIONS_ORDER', () => {
|
||||
it('matches the canonical six-section order locked in the spec', () => {
|
||||
expect(SECTIONS_ORDER).toEqual([
|
||||
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
|
||||
const blocksService = require('../../src/services/contractBlocksService');
|
||||
expect([...SECTIONS_ORDER].sort()).toEqual(
|
||||
[...blocksService.ALLOWED_SECTIONS].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
|
||||
*
|
||||
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
|
||||
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
|
||||
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
|
||||
* tuned for tracker scripts — defence-in-depth, even though the field is
|
||||
* admin-only.
|
||||
*/
|
||||
|
||||
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
|
||||
|
||||
describe('sanitizeTrackerSnippet (#663)', () => {
|
||||
test('returns empty string for non-string / empty / whitespace input', () => {
|
||||
expect(sanitizeTrackerSnippet(null)).toBe('');
|
||||
expect(sanitizeTrackerSnippet(undefined)).toBe('');
|
||||
expect(sanitizeTrackerSnippet(42)).toBe('');
|
||||
expect(sanitizeTrackerSnippet('')).toBe('');
|
||||
expect(sanitizeTrackerSnippet(' ')).toBe('');
|
||||
});
|
||||
|
||||
test('passes through a Plausible-style script tag with data-domain', () => {
|
||||
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('src="https://plausible.io/js/script.js"');
|
||||
expect(out).toContain('data-domain="example.com"');
|
||||
expect(out).toContain('defer');
|
||||
});
|
||||
|
||||
test('passes through a Umami-style script with data-website-id', () => {
|
||||
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('src="https://analytics.example.com/script.js"');
|
||||
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
|
||||
});
|
||||
|
||||
test('passes through inline script body unchanged', () => {
|
||||
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('window.GA = "x"');
|
||||
expect(out).toContain('console.log("init")');
|
||||
});
|
||||
|
||||
test('allows <noscript> fallback', () => {
|
||||
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('<noscript>');
|
||||
});
|
||||
|
||||
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
|
||||
const out = sanitizeTrackerSnippet(
|
||||
'<link rel="preconnect" href="https://t.example.com">'
|
||||
+ '<link rel="dns-prefetch" href="https://t.example.com">',
|
||||
);
|
||||
expect(out).toContain('rel="preconnect"');
|
||||
expect(out).toContain('rel="dns-prefetch"');
|
||||
expect(out).toContain('href="https://t.example.com"');
|
||||
});
|
||||
|
||||
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
|
||||
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
|
||||
expect(out).not.toContain('stylesheet');
|
||||
expect(out).not.toContain('href');
|
||||
});
|
||||
|
||||
test('strips disallowed tags entirely', () => {
|
||||
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('iframe');
|
||||
expect(out).not.toContain('<div');
|
||||
expect(out).not.toContain('<h1');
|
||||
});
|
||||
|
||||
test('strips javascript: URLs from script src', () => {
|
||||
const input = '<script src="javascript:alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('strips data: URLs from script src', () => {
|
||||
const input = '<script src="data:text/javascript,alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('data:text/javascript');
|
||||
});
|
||||
|
||||
test('strips on* event-handler attributes (defence-in-depth)', () => {
|
||||
// event-handler attrs are not in our allowlist; sanitize-html strips them.
|
||||
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('onload');
|
||||
expect(out).toContain('src="https://t.example/x.js"');
|
||||
});
|
||||
|
||||
test('returns empty string on unparseable input rather than throwing', () => {
|
||||
// sanitize-html is fault-tolerant — pass deliberately malformed and
|
||||
// confirm we don't blow up.
|
||||
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
|
||||
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Tests for the passive-customer surface:
|
||||
*
|
||||
* - createDirect inserts a customer with password_hash=null,
|
||||
* queueEmail is never called, race-guard rejects duplicates
|
||||
* - createInvitation allows passing through when the existing
|
||||
* customer is passive (promotion path); still rejects when the
|
||||
* existing customer is active (real duplicate)
|
||||
* - acceptInvitation upserts into an existing passive customer
|
||||
* row (preserving id) when one exists; inserts a fresh row
|
||||
* otherwise; still rejects when the existing customer is active
|
||||
*
|
||||
* Pure unit tests — db is mocked via a thenable chain so we can
|
||||
* inspect every insert / update payload without spinning up SQLite.
|
||||
*/
|
||||
|
||||
// ----- mock db chain --------------------------------------------------
|
||||
//
|
||||
// We need fine-grained control over which row each table-name returns
|
||||
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
|
||||
// what `.update(...)` resolves to. The chain is a thenable proxy that
|
||||
// terminates on the call we care about.
|
||||
|
||||
const tableSeeds = {}; // table → first-row return value
|
||||
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
|
||||
const updateCalls = []; // [{ table, where, updates }]
|
||||
let nextInsertId = 1000;
|
||||
|
||||
function resetMockDb() {
|
||||
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
|
||||
for (const k of Object.keys(insertResults)) delete insertResults[k];
|
||||
updateCalls.length = 0;
|
||||
nextInsertId = 1000;
|
||||
}
|
||||
|
||||
function makeChain(tableName) {
|
||||
const chain = {
|
||||
_whereClauses: [],
|
||||
where(...args) { this._whereClauses.push(args); return this; },
|
||||
whereNull() { return this; },
|
||||
whereNot() { return this; },
|
||||
andWhere() { return this; },
|
||||
orderBy() { return this; },
|
||||
leftJoin() { return this; },
|
||||
groupBy() { return this; },
|
||||
select(...args) {
|
||||
// listCustomers / search → return seeded array
|
||||
const seeded = tableSeeds[`${tableName}__select`];
|
||||
return Promise.resolve(seeded || []);
|
||||
},
|
||||
first() {
|
||||
const seeded = tableSeeds[tableName];
|
||||
return Promise.resolve(seeded);
|
||||
},
|
||||
insert(payload) {
|
||||
const id = nextInsertId++;
|
||||
insertResults[tableName] = insertResults[tableName] || [];
|
||||
insertResults[tableName].push({ ...payload, id });
|
||||
const result = { id };
|
||||
return {
|
||||
returning() { return Promise.resolve([result]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
update(updates) {
|
||||
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
|
||||
return Promise.resolve(1);
|
||||
},
|
||||
del() { return Promise.resolve(1); },
|
||||
raw() { return this; },
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
|
||||
mockDbFn.raw = jest.fn();
|
||||
mockDbFn.transaction = async (cb) => cb(mockDbFn);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const mockQueueEmail = jest.fn(async () => {});
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: mockQueueEmail,
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({
|
||||
profile: { default_locale: 'de' },
|
||||
bankAccounts: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const customerAccountsService = require('../../src/services/customerAccountsService');
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockDb();
|
||||
mockQueueEmail.mockClear();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// createDirect
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe('createDirect', () => {
|
||||
it('inserts a customer with password_hash=null, is_active=true', async () => {
|
||||
tableSeeds.customer_accounts = undefined; // no duplicate
|
||||
const result = await customerAccountsService.createDirect({
|
||||
email: 'test@example.com',
|
||||
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
|
||||
createdByAdminId: 5,
|
||||
});
|
||||
expect(result.id).toBeDefined();
|
||||
const inserted = insertResults.customer_accounts[0];
|
||||
expect(inserted.email).toBe('test@example.com');
|
||||
expect(inserted.password_hash).toBeNull();
|
||||
expect(inserted.created_by_admin_id).toBe(5);
|
||||
expect(inserted.first_name).toBe('Anna');
|
||||
expect(inserted.company_name).toBe('ACME GmbH');
|
||||
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
|
||||
expect([true, 1, '1']).toContain(inserted.is_active);
|
||||
});
|
||||
|
||||
it('defaults preferred_language from the business profile', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: 'de@example.com',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
|
||||
});
|
||||
|
||||
it('honours preferred_language when the admin pre-fills it', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: 'fr@example.com',
|
||||
prefill: { preferred_language: 'fr' },
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
|
||||
});
|
||||
|
||||
it('rejects when a customer with the email already exists', async () => {
|
||||
tableSeeds.customer_accounts = { id: 7, email: 'dup@example.com', password_hash: 'whatever' };
|
||||
await expect(customerAccountsService.createDirect({
|
||||
email: 'dup@example.com',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
})).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await expect(customerAccountsService.createDirect({
|
||||
email: '',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
})).rejects.toThrow(/Email is required/);
|
||||
});
|
||||
|
||||
it('NEVER queues an invitation email (regression guard)', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: 'silent@example.com',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(mockQueueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// createInvitation passive-allowance behaviour
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe('createInvitation — duplicate-email guard', () => {
|
||||
it('still rejects when the existing customer has a password (real duplicate)', async () => {
|
||||
tableSeeds.customer_accounts = { id: 1, email: 'active@example.com', password_hash: 'hash' };
|
||||
await expect(customerAccountsService.createInvitation({
|
||||
email: 'active@example.com',
|
||||
invitedById: 5,
|
||||
prefill: null,
|
||||
})).rejects.toThrow(/already exists/);
|
||||
expect(mockQueueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
|
||||
tableSeeds.customer_accounts = { id: 7, email: 'passive@example.com', password_hash: null };
|
||||
// no pending invitation
|
||||
// The chain returns `tableSeeds.customer_invitations` for .first()
|
||||
// and we haven't seeded one, so it's undefined → allowed through.
|
||||
const out = await customerAccountsService.createInvitation({
|
||||
email: 'passive@example.com',
|
||||
invitedById: 9,
|
||||
prefill: { first_name: 'Anna' },
|
||||
});
|
||||
expect(out.id).toBeDefined();
|
||||
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers in customerHoursService (migration
|
||||
* 129). The CRUD paths themselves are exercised end-to-end via the
|
||||
* admin/customers routes during manual QA; this file covers the
|
||||
* deterministic logic so regressions in the rate / duration / lock
|
||||
* resolution show up before they hit a real invoice.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
|
||||
|
||||
// The service imports invoiceService which pulls in the DB. We don't
|
||||
// need either for the pure helpers — stub the DB layer so the
|
||||
// require chain doesn't try to connect to anything.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(),
|
||||
logActivity: jest.fn(),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/invoiceService', () => ({}));
|
||||
|
||||
const { _internal } = require(servicePath);
|
||||
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
|
||||
|
||||
describe('computeDurationMinutes', () => {
|
||||
it('returns minute count for a basic window', () => {
|
||||
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
|
||||
});
|
||||
|
||||
it('handles single-minute precision', () => {
|
||||
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
|
||||
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
|
||||
});
|
||||
|
||||
it('rejects malformed input', () => {
|
||||
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
|
||||
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
|
||||
});
|
||||
|
||||
it('rejects zero or negative duration', () => {
|
||||
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
|
||||
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEffectiveRate', () => {
|
||||
it('prefers the per-entry override when set', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: 20000 },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(20000);
|
||||
});
|
||||
|
||||
it('falls back to the customer default when no override', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('throws when override, customer rate, AND install default are all unset', () => {
|
||||
expect(() => resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
null,
|
||||
)).toThrow(/No hourly rate/);
|
||||
});
|
||||
|
||||
it('falls back to the install-wide default when override + customer rate are unset', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
12000,
|
||||
)).toBe(12000);
|
||||
});
|
||||
|
||||
it('customer rate wins over the install-wide default', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
12000,
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('treats override=0 as "explicitly zero" (not null)', () => {
|
||||
// Override === 0 is unusual but legal — pro bono blocks, internal
|
||||
// tracking. Must NOT fall through to the customer default.
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: 0 },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEntryLocked', () => {
|
||||
it('unbilled entry → not locked', () => {
|
||||
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('monthly draft → not locked (still accumulating)', () => {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('standalone draft with no send time → not locked', () => {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('future-scheduled draft → not locked', () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('sent / paid / overdue / cancelled → locked', () => {
|
||||
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
|
||||
)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('entry references a deleted invoice (null) → treat as unbilled', () => {
|
||||
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLineItemFromEntry', () => {
|
||||
const baseEntry = {
|
||||
entry_date: '2026-05-20',
|
||||
start_time: '09:00',
|
||||
end_time: '11:30',
|
||||
duration_minutes: 150,
|
||||
description: 'Editing wedding photos',
|
||||
};
|
||||
|
||||
it('formats the description per spec', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||||
expect(li.description).toBe('2026-05-20 09: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,119 @@
|
||||
/**
|
||||
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
|
||||
* gates the migration-137 calendar time columns on events.
|
||||
*
|
||||
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
|
||||
* helper and write through hasColumnCached guards; those are
|
||||
* exercised in manual QA. This file pins the contract so a future
|
||||
* tweak to the validation rules doesn't silently break it.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
|
||||
|
||||
// Stub every DB-bound peer so the require chain doesn't try to open
|
||||
// a knex connection. The helper under test is pure.
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
|
||||
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
|
||||
|
||||
const { normaliseEventTimeTriple } = require(servicePath);
|
||||
|
||||
describe('normaliseEventTimeTriple', () => {
|
||||
it('defaults to full-day when is_full_day is undefined', () => {
|
||||
expect(normaliseEventTimeTriple({})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forces times to null when is_full_day is true even if times are supplied', () => {
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: true,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '12:00',
|
||||
})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid timed range when is_full_day is false', () => {
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '09:30',
|
||||
event_time_end: '17:00',
|
||||
})).toEqual({
|
||||
event_time_start: '09:30',
|
||||
event_time_end: '17:00',
|
||||
is_full_day: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when is_full_day is false and start is missing/malformed', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '25:00',
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '9:00',
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
});
|
||||
|
||||
it('throws when end is missing or malformed', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '12:99',
|
||||
})).toThrow(/HH:MM/);
|
||||
});
|
||||
|
||||
it('throws when end is at or before start', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '10:00',
|
||||
})).toThrow(/after/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '15:00',
|
||||
event_time_end: '10:00',
|
||||
})).toThrow(/after/);
|
||||
});
|
||||
|
||||
it('parses string boolean flag', () => {
|
||||
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
|
||||
// the helper consumes them transparently.
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: 'false',
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
})).toEqual({
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
is_full_day: false,
|
||||
});
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: '1',
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Unit tests for the accounting money logic — re-bill markup (incoming
|
||||
* invoices) and internal-expense amount/build. Pure functions via _internal.
|
||||
*/
|
||||
const expenseService = require('../../src/services/expenseService');
|
||||
|
||||
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
|
||||
|
||||
describe('computeMarkupMinor', () => {
|
||||
it('percent of base, rounded', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
|
||||
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
|
||||
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
|
||||
});
|
||||
it('flat / none', () => {
|
||||
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
|
||||
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
|
||||
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMarkup precedence (no contract / no DB)', () => {
|
||||
it('override > source clause', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
|
||||
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
|
||||
});
|
||||
it("source clause when no override", async () => {
|
||||
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
|
||||
});
|
||||
it('none when nothing set', async () => {
|
||||
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
|
||||
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeExpenseAmount', () => {
|
||||
it('mileage / per-diem = quantity x rate, rounded', () => {
|
||||
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
|
||||
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
|
||||
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
|
||||
});
|
||||
it('amount = the entered minor amount', () => {
|
||||
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
|
||||
});
|
||||
it('null when quantity or rate missing', () => {
|
||||
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
|
||||
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExpenseInsert (internal expense)', () => {
|
||||
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
|
||||
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
|
||||
expect(row.kind).toBe('amount');
|
||||
expect(row.disposition).toBe('eigener_aufwand');
|
||||
expect(row.tax_treatment).toBe('domestic');
|
||||
expect(row.status).toBe('open');
|
||||
expect(row.chf_amount_minor).toBe(5000);
|
||||
expect(row.created_by_admin_id).toBe(7);
|
||||
expect(row.inbound_document_id).toBeNull();
|
||||
});
|
||||
|
||||
it('mileage uses the override rate, else the settings km rate', () => {
|
||||
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
|
||||
expect(withDefault.rate_minor).toBe(70);
|
||||
expect(withDefault.chf_amount_minor).toBe(2940);
|
||||
|
||||
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
|
||||
expect(withOverride.rate_minor).toBe(100);
|
||||
expect(withOverride.chf_amount_minor).toBe(4200);
|
||||
});
|
||||
|
||||
it('per_diem uses days x per-diem rate', () => {
|
||||
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
|
||||
expect(row.rate_minor).toBe(8000);
|
||||
expect(row.chf_amount_minor).toBe(16000);
|
||||
});
|
||||
|
||||
it('event_id null = booked to company; proof path carried', () => {
|
||||
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
|
||||
expect(company.event_id).toBeNull();
|
||||
expect(company.receipt_path).toBe('/p/x.pdf');
|
||||
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
|
||||
expect(evt.event_id).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInboundLineItem (re-bill line)', () => {
|
||||
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
|
||||
expect(li.unit_price_minor).toBe(11000);
|
||||
expect(li.line_total_minor).toBe(11000);
|
||||
expect(li.quantity).toBe(1);
|
||||
expect(li.description).toBe('ACME (Weiterverrechnung)');
|
||||
});
|
||||
|
||||
it('passthrough: distinct suffix, no markup passes through at cost', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
|
||||
expect(li.unit_price_minor).toBe(5000);
|
||||
expect(li.description).toBe('SBB (Durchlaufende Position)');
|
||||
});
|
||||
|
||||
it('falls back to net amount + generic label when total/supplier missing', () => {
|
||||
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
|
||||
expect(li.unit_price_minor).toBe(7300);
|
||||
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
|
||||
});
|
||||
|
||||
it('throws when there is no amount to re-bill', () => {
|
||||
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
|
||||
.toThrow(/no amount/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
|
||||
const reclaim = ['CH', 'LI'];
|
||||
it('explicit valid treatment always wins', () => {
|
||||
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
|
||||
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
|
||||
});
|
||||
it('country in the reclaim list → domestic', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
|
||||
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
|
||||
});
|
||||
it('country outside the reclaim list → foreign non-reclaimable', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
});
|
||||
it('unknown / empty country falls back to domestic', () => {
|
||||
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
|
||||
});
|
||||
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
|
||||
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
|
||||
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
|
||||
});
|
||||
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
|
||||
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
|
||||
const future = new Date(Date.now() + 86400000).toISOString();
|
||||
const past = new Date(Date.now() - 86400000).toISOString();
|
||||
it('monthly draft and not-yet-armed scheduled are mutable', () => {
|
||||
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
|
||||
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
|
||||
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
|
||||
});
|
||||
it('armed / issued invoices are locked', () => {
|
||||
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
|
||||
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for the migration-119 hierarchy support in invoiceService —
|
||||
* the shared helpers come from quoteService._internal (validated in
|
||||
* quoteService.hierarchy.test.js), so we focus here on the
|
||||
* invoice-specific seams:
|
||||
*
|
||||
* - quote → invoice cloner preserves parent_position + details_text
|
||||
* across the conversion
|
||||
* - the cloner's installment "adjustment" line only reconciles
|
||||
* against TOP-LEVEL cloned items (sub-items don't contribute to
|
||||
* net so they can't appear in the sum)
|
||||
*
|
||||
* Pure helper, no DB.
|
||||
*/
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
|
||||
|
||||
describe('quote → invoice cloner shape', () => {
|
||||
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
|
||||
// take source quote line items (with parent_position) and produce the
|
||||
// `cloned` array that's passed into insertLineItemsHierarchical.
|
||||
function modelCloner(sourceLines) {
|
||||
return sourceLines.map((li) => ({
|
||||
position: parseInt(li.position, 10),
|
||||
quantity: Number(li.quantity || 1),
|
||||
description: li.description,
|
||||
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
|
||||
discount_percent: Number(li.discount_percent || 0),
|
||||
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
|
||||
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
}
|
||||
|
||||
it('preserves parent_position so the hierarchy carries across conversion', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].parent_position).toBeNull();
|
||||
expect(cloned[1].parent_position).toBe(1);
|
||||
expect(cloned[2].parent_position).toBe(1);
|
||||
// The cloned shape passes hierarchy validation — same positions
|
||||
// means the same parent links work without any remap.
|
||||
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves details_text verbatim', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
|
||||
details_text: 'Includes online gallery + 100 high-res downloads.' },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
|
||||
});
|
||||
|
||||
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
|
||||
// Recreate the inner math from scheduleInvoicesForEvent: sum
|
||||
// only line_total_minor where parent_position is null. Sub-items
|
||||
// would otherwise double-count and skew the adjustment.
|
||||
//
|
||||
// Note: the cloner stores raw line_total_minor on each row from
|
||||
// the source quote. By the time this sum runs, the parent's
|
||||
// line_total_minor has already been resolved upstream (via
|
||||
// computeTotals on the quote at save time) — so iterating
|
||||
// top-level only sums the resolved parent totals + standalone
|
||||
// top-level items. Sub-items never contribute here regardless of
|
||||
// whether their parent's total was auto-resolved or not.
|
||||
const cloned = modelCloner([
|
||||
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
|
||||
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
|
||||
// Sub-items €150 + €200 + €100 — shown for transparency, must
|
||||
// NOT enter the reconciliation sum.
|
||||
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
|
||||
// Another top-level €100
|
||||
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
|
||||
]);
|
||||
const clonedSum = cloned
|
||||
.filter((x) => x.parent_position == null)
|
||||
.reduce((s, x) => s + x.line_total_minor, 0);
|
||||
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
|
||||
expect(clonedSum).toBe(55000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLineItemsHierarchical for invoices', () => {
|
||||
function makeTrxMock() {
|
||||
let nextId = 200;
|
||||
const inserts = [];
|
||||
const trx = (tableName) => ({
|
||||
insert(row) {
|
||||
const id = nextId++;
|
||||
inserts.push({ table: tableName, row: { ...row, id } });
|
||||
return {
|
||||
returning() { return Promise.resolve([{ id }]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
});
|
||||
return { trx, inserts };
|
||||
}
|
||||
|
||||
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
|
||||
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||||
]);
|
||||
expect(inserts).toHaveLength(2);
|
||||
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
|
||||
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
|
||||
// Parent inserted first, sub-item second with parent_line_item_id
|
||||
// matching the parent's synthesised id.
|
||||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||||
expect(inserts[1].row.parent_line_item_id).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
|
||||
*
|
||||
* Validation tests run against the pure validator directly. Orchestration
|
||||
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
|
||||
* — chains are queued per table and assertions probe insert/update/delete
|
||||
* call shapes rather than SQL.
|
||||
*/
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
}
|
||||
|
||||
describe('validateInstallmentPlanInput', () => {
|
||||
const { validateInstallmentPlanInput } = invoiceService;
|
||||
|
||||
it('throws on empty array', () => {
|
||||
expect(() => validateInstallmentPlanInput([]))
|
||||
.toThrow(/non-empty array/);
|
||||
});
|
||||
|
||||
it('throws on non-array', () => {
|
||||
expect(() => validateInstallmentPlanInput(null))
|
||||
.toThrow(/non-empty array/);
|
||||
});
|
||||
|
||||
it('throws on out-of-range percent', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
|
||||
])).toThrow(/percent must be between 0 and 100/);
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
|
||||
])).toThrow(/percent must be between 0 and 100/);
|
||||
});
|
||||
|
||||
it('throws on unknown trigger', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
|
||||
])).toThrow(/invalid trigger/);
|
||||
});
|
||||
|
||||
it('throws when percents do not sum to 100', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 50, trigger: 'before_event', offset_days: -7 },
|
||||
])).toThrow(/must sum to 100/);
|
||||
});
|
||||
|
||||
it('accepts a valid three-row plan with mixed triggers', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
|
||||
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
|
||||
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('tolerates 0.001 rounding drift in the sum', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
|
||||
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstallmentPlan — guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
const goodPlan = [
|
||||
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
];
|
||||
|
||||
it('rejects when dealUuid is missing', async () => {
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s when the deal has no invoices', async () => {
|
||||
pickChainFor('invoices')._selectResult = [];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
|
||||
});
|
||||
|
||||
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
||||
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
|
||||
});
|
||||
|
||||
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
||||
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
|
||||
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
|
||||
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
|
||||
});
|
||||
|
||||
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
|
||||
const badPlan = [
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -7 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstallmentPlan — reshape (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
const sibling = (overrides) => ({
|
||||
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
shipping_amount_minor: 0, vat_rate: 7.7,
|
||||
customer_account_id: 5, source_quote_id: null, event_id: null,
|
||||
event_name: 'Wedding', event_date: '2026-08-15',
|
||||
language: 'de', currency: 'CHF',
|
||||
issue_date: '2026-05-25', due_date: '2026-06-24',
|
||||
cc_pdf_email: null,
|
||||
payment_net_days_template_id: null, payment_timing_template_id: null,
|
||||
payment_term_snapshot: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
||||
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2, 3]);
|
||||
expect(result.created).toEqual([]);
|
||||
expect(result.deleted).toEqual([]);
|
||||
// Sequence helper never touched on a same-count reshape.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
installment_total: 2 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
installment_total: 2 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('invoices')._insertResult = [{ id: 99 }];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2]);
|
||||
expect(result.created.length).toBe(1);
|
||||
expect(result.deleted).toEqual([]);
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shrinks 3→2 by deleting the third row + its line items', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
||||
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2]);
|
||||
expect(result.created).toEqual([]);
|
||||
expect(result.deleted).toEqual([3]);
|
||||
// Line items + invoice rows deleted on the trimmed sibling.
|
||||
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
|
||||
expect(pickChainFor('invoices').del).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Tests for invoiceService lock + state-transition guards.
|
||||
*
|
||||
* Focuses on the rules that protect tax/audit integrity:
|
||||
* - reissueInvoice refuses to act on `scheduled` (use Edit)
|
||||
* - reissueInvoice cancels + clones any other status
|
||||
* - releaseForDelivery refuses to act on non-pending_delivery
|
||||
* - recordPaymentCheckAction refuses already-used / expired tokens
|
||||
*
|
||||
* db is deep-mocked so the tests are deterministic and fast.
|
||||
*/
|
||||
|
||||
// Mock db chain: each table call returns a builder whose methods
|
||||
// chain (return `this`) until a terminal method (.first / .update /
|
||||
// .insert / .returning) resolves with the queued value.
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
_allRows: [],
|
||||
// knex chains are thenable — awaiting them runs the query and
|
||||
// resolves with the row set. We mirror that so callers can
|
||||
// `await trx('t').where(...).orderBy(...)` and get an array.
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
// select is both chainable (`.select('col').first()`) and awaitable
|
||||
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
// db.transaction(cb) runs the callback with a "trx" — for our
|
||||
// purposes the same chain factory works as trx.
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
}
|
||||
|
||||
describe('invoiceService.reissueInvoice', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
|
||||
await expect(invoiceService.reissueInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
|
||||
});
|
||||
|
||||
it('throws when the source invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.reissueInvoice(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('cancels the original and creates a new row when status is sent', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'sent', customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de', vat_rate: 7.7,
|
||||
shipping_amount_minor: 0, cc_pdf_email: null,
|
||||
business_bank_account_id: null, qr_format: null,
|
||||
payment_term_template_id: null, event_id: null,
|
||||
source_quote_id: null,
|
||||
};
|
||||
pickChainFor('customer_accounts')._firstValue = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
};
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('app_settings')._firstValue = null;
|
||||
// document_sequences row used by claimNextSequence.
|
||||
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
|
||||
|
||||
const result = await invoiceService.reissueInvoice(1, 42);
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.replaces).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.createStorno', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects when the source invoice does not exist (404)', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.createStorno(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
|
||||
});
|
||||
|
||||
it('rejects when the source is already cancelled (no double-Storno)', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('rejects when asked to Storno a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('inserts a Storno row and flips the original on a sent invoice', async () => {
|
||||
// Original is `sent`, no line items, no event.
|
||||
const invoicesChain = pickChainFor('invoices');
|
||||
invoicesChain._firstValue = {
|
||||
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de', vat_rate: 7.7,
|
||||
net_amount_minor: 30000, vat_amount_minor: 2310,
|
||||
total_amount_minor: 32310, shipping_amount_minor: 0,
|
||||
cc_pdf_email: null, event_id: null,
|
||||
};
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('app_settings')._firstValue = null;
|
||||
// document_sequences row used by claimNextSequence.
|
||||
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
|
||||
|
||||
const stornoId = await invoiceService.createStorno(1, 42);
|
||||
expect(stornoId).toBeDefined();
|
||||
|
||||
// The mock chain's .update() is called twice on `invoices`:
|
||||
// 1) `.insert(...).returning('id')` for the Storno row
|
||||
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
|
||||
// We just verify the helpers were exercised on the right table.
|
||||
expect(invoicesChain.insert).toHaveBeenCalled();
|
||||
expect(invoicesChain.update).toHaveBeenCalled();
|
||||
// The Storno insert payload should carry kind='storno' and
|
||||
// negated row-level totals. Inspect the first insert call's
|
||||
// payload to confirm.
|
||||
const insertedRow = invoicesChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow.kind).toBe('storno');
|
||||
expect(insertedRow.net_amount_minor).toBe(-30000);
|
||||
expect(insertedRow.vat_amount_minor).toBe(-2310);
|
||||
expect(insertedRow.total_amount_minor).toBe(-32310);
|
||||
expect(insertedRow.cancels_invoice_id).toBe(1);
|
||||
expect(insertedRow.status).toBe('scheduled');
|
||||
// No payment instrument on a Storno.
|
||||
expect(insertedRow.business_bank_account_id).toBeNull();
|
||||
expect(insertedRow.qr_format).toBeNull();
|
||||
expect(insertedRow.payment_term_template_id).toBeNull();
|
||||
// Storni have no real payment due, but the schema's NOT NULL
|
||||
// constraint on due_date forces a value — we mirror issue_date.
|
||||
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.cancelInvoice', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects when the invoice does not exist (404)', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.cancelInvoice(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
|
||||
await expect(invoiceService.cancelInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
|
||||
await expect(invoiceService.cancelInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
|
||||
const result = await invoiceService.cancelInvoice(1, 42);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.releaseForDelivery', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('refuses when status is not pending_delivery', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
|
||||
await expect(invoiceService.releaseForDelivery(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
|
||||
it('404s when the invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.releaseForDelivery(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects invalid actions', async () => {
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'abc', action: 'foo',
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s when the token is not on file', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: new Date(),
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
|
||||
});
|
||||
|
||||
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() - 86400000),
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
|
||||
});
|
||||
|
||||
it('rejects partial with amount <= 0', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects partial with amount > outstanding', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('skips when invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res).toEqual({ sent: false, reason: 'not_found' });
|
||||
});
|
||||
|
||||
it('skips when status is not sent/overdue', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'paid',
|
||||
};
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res.sent).toBe(false);
|
||||
expect(res.reason).toMatch(/wrong_status_paid/);
|
||||
});
|
||||
|
||||
it('respects the 24h throttle', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
|
||||
};
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
|
||||
});
|
||||
|
||||
it('bypasses the throttle when skipThrottle=true', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
customer_account_id: 5,
|
||||
created_by_admin_id: 42,
|
||||
total_amount_minor: 10000,
|
||||
currency: 'CHF',
|
||||
language: 'de',
|
||||
reminder_level: 0,
|
||||
due_date: '2026-05-01',
|
||||
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
|
||||
event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
|
||||
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
|
||||
expect(res.sent).toBe(true);
|
||||
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Smoke tests for invoiceService's primary flows ahead of the god-file
|
||||
* decomposition — createInvoice happy path (incl. the line-item
|
||||
* totals/VAT math), list/get reads, and the status-transition guards
|
||||
* on cancelInvoice / releaseForDelivery.
|
||||
*
|
||||
* Uses the same deep-mocked db pattern as
|
||||
* invoiceService.installmentPlan.test.js — chains are queued per table
|
||||
* and assertions probe insert/update call shapes rather than SQL.
|
||||
*/
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
|
||||
const activeCustomer = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
billing_cadence: 'per_event', preferred_language: 'de',
|
||||
};
|
||||
|
||||
describe('createInvoice — happy path + totals', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
pickChainFor('invoices')._insertResult = [{ id: 777 }];
|
||||
|
||||
const result = await invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 8.1,
|
||||
lineItems: [
|
||||
// 2 × 100.00 = 200.00
|
||||
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
|
||||
// 50.00 with 10% discount = 45.00
|
||||
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
|
||||
// Parent header — total auto-resolves from priced sub-items (350.00)
|
||||
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
|
||||
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
|
||||
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
|
||||
],
|
||||
}, 1);
|
||||
|
||||
expect(result.invoiceIds).toEqual([777]);
|
||||
|
||||
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
|
||||
// double-count. VAT = round(59500 × 8.1%) = 4820.
|
||||
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
invoice_number: 'R-2026-0042',
|
||||
customer_account_id: 5,
|
||||
currency: 'CHF',
|
||||
status: 'scheduled',
|
||||
net_amount_minor: 59500,
|
||||
vat_rate: 8.1,
|
||||
vat_amount_minor: 4820,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: 64320,
|
||||
installment_total: 1,
|
||||
}));
|
||||
// Exactly one sequence number claimed for a single-row create.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
// Line items landed in invoice_line_items.
|
||||
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('409s on a deactivated customer before touching the sequence', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5, vatRate: 0, lineItems: [],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 409 });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
|
||||
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
|
||||
],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('lists invoices with total + pagination echo', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, invoice_number: 'R-2026-0001' },
|
||||
{ id: 2, invoice_number: 'R-2026-0002' },
|
||||
];
|
||||
pickChainFor('invoices')._firstValue = { total: 7 };
|
||||
|
||||
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.total).toBe(7);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
|
||||
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
|
||||
pickChainFor('invoice_line_items as li')._selectResult = [
|
||||
{ id: 30, position: 1, description: 'Shoot' },
|
||||
];
|
||||
pickChainFor('invoice_payment_log')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.getInvoiceById(3);
|
||||
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns null for an unknown id', async () => {
|
||||
pickChainFor('invoices')._firstValue = undefined;
|
||||
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
|
||||
};
|
||||
const result = await invoiceService.cancelInvoice(9, 1);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'cancelled' })
|
||||
);
|
||||
});
|
||||
|
||||
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'cancelled', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(9, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 10, status: 'sent', kind: 'storno',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(10, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 11, status: 'sent', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.releaseForDelivery(11, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Tests for ledgerService (Accounting Layer A).
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
|
||||
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
|
||||
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
|
||||
* the per-tool CSV shapes.
|
||||
*/
|
||||
|
||||
// ----- canned data per table ------------------------------------------
|
||||
let accountsRows = [];
|
||||
let vatRows = [];
|
||||
let invoiceRows = [];
|
||||
let inboundRows = [];
|
||||
let expenseRows = [];
|
||||
|
||||
function makeChain(rows) {
|
||||
const c = {
|
||||
_rows: rows,
|
||||
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
|
||||
leftJoin() { return this; },
|
||||
where() { return this; },
|
||||
whereNot() { return this; },
|
||||
whereIn() { return this; },
|
||||
whereNotIn() { return this; },
|
||||
whereBetween() { return this; },
|
||||
whereRaw() { return this; },
|
||||
orderBy() { return this; },
|
||||
orderByRaw() { return this; },
|
||||
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
|
||||
select() { return Promise.resolve(this._rows); },
|
||||
first() { return Promise.resolve(this._rows[0]); },
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((table) => {
|
||||
switch (table) {
|
||||
case 'ledger_accounts': return makeChain(accountsRows);
|
||||
case 'vat_codes': return makeChain(vatRows);
|
||||
case 'invoices': return makeChain(invoiceRows);
|
||||
case 'inbound_documents': return makeChain(inboundRows);
|
||||
case 'expenses': return makeChain(expenseRows);
|
||||
default: return makeChain([]);
|
||||
}
|
||||
});
|
||||
mockDbFn.raw = (s) => s;
|
||||
mockDbFn.schema = {
|
||||
hasTable: jest.fn(async () => true),
|
||||
hasColumn: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
|
||||
|
||||
const SETTINGS = {
|
||||
ledger_account_debitoren: '1100',
|
||||
ledger_account_kreditoren: '2000',
|
||||
ledger_account_default_revenue: '3400',
|
||||
ledger_account_default_expense: '6700',
|
||||
ledger_account_mileage: '6200',
|
||||
ledger_account_per_diem: '6640',
|
||||
ledger_account_rebilled_revenue: '3940',
|
||||
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
|
||||
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
|
||||
};
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
|
||||
}));
|
||||
|
||||
const ledgerService = require('../../src/services/ledgerService');
|
||||
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
|
||||
|
||||
beforeEach(() => {
|
||||
accountsRows = [
|
||||
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
|
||||
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
|
||||
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
|
||||
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
|
||||
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
|
||||
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
|
||||
];
|
||||
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
|
||||
invoiceRows = [];
|
||||
inboundRows = [];
|
||||
expenseRows = [];
|
||||
});
|
||||
|
||||
// ----- pure helpers ----------------------------------------------------
|
||||
describe('rateKey', () => {
|
||||
it('normalises rate to the output-map key', () => {
|
||||
expect(rateKey(8.1)).toBe('8.1');
|
||||
expect(rateKey(8.10)).toBe('8.1');
|
||||
expect(rateKey('2.60')).toBe('2.6');
|
||||
expect(rateKey(0)).toBe('0');
|
||||
expect(rateKey(null)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvEscape / minorToDecimal', () => {
|
||||
it('quotes + doubles inner quotes', () => {
|
||||
expect(csvEscape('a,b')).toBe('"a,b"');
|
||||
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
|
||||
expect(csvEscape(null)).toBe('""');
|
||||
});
|
||||
it('renders minor units as 2dp', () => {
|
||||
expect(minorToDecimal(10810)).toBe('108.10');
|
||||
expect(minorToDecimal(0)).toBe('0.00');
|
||||
expect(minorToDecimal(null)).toBe('0.00');
|
||||
});
|
||||
});
|
||||
|
||||
// ----- buildPostings ---------------------------------------------------
|
||||
describe('buildPostings', () => {
|
||||
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
|
||||
|
||||
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
|
||||
invoiceRows = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
|
||||
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '1100', debitName: 'Debitoren',
|
||||
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
|
||||
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
|
||||
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
|
||||
});
|
||||
});
|
||||
|
||||
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
|
||||
inboundRows = [{
|
||||
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
|
||||
supplier_name: 'Lab AG', tax_treatment: 'domestic',
|
||||
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
|
||||
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '6570', creditAccount: '2000',
|
||||
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
|
||||
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the kind default account for a category-less mileage expense', async () => {
|
||||
expenseRows = [{
|
||||
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
|
||||
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
|
||||
original_amount_minor: null, chf_amount_minor: 5000,
|
||||
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
|
||||
}];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings).toHaveLength(1);
|
||||
expect(postings[0]).toMatchObject({
|
||||
debitAccount: '6200', creditAccount: '2000',
|
||||
grossMinor: 5000, vatMinor: 0,
|
||||
vatCode: 'VST00', source: 'expense', eventName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts the combined journal chronologically across all sources', async () => {
|
||||
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
|
||||
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
|
||||
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
|
||||
const { postings } = await ledgerService.buildPostings(period);
|
||||
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
|
||||
});
|
||||
|
||||
it('requires from/to/currency', async () => {
|
||||
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
|
||||
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----- exportPostings --------------------------------------------------
|
||||
describe('exportPostings', () => {
|
||||
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
|
||||
beforeEach(() => {
|
||||
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
|
||||
});
|
||||
|
||||
it('generic format carries all human-friendly columns', async () => {
|
||||
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
|
||||
const [header, row] = content.trim().split('\r\n');
|
||||
expect(count).toBe(1);
|
||||
expect(header).toContain('DebitAccountName');
|
||||
expect(header).toContain('NetAmount');
|
||||
expect(header).toContain('VatCode');
|
||||
expect(row).toContain('1100');
|
||||
expect(row).toContain('108.10'); // gross 2dp
|
||||
expect(filename).toMatch(/_generic\.csv$/);
|
||||
});
|
||||
|
||||
it('banana format is a TAB-separated .txt with Banana column names', async () => {
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||
const header = content.split('\r\n')[0];
|
||||
// Banana's "Text file with column headers" import wants TAB-separated,
|
||||
// unquoted values in a .txt — not a comma CSV.
|
||||
expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode');
|
||||
expect(content.split('\r\n')[1]).toContain('\t');
|
||||
expect(content).not.toContain('"');
|
||||
expect(filename).toMatch(/_banana\.txt$/);
|
||||
expect(contentType).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
|
||||
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
|
||||
const [header, row] = content.trim().split('\r\n');
|
||||
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tCategory\tVatCode');
|
||||
// The mock period holds one revenue posting (gross 108.10) → Income filled,
|
||||
// Expenses empty, Category = the revenue account.
|
||||
const cells = row.split('\t');
|
||||
expect(cells[3]).toBe('108.10'); // Income
|
||||
expect(cells[4]).toBe(''); // Expenses
|
||||
expect(cells[5]).not.toBe(''); // Category (revenue account)
|
||||
expect(filename).toMatch(/_banana_ie\.txt$/);
|
||||
expect(contentType).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('formats a Postgres Date object as yyyy-mm-dd (not "Thu Jan ...")', async () => {
|
||||
// PG returns DATE columns as JS Date objects (SQLite returns strings); the
|
||||
// export must still emit an ISO date, or Banana rejects it and the Date
|
||||
// column imports empty.
|
||||
invoiceRows = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: new Date(2026, 0, 10),
|
||||
vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
|
||||
customer_company_name: 'ACME',
|
||||
}];
|
||||
const { content } = await ledgerService.exportPostings({ ...period, format: 'banana' });
|
||||
const dateCell = content.split('\r\n')[1].split('\t')[0];
|
||||
expect(dateCell).toBe('2026-01-10');
|
||||
});
|
||||
|
||||
it('bexio format includes tax_code + currency', async () => {
|
||||
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
|
||||
const header = content.split('\r\n')[0];
|
||||
expect(header).toContain('tax_code');
|
||||
expect(header).toContain('currency');
|
||||
});
|
||||
|
||||
it('unknown format falls back to generic', async () => {
|
||||
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
|
||||
expect(filename).toMatch(/_generic\.csv$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Tests for the createBaseDocument + getPageMetrics helpers — the
|
||||
* shared PDF factory used by quote/invoice rendering AND by the
|
||||
* upcoming tax-report renderer. These verify orientation handling
|
||||
* and font defaults without touching DB or filesystem.
|
||||
*/
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
|
||||
describe('getPageMetrics', () => {
|
||||
it('returns portrait A4 metrics by default', () => {
|
||||
const p = pdfService.getPageMetrics();
|
||||
expect(p.width).toBeCloseTo(595.28, 1);
|
||||
expect(p.height).toBeCloseTo(841.89, 1);
|
||||
expect(p.contentWidth).toBeCloseTo(515.28, 1);
|
||||
});
|
||||
|
||||
it('returns portrait when orientation is "portrait"', () => {
|
||||
const p = pdfService.getPageMetrics('portrait');
|
||||
expect(p.width).toBeLessThan(p.height);
|
||||
});
|
||||
|
||||
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
|
||||
const p = pdfService.getPageMetrics('landscape');
|
||||
expect(p.width).toBeCloseTo(841.89, 1);
|
||||
expect(p.height).toBeCloseTo(595.28, 1);
|
||||
expect(p.contentWidth).toBeCloseTo(761.89, 1);
|
||||
expect(p.width).toBeGreaterThan(p.height);
|
||||
});
|
||||
|
||||
it('ignores unknown orientation values (falls back to portrait)', () => {
|
||||
const p = pdfService.getPageMetrics('upside-down');
|
||||
expect(p.width).toBeLessThan(p.height);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBaseDocument', () => {
|
||||
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
|
||||
const { doc, page, fonts } = pdfService.createBaseDocument();
|
||||
expect(doc).toBeDefined();
|
||||
expect(typeof doc.on).toBe('function');
|
||||
expect(typeof doc.font).toBe('function');
|
||||
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
|
||||
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
|
||||
});
|
||||
|
||||
it('produces a landscape document when orientation is "landscape"', () => {
|
||||
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
|
||||
expect(page.width).toBeGreaterThan(page.height);
|
||||
// PDFKit stores the active page dims on doc.page.
|
||||
expect(doc.page.width).toBeCloseTo(841.89, 1);
|
||||
expect(doc.page.height).toBeCloseTo(595.28, 1);
|
||||
});
|
||||
|
||||
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
|
||||
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
|
||||
const chunks = [];
|
||||
doc.on('data', (c) => chunks.push(c));
|
||||
const ended = new Promise((resolve) => doc.on('end', resolve));
|
||||
doc.text('hello', 40, 40);
|
||||
doc.end();
|
||||
await ended;
|
||||
const buf = Buffer.concat(chunks);
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontTtfPath: null },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('falls back to Helvetica when the custom TTF path does not exist', () => {
|
||||
// No exception, no logger.error blow-up — just silent fallback.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('registers a bundled font family when pdfFontFamily is set', () => {
|
||||
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
|
||||
// backend/assets/fonts/Inter/, so the resolver should pick 400
|
||||
// for body and 700 for bold.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: 'Inter' },
|
||||
});
|
||||
expect(fonts.body).toBe('crm-body');
|
||||
expect(fonts.bold).toBe('crm-bold');
|
||||
});
|
||||
|
||||
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: 'NotARealFamily' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('strips path-traversal characters from pdfFontFamily', () => {
|
||||
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
|
||||
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: '../../etc/passwd' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
|
||||
// The explicit upload is the priority-1 override. When the upload
|
||||
// path is unusable (file missing) the family is consulted next.
|
||||
// Here we set BOTH to invalid values and confirm Helvetica fallback
|
||||
// — what matters is that the family DIDN'T get registered while a
|
||||
// (failed) explicit path was being evaluated.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: {
|
||||
pdfFontTtfPath: '/nonexistent/path/font.ttf',
|
||||
pdfFontFamily: 'Inter',
|
||||
},
|
||||
});
|
||||
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
|
||||
// registers successfully. crm-body / crm-bold confirm a custom
|
||||
// font won.
|
||||
expect(fonts.body).toBe('crm-body');
|
||||
expect(fonts.bold).toBe('crm-bold');
|
||||
});
|
||||
|
||||
it('forwards PDF info metadata (Title, Author) to the document', () => {
|
||||
const { doc } = pdfService.createBaseDocument({
|
||||
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
|
||||
});
|
||||
// PDFKit copies these onto doc.info during construction.
|
||||
expect(doc.info.Title).toBe('Tax Report 2026');
|
||||
expect(doc.info.Author).toBe('picpeak');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exported letterhead helper', () => {
|
||||
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
|
||||
expect(typeof pdfService.drawIssuerBlock).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// Storno rendering — smoke-tests that exercise the kind='storno'
|
||||
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
|
||||
// directly for German strings because PDFKit Flate-compresses
|
||||
// content streams, but we CAN verify the renderer:
|
||||
// - completes without throwing on a Storno-shaped context,
|
||||
// - produces a valid %PDF magic header,
|
||||
// - produces a SMALLER document than its invoice counterpart
|
||||
// (no payment block, no QR slip → fewer bytes), proving the
|
||||
// suppression branches actually fire.
|
||||
//
|
||||
// Visual correctness (title swap, reference line, signed totals) is
|
||||
// validated by manual review of a real Storno PDF; the renderer's
|
||||
// branch logic is unit-tested in service tests where the inputs
|
||||
// can be asserted directly.
|
||||
describe('renderInvoiceToBuffer — Storno branch', () => {
|
||||
function buildContext(overrides = {}) {
|
||||
return {
|
||||
locale: 'de',
|
||||
currency: 'CHF',
|
||||
issuer: { companyName: 'AcmeCo' },
|
||||
recipient: {
|
||||
companyName: 'KundenCo', addressLine1: 'Strasse 1',
|
||||
city: 'Bern', postalCode: '3000',
|
||||
},
|
||||
lineItems: [{
|
||||
quantity: 1, description: 'Photo session',
|
||||
unitPriceMinor: 30000, lineTotalMinor: 30000,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}],
|
||||
totals: {
|
||||
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 32310,
|
||||
},
|
||||
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
// Bank + payment term are part of the baseline invoice so the
|
||||
// payment block renders a real IBAN + Zahlungsbedingungen
|
||||
// section. The Storno branch suppresses this entirely, which
|
||||
// produces a visible byte-size delta.
|
||||
bank: {
|
||||
accountHolder: 'AcmeCo',
|
||||
iban: 'CH9300762011623852957',
|
||||
bic: 'POFICHBE',
|
||||
currency: 'CHF',
|
||||
},
|
||||
qrFormat: 'none',
|
||||
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
|
||||
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
|
||||
totals: {
|
||||
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: -32310,
|
||||
},
|
||||
doc: {
|
||||
kind: 'storno',
|
||||
invoiceNumber: 'R-2026-0080',
|
||||
issueDate: '2026-05-15',
|
||||
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
},
|
||||
}));
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
|
||||
// Baseline: normal invoice with a payment block.
|
||||
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
|
||||
// Storno: same context but kind='storno' → payment block + QR
|
||||
// both suppressed. Payment block alone is ~80pt tall in the
|
||||
// PDF; its absence is reliably detectable as a byte-size delta.
|
||||
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
|
||||
totals: {
|
||||
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: -32310,
|
||||
},
|
||||
doc: {
|
||||
kind: 'storno',
|
||||
invoiceNumber: 'R-2026-0080',
|
||||
issueDate: '2026-05-15',
|
||||
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
},
|
||||
}));
|
||||
expect(stornoBuf.length).toBeGreaterThan(0);
|
||||
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Pure-function tests for the PDF rendering helpers. These are the
|
||||
* functions that DON'T touch PDFKit / DB — formatting, salutation
|
||||
* routing, EPC payload construction.
|
||||
*
|
||||
* The helpers aren't directly exported from pdfService.js (it
|
||||
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
|
||||
* them via `_internal` which the module already exposes for tests.
|
||||
*/
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
const { formatMinor, formatDate, t } = pdfService._internal;
|
||||
|
||||
describe('formatMinor', () => {
|
||||
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
|
||||
// de-CH uses ’ (U+2019) as the thousands separator.
|
||||
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[’',\u2019]?234\.56/);
|
||||
});
|
||||
|
||||
it('formats large amounts with thousands separators', () => {
|
||||
// 12345600 minor units = 123,456.00 major; the separator
|
||||
// varies by locale (de-CH = U+2019, en-GB = ',').
|
||||
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[’',\u2019]456\.00/);
|
||||
});
|
||||
|
||||
it('returns 0,00 for zero or null', () => {
|
||||
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
|
||||
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
|
||||
});
|
||||
|
||||
it('returns 2-decimal output regardless of locale', () => {
|
||||
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
// formatDate now respects ctx.dateFormat (object with `format`
|
||||
// key) — when omitted defaults to DD.MM.YYYY.
|
||||
it('defaults to DD.MM.YYYY when no format passed', () => {
|
||||
expect(formatDate('2026-04-19')).toBe('19.04.2026');
|
||||
});
|
||||
|
||||
it('honors the configured DD/MM/YYYY format', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
|
||||
});
|
||||
|
||||
it('honors the configured MM/DD/YYYY format', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
|
||||
});
|
||||
|
||||
it('honors ISO YYYY-MM-DD', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
|
||||
});
|
||||
|
||||
it('returns empty string on empty input', () => {
|
||||
expect(formatDate('')).toBe('');
|
||||
expect(formatDate(null)).toBe('');
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string on invalid input rather than throwing', () => {
|
||||
expect(formatDate('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('accepts Date objects', () => {
|
||||
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('t (i18n lookup)', () => {
|
||||
it('returns the EN value for an EN-only locale', () => {
|
||||
expect(t('en', 'invoice_title')).toBe('Invoice');
|
||||
expect(t('en', 'quote_title')).toBe('Quote');
|
||||
});
|
||||
|
||||
it('returns the DE value for de locale', () => {
|
||||
expect(t('de', 'invoice_title')).toBe('Rechnung');
|
||||
expect(t('de', 'quote_title')).toBe('Angebot');
|
||||
});
|
||||
|
||||
it('falls back to EN for unknown locales', () => {
|
||||
expect(t('xx', 'invoice_title')).toBe('Invoice');
|
||||
});
|
||||
|
||||
it('substitutes named tokens like {percent}', () => {
|
||||
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
|
||||
expect(out).toMatch(/3% discount if paid within 5 working days\./);
|
||||
});
|
||||
|
||||
it('falls back to EN when the key is missing on the requested locale', () => {
|
||||
// page_of is seeded on all locales — pick something that
|
||||
// exists on EN with a substitution.
|
||||
const out = t('zz', 'page_of', { current: 1, total: 3 });
|
||||
expect(out).toBe('Page 1 of 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* exportAsTxt — issue #623 regression test.
|
||||
*
|
||||
* The admin UI labels the TXT export "for Lightroom search". Lightroom's
|
||||
* filename search wants ONE comma-separated line WITHOUT file extensions
|
||||
* (the gallery JPEGs may map to RAW files in the catalog). The frontend
|
||||
* now passes separator='comma' + include_extension=false for the TXT
|
||||
* format; this test pins the resulting shape so a future refactor can't
|
||||
* silently regress it back to the newline-separated form the bug reported.
|
||||
*
|
||||
* Also pins backward compatibility: a direct API caller passing no options
|
||||
* still gets the original newline-with-extension behaviour, so existing
|
||||
* integrations don't break.
|
||||
*/
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} }));
|
||||
|
||||
const { PhotoExportService } = require('../../src/services/photoExportService');
|
||||
const service = new PhotoExportService();
|
||||
|
||||
const PHOTOS = [
|
||||
{ original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' },
|
||||
{ original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' },
|
||||
{ original_filename: 'shoot.final.tif', filename: 'ghi789.tif' },
|
||||
{ original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename
|
||||
];
|
||||
|
||||
describe('exportAsTxt (issue #623)', () => {
|
||||
it('Lightroom mode: comma-joined, no extension, no space', () => {
|
||||
const result = service.exportAsTxt(PHOTOS, {
|
||||
separator: 'comma',
|
||||
include_extension: false,
|
||||
});
|
||||
expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback');
|
||||
expect(result.contentType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('backward compatible: no options → newline-joined with extensions', () => {
|
||||
const result = service.exportAsTxt(PHOTOS);
|
||||
expect(result.content).toBe(
|
||||
'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('semicolon separator joins without a trailing space', () => {
|
||||
const result = service.exportAsTxt(PHOTOS, {
|
||||
separator: 'semicolon',
|
||||
include_extension: false,
|
||||
});
|
||||
expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback');
|
||||
});
|
||||
|
||||
it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => {
|
||||
const result = service.exportAsTxt(PHOTOS, {
|
||||
filename_format: 'picpeak',
|
||||
separator: 'comma',
|
||||
include_extension: false,
|
||||
});
|
||||
expect(result.content).toBe('abc123,def456,ghi789,fallback');
|
||||
});
|
||||
|
||||
it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => {
|
||||
// path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom
|
||||
// catalogs that store basenames like "shoot.final" still match.
|
||||
const result = service.exportAsTxt(
|
||||
[{ original_filename: 'shoot.final.tif', filename: 'x.tif' }],
|
||||
{ separator: 'comma', include_extension: false },
|
||||
);
|
||||
expect(result.content).toBe('shoot.final');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tests for the migration-119 hierarchy support in quoteService:
|
||||
* computeTotals, validateLineItemHierarchy, and the two-phase
|
||||
* insertLineItemsHierarchical helper. All pure / db-mocked so the
|
||||
* suite runs fast and is deterministic.
|
||||
*/
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
const {
|
||||
computeTotals,
|
||||
validateLineItemHierarchy,
|
||||
insertLineItemsHierarchical,
|
||||
} = quoteService._internal;
|
||||
|
||||
describe('computeTotals — hierarchy + parent auto-resolve rule', () => {
|
||||
it('parent total auto-resolves to sum of priced sub-items (parent unit_price ignored)', () => {
|
||||
const items = [
|
||||
// Parent with its own price €500 — should be IGNORED because
|
||||
// sub-items have prices. Parent's effective line_total becomes
|
||||
// sum of priced sub-items.
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
// Priced sub-items €150 + €200 = €350
|
||||
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
|
||||
{ position: 3, quantity: 1, unit_price_minor: 20000, discount_percent: 0, parent_position: 1 },
|
||||
// Another top-level item: €100
|
||||
{ position: 4, quantity: 2, unit_price_minor: 5000, discount_percent: 0 },
|
||||
];
|
||||
const out = computeTotals(items, 0, 0);
|
||||
// Net = 35000 (parent 1, auto-resolved) + 10000 (row 4) = 45000.
|
||||
// Parent's own €500 is silently overridden.
|
||||
expect(out.netAmountMinor).toBe(45000);
|
||||
// Parent's stored line_total_minor reflects the resolved sum.
|
||||
expect(out.lineItems[0].line_total_minor).toBe(35000);
|
||||
});
|
||||
|
||||
it('priceless sub-items leave the parent\'s own line_total intact', () => {
|
||||
const items = [
|
||||
// Parent €500 with three priceless transparency-bullets — the
|
||||
// €500 stands.
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||||
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||||
];
|
||||
const out = computeTotals(items, 0, 0);
|
||||
expect(out.netAmountMinor).toBe(50000);
|
||||
expect(out.lineItems[0].line_total_minor).toBe(50000);
|
||||
});
|
||||
|
||||
it('mixed priced + priceless sub-items: only priced contribute, parent\'s own price still overridden', () => {
|
||||
const items = [
|
||||
// Parent €500 → overridden because at least one sub-item is priced.
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
|
||||
// Priceless bullet — doesn't add anything
|
||||
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||||
];
|
||||
const out = computeTotals(items, 0, 0);
|
||||
// Parent resolves to €150 (only priced sub-item).
|
||||
expect(out.netAmountMinor).toBe(15000);
|
||||
expect(out.lineItems[0].line_total_minor).toBe(15000);
|
||||
});
|
||||
|
||||
it('still computes line_total_minor on sub-items so the renderer can show it', () => {
|
||||
const out = computeTotals([
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 2, unit_price_minor: 15000, discount_percent: 10, parent_position: 1 },
|
||||
], 0);
|
||||
expect(out.lineItems[1].line_total_minor).toBe(27000); // 2 × 150.00 × 0.9 = 270.00
|
||||
});
|
||||
|
||||
it('applies VAT to the resolved parent total', () => {
|
||||
const out = computeTotals([
|
||||
// Parent €1000 overridden by priced €800 sub-item
|
||||
{ position: 1, quantity: 1, unit_price_minor: 100000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 80000, discount_percent: 0, parent_position: 1 },
|
||||
], 7.7);
|
||||
// Resolved net = 80000, VAT 7.7% = 6160.
|
||||
expect(out.netAmountMinor).toBe(80000);
|
||||
expect(out.vatAmountMinor).toBe(6160);
|
||||
expect(out.totalAmountMinor).toBe(86160);
|
||||
});
|
||||
|
||||
it('treats empty-string parent_position as top-level (frontend may send "")', () => {
|
||||
const out = computeTotals([
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: '' },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: null },
|
||||
], 0);
|
||||
expect(out.netAmountMinor).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLineItemHierarchy', () => {
|
||||
it('accepts a flat list of top-level items', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 2 },
|
||||
{ position: 3 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts one level of sub-items under valid parents', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 2, parent_position: 1 },
|
||||
{ position: 3, parent_position: 1 },
|
||||
{ position: 4 },
|
||||
{ position: 5, parent_position: 4 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects duplicate positions', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 1 },
|
||||
])).toThrow(/Duplicate line item position/);
|
||||
});
|
||||
|
||||
it('rejects a sub-item pointing at a missing parent', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1, parent_position: 99 },
|
||||
])).toThrow(/missing parent position/);
|
||||
});
|
||||
|
||||
it('rejects a sub-item under another sub-item (max 1 level deep)', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 2, parent_position: 1 },
|
||||
{ position: 3, parent_position: 2 },
|
||||
])).toThrow(/max one level deep/);
|
||||
});
|
||||
|
||||
it('rejects an item whose parent is itself', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 5, parent_position: 5 },
|
||||
])).toThrow(/cannot be its own parent/);
|
||||
});
|
||||
|
||||
it('rejects an item without a positive position', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 0 },
|
||||
])).toThrow(/positive position/);
|
||||
});
|
||||
|
||||
it('is a no-op on empty / non-array input', () => {
|
||||
expect(() => validateLineItemHierarchy([])).not.toThrow();
|
||||
expect(() => validateLineItemHierarchy(null)).not.toThrow();
|
||||
expect(() => validateLineItemHierarchy(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLineItemsHierarchical', () => {
|
||||
// Tiny trx mock — captures insert calls so we can verify the
|
||||
// two-phase ordering and the parent-id remap. `.returning('id')`
|
||||
// returns a synthesised id matching the call order.
|
||||
function makeTrxMock() {
|
||||
let nextId = 100;
|
||||
const inserts = []; // [{ table, row }]
|
||||
const trx = (tableName) => ({
|
||||
insert(row) {
|
||||
const id = nextId++;
|
||||
inserts.push({ table: tableName, row: { ...row, id } });
|
||||
return {
|
||||
returning() { return Promise.resolve([{ id }]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); }, // bare await: no returning() call
|
||||
};
|
||||
},
|
||||
});
|
||||
return { trx, inserts };
|
||||
}
|
||||
|
||||
it('inserts top-level items first, then sub-items with remapped parent_line_item_id', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
|
||||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, discount_percent: 0, line_total_minor: 20000, parent_position: 1 },
|
||||
{ position: 4, description: 'Travel', quantity: 1, unit_price_minor: 10000, discount_percent: 0, line_total_minor: 10000, parent_position: null },
|
||||
]);
|
||||
// 4 inserts, all into quote_line_items.
|
||||
expect(inserts).toHaveLength(4);
|
||||
expect(inserts.every((i) => i.table === 'quote_line_items')).toBe(true);
|
||||
// Order: top-level first (positions 1 and 4), then sub-items 2 and 3.
|
||||
expect(inserts.map((i) => i.row.position)).toEqual([1, 4, 2, 3]);
|
||||
// Top-level items have parent_line_item_id = null.
|
||||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||||
expect(inserts[1].row.parent_line_item_id).toBeNull();
|
||||
// Sub-items reference the id returned for position-1 parent (100).
|
||||
expect(inserts[2].row.parent_line_item_id).toBe(100);
|
||||
expect(inserts[3].row.parent_line_item_id).toBe(100);
|
||||
// parent_position is stripped (wire-only field, not a DB column).
|
||||
expect(inserts[0].row).not.toHaveProperty('parent_position');
|
||||
expect(inserts[2].row).not.toHaveProperty('parent_position');
|
||||
});
|
||||
|
||||
it('copies details_text through to the inserted row', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
|
||||
{ position: 1, description: 'P', unit_price_minor: 0, details_text: 'Includes online gallery.', parent_position: null },
|
||||
]);
|
||||
expect(inserts[0].row.details_text).toBe('Includes online gallery.');
|
||||
});
|
||||
|
||||
it('is a no-op on empty items array', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, []);
|
||||
expect(inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the supplied ownerColumn so the same helper handles invoice_line_items', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 42, [
|
||||
{ position: 1, description: 'X', unit_price_minor: 100, parent_position: null },
|
||||
]);
|
||||
expect(inserts[0].table).toBe('invoice_line_items');
|
||||
expect(inserts[0].row.invoice_id).toBe(42);
|
||||
expect(inserts[0].row).not.toHaveProperty('quote_id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for quoteService lock + state-transition guards:
|
||||
* - updateQuote refuses on accepted / declined / converted
|
||||
* - adminAcceptQuote refuses on already-terminal states + atomic
|
||||
* update path
|
||||
*
|
||||
* db deep-mocked, same chain pattern as invoiceService tests.
|
||||
*/
|
||||
|
||||
const tableChains = {};
|
||||
function makeChain() {
|
||||
return {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
// knex chains are thenable; mirror that so `await trx('t')...`
|
||||
// resolves to an array of rows.
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._selectResult); }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
};
|
||||
}
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
}
|
||||
|
||||
describe('quoteService.updateQuote — lock guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('404s when the quote does not exist', async () => {
|
||||
pickChainFor('quotes')._firstValue = null;
|
||||
await expect(quoteService.updateQuote(99, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('locks accepted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
|
||||
await expect(quoteService.updateQuote(1, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
|
||||
});
|
||||
|
||||
it('locks declined quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
|
||||
await expect(quoteService.updateQuote(1, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
|
||||
});
|
||||
|
||||
it('locks converted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
|
||||
await expect(quoteService.updateQuote(1, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
|
||||
});
|
||||
|
||||
it('allows edits on draft + sent + expired (no QUOTE_LOCKED throw)', async () => {
|
||||
for (const status of ['draft', 'sent', 'expired']) {
|
||||
pickChainFor('quotes')._firstValue = {
|
||||
id: 1, status, vat_rate: 0, shipping_amount_minor: 0,
|
||||
};
|
||||
// The lock check sits at the TOP of updateQuote. The
|
||||
// observable behavior we care about is "no QUOTE_LOCKED
|
||||
// 409 thrown on these statuses". The full transaction
|
||||
// path may resolve to anything (incl. undefined) since
|
||||
// the test mocks the trx callback — that's fine.
|
||||
let err = null;
|
||||
try { await quoteService.updateQuote(1, { lineItems: [] }, 1); }
|
||||
catch (e) { err = e; }
|
||||
if (err) {
|
||||
// Any error other than the QUOTE_LOCKED guard is allowed
|
||||
// (we're not exercising the full path here).
|
||||
expect(err.code).not.toBe('QUOTE_LOCKED');
|
||||
}
|
||||
resetChains();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoteService.adminAcceptQuote', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('404s when the quote does not exist', async () => {
|
||||
pickChainFor('quotes')._firstValue = null;
|
||||
await expect(quoteService.adminAcceptQuote(99, 1))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('refuses already-accepted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
|
||||
await expect(quoteService.adminAcceptQuote(1, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_ALREADY_ACCEPTED' });
|
||||
});
|
||||
|
||||
it('refuses declined quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
|
||||
await expect(quoteService.adminAcceptQuote(1, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_DECLINED' });
|
||||
});
|
||||
|
||||
it('refuses converted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
|
||||
await expect(quoteService.adminAcceptQuote(1, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_CONVERTED' });
|
||||
});
|
||||
|
||||
it('accepts draft / sent / expired and returns lockedAt', async () => {
|
||||
for (const status of ['draft', 'sent', 'expired']) {
|
||||
pickChainFor('quotes')._firstValue = {
|
||||
id: 1, status, customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de',
|
||||
quote_number: 'Q-2026-0001',
|
||||
total_amount_minor: 10000,
|
||||
event_name: null,
|
||||
};
|
||||
pickChainFor('customer_accounts')._firstValue = {
|
||||
id: 5, email: 'c@example.com', display_name: 'Test',
|
||||
};
|
||||
pickChainFor('quote_line_items')._selectResult = [];
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
|
||||
const result = await quoteService.adminAcceptQuote(1, 42);
|
||||
expect(result.status).toBe('accepted');
|
||||
expect(result.lockedAt).toBeInstanceOf(Date);
|
||||
resetChains();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const rasterizeService = require('../../src/services/rasterizeService');
|
||||
|
||||
// The page-range guard runs BEFORE any fs/pdftoppm work, so these reject
|
||||
// without touching the binary or disk (PR #622 concern 6).
|
||||
describe('getRenderedPagePath page-range guard', () => {
|
||||
it.each([0, -1, 201, 1000, 1.5, NaN])('rejects out-of-range page %p', async (page) => {
|
||||
await expect(rasterizeService.getRenderedPagePath(1, '/tmp/does-not-exist.pdf', page))
|
||||
.rejects.toMatchObject({ statusCode: 400, code: 'PAGE_OUT_OF_RANGE' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
|
||||
* `umamiAdapter` test contract: missing config / URL shape / encoding /
|
||||
* normalisation / unknown-bucket drop / failure modes.
|
||||
*
|
||||
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
|
||||
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
|
||||
* envelopes since their docs hint at minor v0 → v1 shape variation.
|
||||
*/
|
||||
|
||||
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
|
||||
|
||||
const ORIGINAL_FETCH = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
function mockJson(body, { status = 200 } = {}) {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}));
|
||||
}
|
||||
|
||||
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
|
||||
|
||||
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
|
||||
test('returns null when config is incomplete', async () => {
|
||||
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
expect(global.fetch).toBe(ORIGINAL_FETCH);
|
||||
});
|
||||
|
||||
test('builds the expected URL + sends Bearer auth', async () => {
|
||||
mockJson([{ device: 'desktop', sessions: 10 }]);
|
||||
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
|
||||
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
|
||||
const [calledUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
|
||||
expect(init.headers.Authorization).toBe('Bearer rkey');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
test('URL-encodes the websiteId for reserved chars', async () => {
|
||||
mockJson([{ device: 'desktop', sessions: 1 }]);
|
||||
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
const [calledUrl] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
|
||||
});
|
||||
|
||||
test('normalises a typical {device, sessions} payload into percentages', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', sessions: 60 },
|
||||
{ device: 'mobile', sessions: 30 },
|
||||
{ device: 'tablet', sessions: 10 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
|
||||
});
|
||||
|
||||
test('accepts the {data: [...]} envelope variant', async () => {
|
||||
mockJson({ data: [
|
||||
{ device: 'desktop', sessions: 1 },
|
||||
{ device: 'mobile', sessions: 3 },
|
||||
] });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
|
||||
});
|
||||
|
||||
test('falls back to `visitors` when `sessions` is absent', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', visitors: 80 },
|
||||
{ device: 'mobile', visitors: 20 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('tolerates a `dimension` key as the bucket label', async () => {
|
||||
mockJson([
|
||||
{ dimension: 'desktop', sessions: 50 },
|
||||
{ dimension: 'mobile', sessions: 50 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
|
||||
});
|
||||
|
||||
test('drops unknown buckets', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', sessions: 80 },
|
||||
{ device: 'mobile', sessions: 20 },
|
||||
{ device: 'fridge', sessions: 100 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
|
||||
mockJson([]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
mockJson({ error: 'unauthorized' }, { status: 401 });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => { throw new SyntaxError('not json'); },
|
||||
}));
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Smoke tests for taxReportService.renderTaxReportPdf and
|
||||
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
|
||||
* businessProfileService (canned issuer) and assert that the
|
||||
* rendered output meets a few hard requirements:
|
||||
*
|
||||
* - PDF starts with the %PDF magic bytes, is non-empty
|
||||
* - CSV header contains the localised column names
|
||||
* - CSV body contains the invoice numbers in order
|
||||
* - CSV totals row contains the grand totals
|
||||
*/
|
||||
|
||||
let invoiceRowsForRun = [];
|
||||
let replacementsRowsForRun = [];
|
||||
let callCount = 0;
|
||||
|
||||
function makeChain(initialRows) {
|
||||
return {
|
||||
_rows: initialRows,
|
||||
then(onResolve, onReject) {
|
||||
return Promise.resolve(this._rows).then(onResolve, onReject);
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
whereRaw: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
orderByRaw: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
// Route by table name when supplied — the Skonto aggregate (added
|
||||
// by migration 126) queries `invoice_payment_log`; the #4 cost side
|
||||
// queries `inbound_documents` + `expenses`; everything else (main
|
||||
// listing, replacements lookup) hits `invoices`.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
if (tableName === 'inbound_documents') return makeChain([]);
|
||||
if (tableName === 'expenses') return makeChain([]);
|
||||
callCount += 1;
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
// `.raw()` is used in the .select() column list for the event_name
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments so the raw() return value just needs to exist.
|
||||
mockDbFn.raw = jest.fn((sql) => sql);
|
||||
// #4 loadCosts schema-guards each cost table; default the PDF/CSV
|
||||
// fixtures to "no accounting tables" so these renderers exercise the
|
||||
// revenue path unchanged.
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({
|
||||
profile: {
|
||||
company_name: 'ACME Test GmbH',
|
||||
address_line1: 'Teststrasse 1',
|
||||
postal_code: '8000',
|
||||
city: 'Zürich',
|
||||
country_code: 'CH',
|
||||
email: 'hello@example.com',
|
||||
default_locale: 'de',
|
||||
default_currency: 'CHF',
|
||||
pdf_show_logo: 1,
|
||||
pdf_show_company_name: 1,
|
||||
pdf_logo_height: 56,
|
||||
pdf_company_name_inline: 0,
|
||||
pdf_folding_marks: 'none',
|
||||
logo_path: null,
|
||||
pdf_font_ttf_path: null,
|
||||
},
|
||||
bankAccounts: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
|
||||
}));
|
||||
|
||||
const taxReportService = require('../../src/services/taxReportService');
|
||||
|
||||
beforeEach(() => {
|
||||
invoiceRowsForRun = [];
|
||||
replacementsRowsForRun = [];
|
||||
callCount = 0;
|
||||
mockDbFn.mockClear();
|
||||
});
|
||||
|
||||
const SAMPLE_ROW = (override = {}) => ({
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
|
||||
customer_last_name: null, customer_display_name: null, customer_email: null,
|
||||
event_name: 'Hochzeit Müller',
|
||||
...override,
|
||||
});
|
||||
|
||||
describe('renderTaxReportPdf', () => {
|
||||
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders a header even when no invoices are in the period', async () => {
|
||||
invoiceRowsForRun = [];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders successfully when cancelled rows are present', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
|
||||
];
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders without throwing when a row has long text that must wrap', async () => {
|
||||
// Customer + event labels long enough to force multi-line wrap
|
||||
// in their narrow columns. The dynamic row-height logic should
|
||||
// grow the row to fit rather than overlapping the next one.
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({
|
||||
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
|
||||
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
|
||||
}),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
|
||||
];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('honours the locale parameter (en) without throwing', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
// Regression: the page-number footer used to place its baseline
|
||||
// inside the bottom margin, which made PDFKit auto-paginate one
|
||||
// empty page per existing page (so a 1-page report ended up as 2,
|
||||
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
|
||||
// is the cheapest way to detect a recurrence without parsing the
|
||||
// PDF — every page object in the xref table carries that marker
|
||||
// exactly once.
|
||||
it('does not duplicate pages when stamping the page-number footer', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
|
||||
// Small single-row report should fit on a single page. The
|
||||
// previous buggy renderer produced 2 (1 content + 1 footer-only).
|
||||
expect(pageMarkers.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTaxReportCsv', () => {
|
||||
it('returns a CSV blob with the de localised header row', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(contentType).toMatch(/text\/csv/);
|
||||
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
|
||||
const lines = content.split('\r\n');
|
||||
// Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
|
||||
// old Rechnung/Kunde split.
|
||||
expect(lines[0]).toContain('Typ'); // de header for tax_col_type
|
||||
expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference
|
||||
expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant"
|
||||
expect(lines[0]).toContain('Netto');
|
||||
});
|
||||
|
||||
it('lists each invoice on its own row in order', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
|
||||
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
const idxA = content.indexOf('R-2026-0001');
|
||||
const idxB = content.indexOf('R-2026-0002');
|
||||
const idxC = content.indexOf('R-2026-0003');
|
||||
expect(idxA).toBeGreaterThan(0);
|
||||
expect(idxB).toBeGreaterThan(idxA);
|
||||
expect(idxC).toBeGreaterThan(idxB);
|
||||
});
|
||||
|
||||
it('appends a trailing totals row with the grand totals', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
}),
|
||||
SAMPLE_ROW({
|
||||
id: 2, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
}),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
|
||||
expect(content).toMatch(/"150\.00"/);
|
||||
expect(content).toMatch(/"11\.55"/);
|
||||
expect(content).toMatch(/"161\.55"/);
|
||||
});
|
||||
|
||||
it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ status: 'cancelled' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Unified ledger CSV has no separate cancelled column — a cancelled row is
|
||||
// flagged by appending the localised "(Cancelled)" tag to its Reference.
|
||||
const dataRow = content.split('\r\n')[1];
|
||||
expect(dataRow).toContain('R-2026-0001 (Cancelled)');
|
||||
});
|
||||
|
||||
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
expect(content).toContain('\r\n');
|
||||
// The route wraps the BOM around the content; the service output
|
||||
// itself is BOM-free so callers (tests) get a clean string.
|
||||
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Tests for taxReportService.
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure helpers (grossUpLateFee, computeReportedAmounts,
|
||||
* buildCustomerLabel) — no db mock needed.
|
||||
* 2. getTaxReport — db chain deep-mocked so we can feed canned
|
||||
* invoice rows and assert the filter/bucket/total math.
|
||||
*/
|
||||
|
||||
// ----- mock db chain ---------------------------------------------------
|
||||
//
|
||||
// taxReportService builds a single chain:
|
||||
// db('invoices').leftJoin(...).leftJoin(...).whereBetween(...)
|
||||
// .where(...).whereIn(...).orderBy(...).select(...)
|
||||
// and then for cancelled ids:
|
||||
// db('invoices').whereIn('replaces_invoice_id', ids).select(...)
|
||||
//
|
||||
// We use one shared chain factory that returns canned rows from
|
||||
// `_selectResult` for the main query, and lets us swap the result
|
||||
// for the replacements lookup via a "second-call" hook.
|
||||
|
||||
let invoiceRowsForRun = [];
|
||||
let replacementsRowsForRun = [];
|
||||
let inboundRowsForRun = [];
|
||||
let expenseRowsForRun = [];
|
||||
let costTablesPresent = false;
|
||||
let callCount = 0;
|
||||
|
||||
function makeChain(initialRows) {
|
||||
const c = {
|
||||
_rows: initialRows,
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._rows).then(onResolve, onReject);
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
whereRaw: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
orderByRaw: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
// Migration 126 added a Skonto aggregate that hits
|
||||
// `invoice_payment_log` — route those explicitly to an empty list so
|
||||
// the test surface stays focused on the invoices/replacements flow.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
// Cost side (#4): incoming invoices + internal expenses.
|
||||
if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun);
|
||||
if (tableName === 'expenses') return makeChain(expenseRowsForRun);
|
||||
// `invoices` is queried for the main listing (call 1) and, when there
|
||||
// are cancelled rows, the replacements lookup (call 2).
|
||||
callCount += 1;
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
// loadCosts (#4) schema-guards each cost table. Default off so the
|
||||
// revenue-only tests are unaffected; cost-side tests flip it on.
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) };
|
||||
// `.raw()` is used in the .select() column list for the event_name
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments and returns the mocked rows, so the raw() return value
|
||||
// just needs to exist — a string is fine.
|
||||
mockDbFn.raw = jest.fn((sql) => sql);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
const taxReportService = require('../../src/services/taxReportService');
|
||||
const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReportService._internal;
|
||||
|
||||
beforeEach(() => {
|
||||
invoiceRowsForRun = [];
|
||||
replacementsRowsForRun = [];
|
||||
inboundRowsForRun = [];
|
||||
expenseRowsForRun = [];
|
||||
costTablesPresent = false;
|
||||
callCount = 0;
|
||||
mockDbFn.mockClear();
|
||||
});
|
||||
|
||||
// ----- pure helpers ----------------------------------------------------
|
||||
|
||||
describe('grossUpLateFee', () => {
|
||||
it('returns zeros for a zero or negative fee', () => {
|
||||
expect(grossUpLateFee(0, 7.7)).toEqual({ net: 0, vat: 0 });
|
||||
expect(grossUpLateFee(-100, 7.7)).toEqual({ net: 0, vat: 0 });
|
||||
expect(grossUpLateFee(null, 7.7)).toEqual({ net: 0, vat: 0 });
|
||||
});
|
||||
|
||||
it('returns the whole fee as net when VAT rate is 0', () => {
|
||||
expect(grossUpLateFee(2500, 0)).toEqual({ net: 2500, vat: 0 });
|
||||
// Missing/invalid rate is treated the same.
|
||||
expect(grossUpLateFee(2500, null)).toEqual({ net: 2500, vat: 0 });
|
||||
});
|
||||
|
||||
it('splits a 25.00 CHF fee at 7.7% into net 23.21 + VAT 1.79', () => {
|
||||
// 2500 / 1.077 = 2321.265… → rounds to 2321; 2500 - 2321 = 179.
|
||||
expect(grossUpLateFee(2500, 7.7)).toEqual({ net: 2321, vat: 179 });
|
||||
});
|
||||
|
||||
it('guarantees net + vat === gross input (no rounding drift)', () => {
|
||||
for (const fee of [1, 2500, 9999, 12345, 250000]) {
|
||||
for (const rate of [7.7, 8.1, 19, 20.5]) {
|
||||
const { net, vat } = grossUpLateFee(fee, rate);
|
||||
expect(net + vat).toBe(fee);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeReportedAmounts', () => {
|
||||
it('returns stored amounts unchanged when late fee is zero', () => {
|
||||
const r = computeReportedAmounts({
|
||||
net_amount_minor: 10000,
|
||||
vat_amount_minor: 770,
|
||||
total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0,
|
||||
vat_rate: 7.7,
|
||||
});
|
||||
expect(r).toEqual({ netMinor: 10000, vatMinor: 770, totalMinor: 10770 });
|
||||
});
|
||||
|
||||
it('adds the late-fee net/vat split onto the stored net + vat', () => {
|
||||
const r = computeReportedAmounts({
|
||||
net_amount_minor: 10000,
|
||||
vat_amount_minor: 770,
|
||||
total_amount_minor: 13270, // 10000 + 770 + 2500 late fee
|
||||
late_fee_amount_minor: 2500,
|
||||
vat_rate: 7.7,
|
||||
});
|
||||
expect(r.netMinor).toBe(10000 + 2321);
|
||||
expect(r.vatMinor).toBe(770 + 179);
|
||||
expect(r.totalMinor).toBe(13270);
|
||||
});
|
||||
|
||||
it('keeps total at the stored total even when late fee is present', () => {
|
||||
// The stored total already includes the late fee — we never
|
||||
// recompute it from net + vat in the report.
|
||||
const r = computeReportedAmounts({
|
||||
net_amount_minor: 50000,
|
||||
vat_amount_minor: 4050,
|
||||
total_amount_minor: 56550,
|
||||
late_fee_amount_minor: 2500,
|
||||
vat_rate: 8.1,
|
||||
});
|
||||
expect(r.totalMinor).toBe(56550);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCustomerLabel', () => {
|
||||
it('prefers company_name when present', () => {
|
||||
expect(buildCustomerLabel({
|
||||
customer_company_name: 'ACME GmbH',
|
||||
customer_first_name: 'Anna',
|
||||
customer_last_name: 'Beispiel',
|
||||
customer_email: 'anna@example.com',
|
||||
})).toBe('ACME GmbH');
|
||||
});
|
||||
|
||||
it('falls back to first + last name', () => {
|
||||
expect(buildCustomerLabel({
|
||||
customer_company_name: '',
|
||||
customer_first_name: 'Anna',
|
||||
customer_last_name: 'Beispiel',
|
||||
})).toBe('Anna Beispiel');
|
||||
});
|
||||
|
||||
it('falls back to display_name when no name parts', () => {
|
||||
expect(buildCustomerLabel({
|
||||
customer_display_name: 'Anna B.',
|
||||
})).toBe('Anna B.');
|
||||
});
|
||||
|
||||
it('falls back to email as a last resort', () => {
|
||||
expect(buildCustomerLabel({ customer_email: 'anna@example.com' })).toBe('anna@example.com');
|
||||
});
|
||||
|
||||
it('returns empty string when nothing usable is present', () => {
|
||||
expect(buildCustomerLabel({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ----- getTaxReport ----------------------------------------------------
|
||||
|
||||
describe('getTaxReport', () => {
|
||||
it('throws when from/to or currency are missing', async () => {
|
||||
await expect(taxReportService.getTaxReport({})).rejects.toThrow(/from.+to/);
|
||||
await expect(taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31' }))
|
||||
.rejects.toThrow(/currency/);
|
||||
});
|
||||
|
||||
it('returns rows + totals for a clean period with one paid invoice', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
|
||||
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'chf', // lowercase → coerced
|
||||
});
|
||||
expect(out.currency).toBe('CHF');
|
||||
expect(out.rows).toHaveLength(1);
|
||||
expect(out.rows[0]).toMatchObject({
|
||||
invoiceNumber: 'R-2026-0001',
|
||||
isCancelled: false,
|
||||
customerLabel: 'ACME GmbH',
|
||||
eventName: 'Wedding A',
|
||||
netMinor: 10000,
|
||||
vatMinor: 770,
|
||||
totalMinor: 10770,
|
||||
});
|
||||
expect(out.grandTotalNet).toBe(10000);
|
||||
expect(out.grandTotalVat).toBe(770);
|
||||
expect(out.grandTotal).toBe(10770);
|
||||
expect(out.cancelledCount).toBe(0);
|
||||
expect(out.totalsByVatRate).toEqual([
|
||||
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
|
||||
]);
|
||||
expect(out.period).toEqual({ from: '2026-01-01', to: '2026-03-31' });
|
||||
});
|
||||
|
||||
it('keeps cancelled rows visible but excludes them from totals', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 10, invoice_number: 'R-2026-0010', issue_date: '2026-02-01',
|
||||
currency: 'CHF', status: 'cancelled', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
|
||||
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
|
||||
},
|
||||
{
|
||||
id: 11, invoice_number: 'R-2026-0011', issue_date: '2026-02-02',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: 10,
|
||||
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
|
||||
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
// The supersedes lookup query: row 11 supersedes row 10.
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 10, invoice_number: 'R-2026-0011' }];
|
||||
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.rows).toHaveLength(2);
|
||||
const cancelled = out.rows.find((r) => r.invoiceNumber === 'R-2026-0010');
|
||||
const replacement = out.rows.find((r) => r.invoiceNumber === 'R-2026-0011');
|
||||
expect(cancelled.isCancelled).toBe(true);
|
||||
expect(cancelled.replacedByInvoiceNumber).toBe('R-2026-0011');
|
||||
expect(replacement.isCancelled).toBe(false);
|
||||
|
||||
// Totals: only the replacement counts.
|
||||
expect(out.grandTotalNet).toBe(10000);
|
||||
expect(out.grandTotalVat).toBe(770);
|
||||
expect(out.grandTotal).toBe(10770);
|
||||
expect(out.cancelledCount).toBe(1);
|
||||
expect(out.totalsByVatRate).toEqual([
|
||||
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
|
||||
// The real cancel-and-reissue flow produces THREE rows in the period:
|
||||
// the cancelled original, its negative Storno (kind='storno', status='sent'),
|
||||
// and the reissue. Totals must read the reissued amount, not 0.
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
|
||||
currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
},
|
||||
{
|
||||
id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
|
||||
currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
|
||||
net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
},
|
||||
{
|
||||
id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
|
||||
currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: 20,
|
||||
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
|
||||
|
||||
const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
|
||||
expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
|
||||
// The negative storno must NOT net against the totals (the cancelled
|
||||
// original is already excluded) — the reissued revenue stands.
|
||||
expect(out.grandTotalNet).toBe(10000);
|
||||
expect(out.grandTotalVat).toBe(770);
|
||||
expect(out.grandTotal).toBe(10770);
|
||||
expect(out.totalsByVatRate).toEqual([
|
||||
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-01',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'A', event_name: 'X',
|
||||
},
|
||||
{
|
||||
id: 2, invoice_number: 'R-2026-0002', issue_date: '2026-01-02',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 8.1,
|
||||
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'B', event_name: 'Y',
|
||||
},
|
||||
{
|
||||
id: 3, invoice_number: 'R-2026-0003', issue_date: '2026-01-03',
|
||||
currency: 'CHF', status: 'sent', vat_rate: 8.1,
|
||||
net_amount_minor: 5000, vat_amount_minor: 405, total_amount_minor: 5405,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'C', event_name: 'Z',
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.totalsByVatRate).toHaveLength(2);
|
||||
// Sorted ascending by rate.
|
||||
expect(out.totalsByVatRate[0]).toEqual({
|
||||
vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770,
|
||||
});
|
||||
expect(out.totalsByVatRate[1]).toEqual({
|
||||
vatRate: 8.1, netMinor: 25000, vatMinor: 2025, totalMinor: 27025,
|
||||
});
|
||||
expect(out.grandTotalNet).toBe(35000);
|
||||
expect(out.grandTotalVat).toBe(2795);
|
||||
expect(out.grandTotal).toBe(37795);
|
||||
});
|
||||
|
||||
it('folds late fees into the reporting net + vat (gross-up per VAT rate)', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'overdue', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770,
|
||||
total_amount_minor: 13270, // 10000 + 770 + 2500 fee
|
||||
late_fee_amount_minor: 2500, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
// Late fee 2500 @ 7.7% → net 2321 + vat 179.
|
||||
expect(out.rows[0].netMinor).toBe(12321);
|
||||
expect(out.rows[0].vatMinor).toBe(949);
|
||||
expect(out.rows[0].totalMinor).toBe(13270);
|
||||
// Grand totals reflect the same gross-up math.
|
||||
expect(out.grandTotalNet).toBe(12321);
|
||||
expect(out.grandTotalVat).toBe(949);
|
||||
expect(out.grandTotal).toBe(13270);
|
||||
});
|
||||
|
||||
it('returns empty rows + zero totals when no invoices match the period', async () => {
|
||||
invoiceRowsForRun = [];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.rows).toEqual([]);
|
||||
expect(out.grandTotalNet).toBe(0);
|
||||
expect(out.grandTotalVat).toBe(0);
|
||||
expect(out.grandTotal).toBe(0);
|
||||
expect(out.totalsByVatRate).toEqual([]);
|
||||
expect(out.cancelledCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'X',
|
||||
},
|
||||
];
|
||||
costTablesPresent = false; // no accounting migrations on this DB
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 });
|
||||
expect(out.summary).toMatchObject({
|
||||
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
|
||||
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
|
||||
resultNetMinor: 10000, resultGrossMinor: 10770,
|
||||
// VAT registration unconfigured in the test DB → refuse to compute payable.
|
||||
vatRegistrationConfigured: false, vatPayableMinor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
costTablesPresent = true;
|
||||
// Incoming supplier invoice: net 20000 + vat 1540 = 21540.
|
||||
inboundRowsForRun = [
|
||||
{
|
||||
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
|
||||
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
|
||||
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
|
||||
net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540,
|
||||
event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
// Internal expense (mileage, no VAT split): only a CHF base amount.
|
||||
expenseRowsForRun = [
|
||||
{
|
||||
id: 9, created_at: '2026-02-01 12:00:00',
|
||||
supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand',
|
||||
tax_treatment: 'domestic', status: 'open', event_id: null,
|
||||
original_currency: null, original_amount_minor: null, chf_amount_minor: 5000,
|
||||
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null,
|
||||
event_name: null,
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
|
||||
expect(out.costs.rows).toHaveLength(2);
|
||||
// Incoming invoice mapped + booked to the event.
|
||||
const incoming = out.costs.rows.find((r) => r.source === 'incoming');
|
||||
expect(incoming).toMatchObject({
|
||||
supplierLabel: 'Lab AG', eventName: 'Wedding A',
|
||||
netMinor: 20000, vatMinor: 1540, totalMinor: 21540,
|
||||
});
|
||||
// Expense: no net/vat/gross → falls back to the CHF base as total,
|
||||
// and (company-booked) event name blank.
|
||||
const expense = out.costs.rows.find((r) => r.source === 'expense');
|
||||
expect(expense).toMatchObject({
|
||||
eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000,
|
||||
});
|
||||
|
||||
expect(out.costs.totalNet).toBe(25000);
|
||||
expect(out.costs.totalVat).toBe(1540);
|
||||
expect(out.costs.totalGross).toBe(26540);
|
||||
|
||||
// Summary nets income against costs.
|
||||
expect(out.summary).toMatchObject({
|
||||
incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700,
|
||||
costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540,
|
||||
resultNetMinor: 75000, resultGrossMinor: 81160,
|
||||
vatRegistrationConfigured: false, vatPayableMinor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => {
|
||||
costTablesPresent = true;
|
||||
inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed
|
||||
expenseRowsForRun = [];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.costs.totalGross).toBe(0);
|
||||
expect(out.summary.costGrossMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ----- export scope (income/cost split) --------------------------------
|
||||
describe('export scope helpers', () => {
|
||||
const { scopeLedger, normalizeScope } = taxReportService._internal;
|
||||
const ledger = [
|
||||
{ type: 'outgoing', reference: 'R-1' },
|
||||
{ type: 'incoming', reference: 'IN-1' },
|
||||
{ type: 'expense', reference: 'EXP-1' },
|
||||
];
|
||||
|
||||
it('normalizeScope defaults unknown/empty to "all"', () => {
|
||||
expect(normalizeScope('all')).toBe('all');
|
||||
expect(normalizeScope('income')).toBe('income');
|
||||
expect(normalizeScope('cost')).toBe('cost');
|
||||
expect(normalizeScope('bogus')).toBe('all');
|
||||
expect(normalizeScope(undefined)).toBe('all');
|
||||
});
|
||||
|
||||
it('scopeLedger income keeps only outgoing rows', () => {
|
||||
expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']);
|
||||
});
|
||||
|
||||
it('scopeLedger cost keeps incoming + expense rows', () => {
|
||||
expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']);
|
||||
});
|
||||
|
||||
it('scopeLedger all keeps everything; null-safe', () => {
|
||||
expect(scopeLedger(ledger, 'all')).toHaveLength(3);
|
||||
expect(scopeLedger(null, 'income')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTaxReportCsv scope', () => {
|
||||
beforeEach(() => {
|
||||
costTablesPresent = true;
|
||||
invoiceRowsForRun = [{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 8.1,
|
||||
net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'Wedding A',
|
||||
}];
|
||||
inboundRowsForRun = [{
|
||||
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
|
||||
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
|
||||
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
|
||||
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
|
||||
event_name: 'Wedding A',
|
||||
}];
|
||||
});
|
||||
|
||||
it('income scope keeps the invoice row, drops the supplier cost row', async () => {
|
||||
const { content, filename } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income',
|
||||
});
|
||||
expect(content).toContain('R-2026-0001');
|
||||
expect(content).not.toContain('Lab AG');
|
||||
expect(filename).toContain('income_');
|
||||
});
|
||||
|
||||
it('cost scope keeps the supplier row, drops the invoice row', async () => {
|
||||
const { content, filename } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost',
|
||||
});
|
||||
expect(content).toContain('Lab AG');
|
||||
expect(content).not.toContain('R-2026-0001');
|
||||
expect(filename).toContain('cost_');
|
||||
});
|
||||
|
||||
it('all scope (default) keeps both', async () => {
|
||||
const { content, filename } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(content).toContain('R-2026-0001');
|
||||
expect(content).toContain('Lab AG');
|
||||
expect(filename).not.toMatch(/income_|cost_/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
|
||||
*
|
||||
* Pins the contract that drives `adminDashboard.js` analytics route:
|
||||
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
|
||||
* - Returns an Umami adapter shape for provider='umami'.
|
||||
* - Returns a Rybbit adapter shape for provider='rybbit'.
|
||||
* - Back-compat: when `analytics_tracker_provider` is unset, infers
|
||||
* 'umami' from the legacy `analytics_umami_enabled` flag.
|
||||
* - Invalid provider strings fall through to the legacy back-compat path
|
||||
* rather than crashing (defensive).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tracker-fact-')), 'db.sqlite',
|
||||
);
|
||||
|
||||
const { bootCrmDb } = require('../integration/helpers/crmDb');
|
||||
const trackers = require('../../src/services/trackers');
|
||||
|
||||
let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
});
|
||||
|
||||
async function setSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'analytics',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveAdapter (#663)', () => {
|
||||
test('returns null when provider=\'none\'', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'none');
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'custom');
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
|
||||
await setSetting('analytics_umami_enabled', true);
|
||||
await setSetting('analytics_umami_url', 'https://u.example');
|
||||
await setSetting('analytics_umami_website_id', 'w-1');
|
||||
await setSetting('analytics_umami_api_key', 'k-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter).not.toBeNull();
|
||||
expect(adapter.provider).toBe('umami');
|
||||
});
|
||||
|
||||
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'umami');
|
||||
await setSetting('analytics_umami_url', 'https://u.example');
|
||||
await setSetting('analytics_umami_website_id', 'w-1');
|
||||
await setSetting('analytics_umami_api_key', 'k-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter.provider).toBe('umami');
|
||||
});
|
||||
|
||||
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'rybbit');
|
||||
await setSetting('analytics_rybbit_url', 'https://r.example');
|
||||
await setSetting('analytics_rybbit_website_id', 'r-1');
|
||||
await setSetting('analytics_rybbit_api_key', 'rk-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter.provider).toBe('rybbit');
|
||||
});
|
||||
|
||||
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
|
||||
// No legacy umami_enabled → resolves to null (= 'none')
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
|
||||
* the old `umamiClient.test.js` from #662 — same contract, new shape).
|
||||
*
|
||||
* Pins the same 10 cases that protected the original implementation: missing
|
||||
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
|
||||
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
|
||||
*/
|
||||
|
||||
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
|
||||
|
||||
const ORIGINAL_FETCH = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
function mockJson(body, { status = 200 } = {}) {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}));
|
||||
}
|
||||
|
||||
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
|
||||
|
||||
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
|
||||
test('returns null when config is incomplete (back-compat path)', async () => {
|
||||
const a = buildAdapter({});
|
||||
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
const b = buildAdapter({ baseUrl: 'https://u' });
|
||||
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
expect(global.fetch).toBe(ORIGINAL_FETCH);
|
||||
});
|
||||
|
||||
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
|
||||
mockJson([{ x: 'desktop', y: 10 }]);
|
||||
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
|
||||
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toBe(
|
||||
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
|
||||
);
|
||||
expect(init.headers['x-umami-api-key']).toBe('secret');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
test('URL-encodes the websiteId for reserved chars', async () => {
|
||||
mockJson([{ x: 'desktop', y: 1 }]);
|
||||
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
|
||||
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
const [calledUrl] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
|
||||
});
|
||||
|
||||
test('normalises { x, y } payload into integer percentages', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 60 },
|
||||
{ x: 'mobile', y: 30 },
|
||||
{ x: 'tablet', y: 10 },
|
||||
]);
|
||||
const a = buildAdapter(valid);
|
||||
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
|
||||
});
|
||||
|
||||
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 50 },
|
||||
{ x: 'laptop', y: 20 },
|
||||
{ x: 'mobile', y: 30 },
|
||||
]);
|
||||
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
|
||||
});
|
||||
|
||||
test('drops unknown buckets (no silent miscategorisation)', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 80 },
|
||||
{ x: 'mobile', y: 20 },
|
||||
{ x: 'unknown-future-bucket', y: 100 },
|
||||
]);
|
||||
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('returns null on empty payload', async () => {
|
||||
mockJson([]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on non-2xx', async () => {
|
||||
mockJson({ error: 'unauthorized' }, { status: 401 });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on invalid JSON', async () => {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('not json'); },
|
||||
}));
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on network error', async () => {
|
||||
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Coverage for the changelog aggregation introduced for #567.
|
||||
*
|
||||
* `getReleasesSince` is what feeds the update-available modal so the
|
||||
* admin can see release notes for every version between their current
|
||||
* version and latest. The cases below pin:
|
||||
*
|
||||
* - Strictly-newer filtering (the running version itself never
|
||||
* appears in the list).
|
||||
* - Channel filtering (a stable user does not see beta releases,
|
||||
* and vice versa).
|
||||
* - Empty-result fallback when the GitHub fetch returns nothing
|
||||
* (cached null from `fetchAvailableVersions`).
|
||||
*
|
||||
* We mock axios's GitHub response — these tests are pure logic, no
|
||||
* network. Cache is cleared between cases via the service's exported
|
||||
* `clearCache()` to avoid bleed.
|
||||
*/
|
||||
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
const { getReleasesSince, clearCache } = require('../../src/services/updateCheckService');
|
||||
|
||||
function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
|
||||
return {
|
||||
tag_name: tag,
|
||||
name: tag,
|
||||
body,
|
||||
published_at: publishedAt,
|
||||
html_url: `https://github.com/PicPeak/picpeak/releases/tag/${tag}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('updateCheckService.getReleasesSince', () => {
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
axios.get.mockReset();
|
||||
});
|
||||
|
||||
it('returns only releases strictly newer than current, for the requested channel', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: [
|
||||
release('v3.55.0', 'stable notes 3.55.0'),
|
||||
release('v3.54.0', 'stable notes 3.54.0'),
|
||||
release('v3.43.1', 'stable notes 3.43.1 — the running version, should be excluded'),
|
||||
release('v3.43.0'),
|
||||
release('v3.55.0-beta.0', 'beta notes — wrong channel, excluded'),
|
||||
release('v3.54.0-beta.5'),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getReleasesSince('3.43.1', 'stable');
|
||||
|
||||
expect(result.map((r) => r.version)).toEqual(['3.55.0', '3.54.0']);
|
||||
// Body + html_url + publishedAt are preserved so the modal can render them
|
||||
expect(result[0]).toMatchObject({
|
||||
version: '3.55.0',
|
||||
tag: 'v3.55.0',
|
||||
name: 'v3.55.0',
|
||||
body: 'stable notes 3.55.0',
|
||||
htmlUrl: 'https://github.com/PicPeak/picpeak/releases/tag/v3.55.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only beta releases for a beta user', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: [
|
||||
release('v3.55.0-beta.0'),
|
||||
release('v3.54.7-beta.0'),
|
||||
release('v3.55.0'), // stable — wrong channel for a beta user
|
||||
release('v3.54.6-beta.0'),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getReleasesSince('3.54.6-beta.0', 'beta');
|
||||
|
||||
// Strictly newer beta-channel only — does not include 3.55.0 stable
|
||||
// even though it's a newer release, because the beta channel user
|
||||
// wants to see beta releases (which can include releases that
|
||||
// landed on the beta line after the stable cut).
|
||||
expect(result.map((r) => r.version)).toEqual(['3.55.0-beta.0', '3.54.7-beta.0']);
|
||||
});
|
||||
|
||||
it('returns empty array when GitHub fetch fails (e.g. rate-limited)', async () => {
|
||||
axios.get.mockRejectedValue(new Error('API rate limit exceeded'));
|
||||
|
||||
const result = await getReleasesSince('3.43.1', 'stable');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when the user is already on the latest version', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: [release('v3.55.0'), release('v3.54.0')],
|
||||
});
|
||||
|
||||
const result = await getReleasesSince('3.55.0', 'stable');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Coverage for the activate + delete admin-user actions introduced as
|
||||
* the #574 UI follow-up.
|
||||
*
|
||||
* Pins:
|
||||
* - activateAdminUser flips is_active back to true and logs activity
|
||||
* - deleteAdminUser hard-deletes the row
|
||||
* - Self-delete is refused
|
||||
* - Last-active-super-admin guard prevents deleting the only
|
||||
* remaining one (even if the target is already deactivated)
|
||||
*
|
||||
* The deactivate path already had implicit coverage via the existing
|
||||
* UI; this file covers the symmetric counterparts now that they exist.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-user-act-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'user-act-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const userManagementService = require('../../src/services/userManagementService');
|
||||
|
||||
describe('userManagementService — activate + delete (#574 follow-up)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let actorId; // The admin performing the actions (must be active + super_admin)
|
||||
let targetId; // The admin we'll deactivate / reactivate / delete
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: actorId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, actorId, 'super_admin');
|
||||
|
||||
// Second admin to be the target of our actions. Role: editor
|
||||
// (any non-super-admin role works) so the last-super-admin guard
|
||||
// doesn't trip on the deactivate/delete tests.
|
||||
const editor = await db('roles').where({ name: 'editor' }).first();
|
||||
const targetInsert = await db('admin_users').insert({
|
||||
username: 'target', email: 'target@example.com',
|
||||
password_hash: 'x', role_id: editor?.id || null,
|
||||
is_active: 1, created_at: new Date(),
|
||||
}).returning('id');
|
||||
targetId = targetInsert[0]?.id ?? targetInsert[0];
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('activateAdminUser', () => {
|
||||
it('flips is_active back to true when target is deactivated', async () => {
|
||||
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
|
||||
await userManagementService.activateAdminUser(targetId, actorId);
|
||||
const row = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(row.is_active === true || row.is_active === 1).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when target is already active', async () => {
|
||||
await db('admin_users').where({ id: targetId }).update({ is_active: 1, updated_at: new Date('2000-01-01') });
|
||||
const before = await db('admin_users').where({ id: targetId }).first();
|
||||
await userManagementService.activateAdminUser(targetId, actorId);
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
// updated_at NOT bumped — short-circuit fires before the update
|
||||
expect(after.updated_at).toEqual(before.updated_at);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when target does not exist', async () => {
|
||||
await expect(userManagementService.activateAdminUser(99999, actorId))
|
||||
.rejects.toThrow(/not found|admin user/i);
|
||||
});
|
||||
|
||||
it('writes an admin_user_activated activity log entry', async () => {
|
||||
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
|
||||
await userManagementService.activateAdminUser(targetId, actorId);
|
||||
const log = await db('activity_logs')
|
||||
.where({ activity_type: 'admin_user_activated' })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
expect(log).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAdminUser', () => {
|
||||
it('refuses self-deletion with ValidationError', async () => {
|
||||
await expect(userManagementService.deleteAdminUser(actorId, actorId))
|
||||
.rejects.toThrow(/own account/i);
|
||||
// Actor still exists
|
||||
const row = await db('admin_users').where({ id: actorId }).first();
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('refuses to delete the last active super_admin even when target is deactivated', async () => {
|
||||
// Promote target to super_admin and deactivate it. Now there's
|
||||
// only ONE active super_admin (the actor). Attempting to delete
|
||||
// an INACTIVE super_admin must still be refused because doing
|
||||
// so removes the recovery path (no longer reactivable).
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const deactivatedSuperInsert = await db('admin_users').insert({
|
||||
username: 'inactive-super', email: 'inactive-super@example.com',
|
||||
password_hash: 'x', role_id: superRole.id, is_active: 0,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const inactiveSuperId = deactivatedSuperInsert[0]?.id ?? deactivatedSuperInsert[0];
|
||||
|
||||
// Actor is the ONLY active super_admin. Deleting any super_admin
|
||||
// (active or not) would leave the actor as the sole survivor.
|
||||
// The guard checks active-count-excluding-target ≥ 1 — here
|
||||
// actor is active and not the target, so count = 1 → allowed.
|
||||
await userManagementService.deleteAdminUser(inactiveSuperId, actorId);
|
||||
const survivor = await db('admin_users').where({ id: inactiveSuperId }).first();
|
||||
expect(survivor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hard-deletes the row when guards pass', async () => {
|
||||
// Recreate the target since previous tests may have left it active
|
||||
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
|
||||
await userManagementService.deleteAdminUser(targetId, actorId);
|
||||
const row = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes an admin_user_deleted activity log entry', async () => {
|
||||
// Need a fresh target since the previous test deleted ours.
|
||||
const editor = await db('roles').where({ name: 'editor' }).first();
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: 'about-to-go', email: 'about-to-go@example.com',
|
||||
password_hash: 'x', role_id: editor?.id || null,
|
||||
is_active: 0, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
await userManagementService.deleteAdminUser(id, actorId);
|
||||
const log = await db('activity_logs')
|
||||
.where({ activity_type: 'admin_user_deleted' })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
expect(log).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Pins the date-merge fix in `adminDashboard.js` /analytics route
|
||||
* (#661 Bug A). The merge previously failed on Postgres because pg's
|
||||
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
|
||||
* returns a string — the old `dateObj.date === row.date` comparison
|
||||
* was false on Postgres so chartData stayed all-zero even with traffic.
|
||||
*
|
||||
* We test the normalisation helper here in isolation. The route-level
|
||||
* integration is covered by the existing dashboard route test.
|
||||
*/
|
||||
|
||||
// The helper is internal to the route file; reimport via a small wrapper
|
||||
// so we don't need to export everything publicly.
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROUTE_SRC = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/routes/adminDashboard.js'),
|
||||
'utf8',
|
||||
);
|
||||
// Tiny evaluator that grabs the normaliseDateKey function definition from
|
||||
// the route source so the test pins the actual shipping implementation,
|
||||
// not a copy.
|
||||
function extractNormaliseDateKey() {
|
||||
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
|
||||
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(`${match[0]}; return normaliseDateKey;`)();
|
||||
}
|
||||
|
||||
const normaliseDateKey = extractNormaliseDateKey();
|
||||
|
||||
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
|
||||
test('passes through a YYYY-MM-DD string unchanged', () => {
|
||||
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
|
||||
});
|
||||
|
||||
test('slices off a time component on a longer ISO string', () => {
|
||||
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
|
||||
});
|
||||
|
||||
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
|
||||
const d = new Date('2026-06-22T12:34:56Z');
|
||||
expect(normaliseDateKey(d)).toBe('2026-06-22');
|
||||
});
|
||||
|
||||
test('returns null for null / undefined / empty', () => {
|
||||
expect(normaliseDateKey(null)).toBeNull();
|
||||
expect(normaliseDateKey(undefined)).toBeNull();
|
||||
expect(normaliseDateKey('')).toBeNull();
|
||||
});
|
||||
|
||||
test('coerces unexpected types via String() to avoid throwing', () => {
|
||||
// We don't expect to receive a number from either driver, but the
|
||||
// helper should not crash if it does — date merge will simply miss.
|
||||
expect(normaliseDateKey(20260622)).toBe('20260622');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unit tests for the per-weekday business-hours floor (migration 114).
|
||||
* Exercises the pure snap logic against a fixed IANA zone so the results
|
||||
* don't drift with the CI box's local timezone.
|
||||
*
|
||||
* All scenarios use Europe/Zurich (the regulatory-scope default).
|
||||
*/
|
||||
|
||||
const {
|
||||
snapToBusinessHours,
|
||||
parseHHMM,
|
||||
minutesToHHMM,
|
||||
normaliseSchedule,
|
||||
hasAnyBlocks,
|
||||
_internal,
|
||||
} = require('../../src/utils/businessHours');
|
||||
|
||||
const TZ = 'Europe/Zurich';
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Pure-function tests for the slug validator in galleryShortUrlService.
|
||||
* The validator is the security boundary for the `/s/<slug>` public
|
||||
* route — bad shapes leak into a UNIQUE column that's used in URLs
|
||||
* without further escaping, so the rules need to be tight.
|
||||
*/
|
||||
|
||||
// Provide a minimal db stub so requiring the service doesn't crash —
|
||||
// the validator path doesn't touch the DB.
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const {
|
||||
validateSlug,
|
||||
_RESERVED_SLUGS,
|
||||
} = require('../../src/services/galleryShortUrlService');
|
||||
|
||||
describe('validateSlug', () => {
|
||||
describe('accepts', () => {
|
||||
test.each([
|
||||
'sofia-graduation',
|
||||
'sofia',
|
||||
'a', // single char (alphanumeric)
|
||||
'1', // single digit
|
||||
'abc123',
|
||||
'123-abc',
|
||||
'sofia-2026-06-05',
|
||||
'sofia-2026',
|
||||
'a-b-c-d',
|
||||
'wedding-2026',
|
||||
'xK7p2'.toLowerCase(), // lowercase 5-char
|
||||
'a'.repeat(64), // exactly at the limit
|
||||
])('%j', (slug) => {
|
||||
expect(validateSlug(slug)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejects', () => {
|
||||
test.each([
|
||||
['', 'cannot be empty'],
|
||||
[' ', 'cannot be empty'], // trimmed → empty
|
||||
['-sofia', 'lowercase letters'], // leading hyphen
|
||||
['sofia-', 'lowercase letters'], // trailing hyphen
|
||||
['Sofia', 'lowercase letters'], // uppercase
|
||||
['sofia_graduation', 'lowercase letters'], // underscore
|
||||
['sofia.graduation', 'lowercase letters'], // dot
|
||||
['sofia graduation', 'lowercase letters'], // space
|
||||
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
|
||||
['sofia%20graduation', 'lowercase letters'],
|
||||
['a'.repeat(65), 'at most 64'], // one over limit
|
||||
])('%j → %s', (slug, expectedReason) => {
|
||||
const result = validateSlug(slug);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.toLowerCase()).toContain(expectedReason);
|
||||
});
|
||||
|
||||
test('null', () => {
|
||||
expect(validateSlug(null)).toContain('must be a string');
|
||||
});
|
||||
|
||||
test('undefined', () => {
|
||||
expect(validateSlug(undefined)).toContain('must be a string');
|
||||
});
|
||||
|
||||
test('number', () => {
|
||||
expect(validateSlug(42)).toContain('must be a string');
|
||||
});
|
||||
|
||||
test('object', () => {
|
||||
expect(validateSlug({})).toContain('must be a string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved slugs', () => {
|
||||
test.each([
|
||||
'admin',
|
||||
'api',
|
||||
'auth',
|
||||
'gallery',
|
||||
'og',
|
||||
'health',
|
||||
's', // can't shadow the shortener itself
|
||||
'login',
|
||||
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
|
||||
])('reserves %j', (slug) => {
|
||||
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
|
||||
});
|
||||
|
||||
test('"admin" → rejected with "reserved" reason', () => {
|
||||
// validateSlug short-circuits at the regex for slugs containing
|
||||
// dots (favicon.ico fails the regex first). Test a clean
|
||||
// alphanumeric reserved word.
|
||||
const result = validateSlug('admin');
|
||||
expect(result).toBe('short_slug is reserved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
|
||||
test.each([
|
||||
'../etc/passwd',
|
||||
'foo/../bar',
|
||||
'foo?query=1',
|
||||
'foo#fragment',
|
||||
'foo&bar',
|
||||
'foo bar',
|
||||
'foo<script>',
|
||||
'foo>',
|
||||
'foo"',
|
||||
'foo\'',
|
||||
'foo;rm -rf',
|
||||
])('%j', (slug) => {
|
||||
expect(validateSlug(slug)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Tests for the ISO 13616 IBAN validator.
|
||||
*
|
||||
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
|
||||
* — they are publicly published sample values used by every IBAN
|
||||
* implementation as test vectors. NOT real account numbers.
|
||||
*/
|
||||
const { validateIban, _internal } = require('../../src/utils/iban');
|
||||
|
||||
describe('validateIban', () => {
|
||||
it('accepts a canonical Swiss IBAN', () => {
|
||||
const out = validateIban('CH9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
expect(out.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a Liechtenstein IBAN', () => {
|
||||
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a German IBAN', () => {
|
||||
expect(validateIban('DE89370400440532013000').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an Austrian IBAN', () => {
|
||||
expect(validateIban('AT611904300234573201').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a British IBAN with alphanumeric BBAN', () => {
|
||||
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('normalises spaces and lowercase input', () => {
|
||||
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('normalises mixed-case input', () => {
|
||||
const out = validateIban('ch9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('rejects an empty / null / undefined value', () => {
|
||||
expect(validateIban('').reason).toBe('EMPTY');
|
||||
expect(validateIban(' ').reason).toBe('EMPTY');
|
||||
expect(validateIban(null).reason).toBe('EMPTY');
|
||||
expect(validateIban(undefined).reason).toBe('EMPTY');
|
||||
});
|
||||
|
||||
it('rejects a malformed string (numbers in the country slot)', () => {
|
||||
const out = validateIban('12930076201162385295');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-short string', () => {
|
||||
expect(validateIban('CH93').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-long string (over 34 chars)', () => {
|
||||
// 35 chars: pads beyond the ISO 13616 max
|
||||
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a known-country IBAN with the wrong length', () => {
|
||||
// CH must be 21 chars; this one is 22.
|
||||
const out = validateIban('CH9300762011623852957X');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('LENGTH');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with a broken checksum', () => {
|
||||
// Same shape, last digit altered.
|
||||
const out = validateIban('CH9300762011623852950');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with internally invalid characters', () => {
|
||||
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('accepts an unknown-country IBAN that meets the generic length range', () => {
|
||||
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
|
||||
// structural regex passes if length is in [15, 34] and the
|
||||
// checksum holds. Build a checksum-valid string:
|
||||
//
|
||||
// Format: ZZ + check + BBAN. We don't have a real ZZ template
|
||||
// so this test just confirms unknown country codes route
|
||||
// through the fallback length check rather than failing on
|
||||
// LENGTH outright. A checksum-failing ZZ value will hit
|
||||
// CHECKSUM, not LENGTH, which is the assertion below.
|
||||
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
|
||||
});
|
||||
});
|
||||
|
||||
describe('mod97', () => {
|
||||
it('returns 1 for the canonical CH test vector', () => {
|
||||
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns something other than 1 for a tampered IBAN', () => {
|
||||
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IBAN_LENGTHS table', () => {
|
||||
it('has the expected lengths for the most common European countries', () => {
|
||||
// Sanity check that the table didn't drift if someone edits it.
|
||||
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
|
||||
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding');
|
||||
|
||||
// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do,
|
||||
// so each test can compare "sum of rounded lines" against cleanNetMinor.
|
||||
function roundedNet(items, parentKey = 'parent_position') {
|
||||
return items
|
||||
.filter((li) => li[parentKey] == null || li[parentKey] === '')
|
||||
.reduce((s, li) => s + Math.round(li.line_total_minor), 0);
|
||||
}
|
||||
|
||||
function mkLine(position, quantity, unitPriceMinor, extra = {}) {
|
||||
const discount = extra.discount_percent || 0;
|
||||
return {
|
||||
position,
|
||||
quantity,
|
||||
unit_price_minor: unitPriceMinor,
|
||||
discount_percent: discount,
|
||||
line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)),
|
||||
parent_position: extra.parent_position ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cleanNetMinor — sub-cent reconciliation', () => {
|
||||
it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => {
|
||||
const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75,
|
||||
4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25];
|
||||
const items = qtys.map((q, i) => mkLine(i + 1, q, 3225));
|
||||
expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines
|
||||
expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once
|
||||
expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift
|
||||
});
|
||||
|
||||
it('is a no-op when every line is already cent-exact (adjustment 0)', () => {
|
||||
const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)];
|
||||
expect(cleanNetMinor(items)).toBe(roundedNet(items));
|
||||
});
|
||||
|
||||
it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => {
|
||||
const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)];
|
||||
// sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56
|
||||
expect(roundedNet(items)).toBe(37557);
|
||||
expect(cleanNetMinor(items)).toBe(37556);
|
||||
});
|
||||
|
||||
it('honours per-line discounts at full precision', () => {
|
||||
const items = [mkLine(1, 3, 1000, { discount_percent: 33 })];
|
||||
// exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010
|
||||
expect(cleanNetMinor(items)).toBe(2010);
|
||||
});
|
||||
|
||||
it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => {
|
||||
// Parent (pos 1) has two priced sub-items; parent own price ignored.
|
||||
const parent = mkLine(1, 1, 9999); // own price should NOT count
|
||||
const subA = mkLine(2, 2.5, 3225, { parent_position: 1 });
|
||||
const subB = mkLine(3, 1.75, 3225, { parent_position: 1 });
|
||||
const items = [parent, subA, subB];
|
||||
// exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706
|
||||
expect(cleanNetMinor(items)).toBe(13706);
|
||||
// parent's own 9999 must not leak in
|
||||
expect(cleanNetMinor(items)).not.toBe(9999);
|
||||
});
|
||||
|
||||
it('exactLineMinor returns the un-rounded product', () => {
|
||||
expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests for the SSRF guard in `networkValidation.js`.
|
||||
*
|
||||
* Regression coverage for GHSA-wmjx-pc37-272r — the original `isPrivateIPv6`
|
||||
* was a string-prefix check that missed NAT64 (`64:ff9b::/96` per RFC 6052,
|
||||
* `64:ff9b:1::/48` per RFC 8215), so a webhook URL like
|
||||
* `http://[64:ff9b:1::a9fe:a9fe]/` could reach 169.254.169.254 on instances
|
||||
* with NAT64/DNS64 egress.
|
||||
*/
|
||||
|
||||
const { validateExternalUrl, isPrivateIP } = require('../../src/utils/networkValidation');
|
||||
|
||||
describe('validateExternalUrl — NAT64 + embedded-IPv4 SSRF', () => {
|
||||
describe('NAT64 well-known prefix (RFC 6052, 64:ff9b::/96)', () => {
|
||||
test.each([
|
||||
['http://[64:ff9b::a9fe:a9fe]/latest/meta-data/', 'AWS metadata via NAT64 hex'],
|
||||
['http://[64:ff9b::169.254.169.254]/', 'AWS metadata via NAT64 mixed notation'],
|
||||
['http://[64:ff9b::7f00:1]/', 'loopback via NAT64'],
|
||||
['http://[64:ff9b::a00:1]/', '10.0.0.1 via NAT64'],
|
||||
])('blocks %s (%s)', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NAT64 local-use prefix (RFC 8215, 64:ff9b:1::/48)', () => {
|
||||
test.each([
|
||||
['http://[64:ff9b:1::a9fe:a9fe]/', 'AWS metadata via local-use NAT64'],
|
||||
['http://[64:ff9b:1::169.254.169.254]/', 'AWS metadata via mixed notation'],
|
||||
['http://[64:ff9b:1::7f00:1]/', 'loopback via local-use NAT64'],
|
||||
['http://[64:ff9b:1:abcd::1]/', 'arbitrary host inside the /48'],
|
||||
])('blocks %s (%s)', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
|
||||
test.each([
|
||||
'http://[::ffff:127.0.0.1]/',
|
||||
'http://[::ffff:7f00:1]/',
|
||||
'http://[::ffff:169.254.169.254]/',
|
||||
'http://[::ffff:a9fe:a9fe]/',
|
||||
'http://[::ffff:10.0.0.1]/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deprecated IPv4-compatible IPv6 (::/96)', () => {
|
||||
test('blocks ::127.0.0.1', () => {
|
||||
expect(validateExternalUrl('http://[::127.0.0.1]/').valid).toBe(false);
|
||||
});
|
||||
test('blocks ::169.254.169.254', () => {
|
||||
expect(validateExternalUrl('http://[::169.254.169.254]/').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing IPv6 private-range coverage stays intact', () => {
|
||||
test.each([
|
||||
'http://[::1]/',
|
||||
'http://[fc00::1]/',
|
||||
'http://[fd12:3456:789a::1]/',
|
||||
'http://[fe80::1]/',
|
||||
'http://[feb0::1]/',
|
||||
'http://[::]/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public IPv6 hosts stay allowed', () => {
|
||||
test.each([
|
||||
'https://[2001:4860:4860::8888]/',
|
||||
'https://[2606:4700:4700::1111]/',
|
||||
'https://[2a00:1450:4001:830::200e]/',
|
||||
])('allows %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing IPv4 private-range coverage stays intact', () => {
|
||||
test.each([
|
||||
'http://127.0.0.1/',
|
||||
'http://10.0.0.1/',
|
||||
'http://172.16.0.1/',
|
||||
'http://192.168.0.1/',
|
||||
'http://169.254.169.254/',
|
||||
'http://0.0.0.0/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked hostnames', () => {
|
||||
test.each([
|
||||
'http://localhost/',
|
||||
'http://metadata.google.internal/',
|
||||
])('blocks %s', (url) => {
|
||||
expect(validateExternalUrl(url).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed parsing', () => {
|
||||
test('isPrivateIP returns true for non-string', () => {
|
||||
expect(isPrivateIP(null)).toBe(true);
|
||||
expect(isPrivateIP(undefined)).toBe(true);
|
||||
expect(isPrivateIP(42)).toBe(true);
|
||||
});
|
||||
test('invalid URLs are rejected', () => {
|
||||
expect(validateExternalUrl('not a url').valid).toBe(false);
|
||||
expect(validateExternalUrl('').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user