Files
picpeak/backend/__tests__
peipeimo bb2f709fdd fix: single-photo gallery downloads 404 on S3 storage backends (#1048)
* fix(gallery): route single-photo downloads through the storage backend

The route resolved a local filesystem path unconditionally and handed it
to res.sendFile. On an S3/R2 deployment managed photos are never on local
disk, so every per-photo download failed — while download-all and
secure-images worked, because they already went through getStorage().
That asymmetry is why it went unnoticed: the gallery looks healthy until a
guest clicks the download button on one photo.

Measured rather than assumed: because sendFile is called WITH a callback,
Express does not send a response when the file is missing and the callback
only logs. The request does not 404, it hangs until the client gives up.
The new tests pin this — all five backend-path cases time out against the
previous implementation.

Two existing pieces do the work, so this mostly deletes code:

- renderPhotoForDownload (#858) already owns resize-then-watermark ordering
  and the storage fetch, and the zip builders in this same file already use
  it. The inline duplicate of that logic goes.
- the pass-through case branches on storage.kind(). Local disk keeps
  res.sendFile: it emits Content-Length, Accept-Ranges, ETag and
  Last-Modified and answers Range with a 206, and sharing one bare
  stream.pipe(res) with S3 would silently drop all of it — a resumed
  download would append a second full body onto the partial file. On S3 the
  parts that matter for a download are reproduced via stat() and getRange().

Ranges are parsed defensively; an unchecked parse yields NaN bounds and a
206 with a nonsense Content-Range, which corrupts a resumed download rather
than failing it. Malformed or unsatisfiable ranges fall back to a 200.

The pre-stream 404s now run before any image header is staged, so the error
goes out as JSON instead of a .jpg attachment containing JSON.

Co-authored-by: peipeimo <peipeimo@users.noreply.github.com>

* fix(gallery): open the stream before staging download headers, honour If-Range

Both from an external review round on this PR.

stat() succeeding does not mean get() will — a concurrent delete or replace,
or a transient backend error, lands between them. The fetch was awaited
AFTER the headers went out, so:

- the range branch had already called writeHead(206), leaving the outer
  catch nothing to do but throw ERR_HTTP_HEADERS_SENT. In practice the
  request hangs: the new regression test sat for the full 120s jest timeout
  against the previous code instead of returning.
- the full branch would have sent its 500 JSON underneath the staged
  image/jpeg attachment headers — a .jpg file full of JSON, which is the
  exact failure this PR set out to stop doing on the 404 paths.

Opening the stream first also lets a vanished object answer 404 and a
transient failure answer 500, instead of both surfacing as a broken body.

If-Range: emitting Last-Modified without honouring the validator built from
it is the dangerous half of the feature. A client resuming after the object
was replaced — the watcher re-importing a swapped file, an admin re-upload —
would get 206 from the NEW bytes and splice two versions into one corrupt
file. A validator that does not match now falls back to a full 200.

4 new tests; 3 of them fail against the previous commit, the fourth is the
matching-validator control that must keep returning 206.

* fix(gallery): HEAD without egress, classify render failures, stage 206 headers

Round-2 findings from the external reviewer.

Express routes HEAD through this GET handler and Node discards the body,
but the pipe still drains the whole object out of S3 first — a metadata
probe from a download manager cost a full transfer in egress and latency.
Everything a HEAD needs is already in stat().

renderPhotoForDownload rejections were all reported as 404. It can equally
fail because getToFile timed out, tmp filled up, or sharp died; calling that
"photo not found" misleads the guest and hides the incident from us. Now
classified the same way the pass-through branch already does.

The 206 path uses status()+set() instead of writeHead(). writeHead commits
the response immediately, so a stream that resolved and then errored before
its first chunk left pipeStreamToResponse able only to destroy the
connection. Staged headers flush on the first body write, so an error at
byte zero now returns a clean retryable status with keep-alive intact.
Credit to the reviewer for the correction — I had assumed deferring the
commit required buffering.

Writing the test for that surfaced one more: pipeStreamToResponse cleared
Content-Type, Content-Length, ETag and Content-Disposition but not the range
headers, so the 500 went out still advertising Content-Range: bytes 0-9/40 —
telling a resuming client the error body IS the partial content.

Not taken: binding response metadata to a fetched object version. That needs
an ETag/versionId on the storage abstraction and conditional GETs in both
adapters; the reviewer agreed it belongs in its own PR rather than blocking
this one.

Backend suites: 485 passed.

* fix(gallery): answer HEAD before the counters and the render

Round-3 finding. The HEAD short-circuit was inside the storage branch, which
sits below both the download_count increment / access_logs insert and
renderPhotoForDownload — so a download manager's metadata probe was recorded
as a real download, and on a watermarked or resized gallery it also pulled
the original from S3 and ran sharp over it to build a body Node then throws
away.

HEAD now leaves the handler right after the access checks, with no side
effects and no bytes read. Content-Length is included only when the photo
ships untransformed and the size is readable from stat(); a watermark or
resize changes the length and the only way to learn the new one is to do the
work this branch exists to avoid. HEAD may omit it.

Not taken, again: binding the read to the statted object version. The
reviewer already agreed in a follow-up that it needs an ETag/versionId on the
storage abstraction plus conditional GETs in both adapters, and belongs in
its own PR. Re-raising it does not change that.

Tests assert the probe moves neither download_count nor access_logs.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: peipeimo <peipeimo@users.noreply.github.com>
2026-09-01 08:17:32 +02:00
..

Enhanced Backup System Test Suite

This directory contains comprehensive tests for the enhanced backup system with S3 support.

Test Structure

Unit Tests

  • services/backupService.enhanced.test.js - Unit tests for the enhanced backup service
    • Configuration management
    • S3 backup functionality
    • Manifest generation
    • Error handling and recovery
    • Backward compatibility (local and rsync)
    • Service lifecycle management

Integration Tests

  • integration/backup-s3.test.js - Integration tests for S3 backups
    • Real S3/MinIO connection tests
    • Full backup process with actual files
    • Incremental backup verification
    • Manifest storage and retrieval
    • Error recovery scenarios

Manual Integration Test Script

  • ../scripts/test-backup-integration.js - Comprehensive manual testing script
    • Can test against MinIO, AWS S3, or any S3-compatible service
    • Tests all backup types (S3, local, rsync)
    • Performance testing with large files
    • Detailed progress reporting

Running Tests

Prerequisites

  1. For Unit Tests: No special setup required, all dependencies are mocked.

  2. For Integration Tests: Requires a running S3-compatible service (MinIO recommended)

    # Start MinIO using Docker
    docker run -d \
      -p 9000:9000 \
      -p 9001:9001 \
      --name minio-test \
      -e MINIO_ROOT_USER=minioadmin \
      -e MINIO_ROOT_PASSWORD=minioadmin \
      minio/minio server /data --console-address ":9001"
    
  3. Environment Variables (for integration tests):

    # Optional - defaults work with local MinIO
    export TEST_S3_ENDPOINT=http://localhost:9000
    export TEST_S3_ACCESS_KEY=minioadmin
    export TEST_S3_SECRET_KEY=minioadmin
    
    # Skip S3 tests if no S3 service available
    export SKIP_S3_TESTS=true
    

Running Unit Tests

# Run all backup service tests
npm test -- __tests__/services/backupService.enhanced.test.js

# Run specific test suite
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"

# Run with coverage
npm test -- --coverage __tests__/services/backupService.enhanced.test.js

Running Integration Tests

# Ensure MinIO is running first!

# Run S3 integration tests
npm test -- __tests__/integration/backup-s3.test.js

# Run with verbose output
npm test -- __tests__/integration/backup-s3.test.js --verbose

# Skip S3 tests if needed
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js

Running Manual Integration Tests

# Test with local MinIO (default)
node scripts/test-backup-integration.js

# Test with AWS S3
node scripts/test-backup-integration.js \
  --endpoint https://s3.amazonaws.com \
  --access-key YOUR_ACCESS_KEY \
  --secret-key YOUR_SECRET_KEY \
  --bucket your-test-bucket

# Test local backup
node scripts/test-backup-integration.js --type local

# Test with cleanup after completion
node scripts/test-backup-integration.js --cleanup

# Verbose output
node scripts/test-backup-integration.js --verbose

Test Coverage

The test suite covers:

Configuration

  • Database configuration retrieval
  • JSON parsing and error handling
  • Configuration validation
  • Required field validation

S3 Functionality

  • S3 client initialization
  • Connection testing
  • File upload with progress tracking
  • Large file handling (multipart upload)
  • Metadata and custom headers
  • Error handling and retries

Backup Process

  • Full backup execution
  • Incremental backup (changed files only)
  • File checksum calculation and comparison
  • Database backup inclusion
  • Archive inclusion toggle
  • File size limits

Manifest Generation

  • Full manifest generation
  • Incremental manifest with parent reference
  • JSON and YAML format support
  • Manifest validation
  • S3 manifest storage and retrieval
  • Checksum verification

Error Handling

  • S3 connection failures
  • File read errors
  • Individual file failure recovery
  • Retry logic with exponential backoff
  • Email notifications on failure
  • Concurrent backup prevention

Backward Compatibility

  • Local directory backup
  • Rsync backup
  • Existing manifest format support

Service Management

  • Cron job scheduling
  • Service start/stop
  • Manual backup triggering
  • Backup history and status

Mock Setup

The unit tests use comprehensive mocking:

// Database mocking
jest.mock('../../src/database/db');

// S3 client mocking
jest.mock('../../src/services/storage/s3Storage');

// File system mocking
const mockFs = require('mock-fs');

// Cron job mocking
jest.mock('node-cron');

CI/CD Integration

To run tests in CI/CD pipeline:

# Example GitHub Actions
- name: Run Unit Tests
  run: npm test -- __tests__/services/backupService.enhanced.test.js

- name: Start MinIO
  run: |
    docker run -d \
      -p 9000:9000 \
      --name minio-test \
      -e MINIO_ROOT_USER=minioadmin \
      -e MINIO_ROOT_PASSWORD=minioadmin \
      minio/minio server /data

- name: Run Integration Tests
  run: npm test -- __tests__/integration/backup-s3.test.js

Debugging Tests

# Run tests in debug mode
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js

# Run single test with console output
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose

Performance Considerations

  • Integration tests create real files and S3 objects
  • Each test run creates a unique S3 bucket to avoid conflicts
  • Cleanup is automatic but can be disabled for debugging
  • Large file tests (10MB+) are included but can be slow

Adding New Tests

When adding new backup features:

  1. Add unit tests to backupService.enhanced.test.js
  2. Add integration tests to backup-s3.test.js if S3-specific
  3. Update manual test script for comprehensive testing
  4. Ensure mocks are properly configured
  5. Document any new environment requirements