Files
picpeak/backend/__tests__/utils/feedbackExportCameraName.test.js
T
Paul Nothaft 4f684eb482 fix(feedback): name the camera original in the exports, not just the stored file (#1224) (#1228)
Both feedback exports carried only `photos.filename` — the sanitized stored
name (`wedding-smith_individual_1755892345.jpg`). Acting on client picks means
finding the master on disk, and that name matches nothing in a Lightroom
catalog, so the export could not be joined to anything.

Adds the camera-original name to the long and pivot shapes, and by extension
to the archive's feedback_data.csv/.json, which reuse the same query.

COALESCE(source_filename, original_filename), not original_filename alone:
the latter is overwritten the first time an edited render is uploaded over a
proof (#745), so an export taken after a round-trip would name the render
rather than the master and silently stop matching. source_filename is written
once at ingest and survives a replace by design (migration 193). That case is
the load-bearing test.

Aliased to `original_filename` — the name the sibling photo export already
uses for this column, and the question the reader is asking. Left empty when
neither is known rather than echoing the stored name: blank reads as "no match
possible", where repeating the sanitized name invites a match attempt against
a file that does not exist under it.

The column is added, not swapped: `filename` is untouched, so anything reading
the old column keeps working.

Reported by the 8digit/picpeak fork, which has carried a narrower version of
this patch (original_filename only) across rebases.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-29 11:09:59 +02:00

157 lines
5.9 KiB
JavaScript

/**
* Feedback exports carry the camera-original filename (#1224).
*
* Both exports used to select only `photos.filename` — the sanitized stored
* name — so a photographer acting on client picks had nothing to match the
* masters on disk with.
*
* The load-bearing case is the third one: `original_filename` is overwritten
* the first time an edited render is uploaded over a proof (#745), so an
* export that read it alone would name the render rather than the master and
* silently stop matching. `source_filename` survives a replace by design
* (migration 193) and must win.
*/
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-export-camera-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-export-camera-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const EVENT_SLUG = 'feedback-export-camera-event';
const GUEST = 'guest-camera-identifier';
let db;
let cleanup;
let eventId;
// filename is always the sanitized stored name; the other two vary per case.
async function addPhoto({ filename, originalFilename, sourceFilename }) {
const inserted = await db('photos').insert({
event_id: eventId,
filename,
original_filename: originalFilename,
source_filename: sourceFilename,
path: `events/camera-name/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
async function like(photoId) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
guest_id: null,
ip_address: '127.0.0.1',
user_agent: 'jest',
}, GUEST);
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Feedback Export Camera Name',
event_date: '2026-08-28',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'feedback-export-camera-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];
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('feedback exports carry the camera-original filename (#1224)', () => {
it('reports the camera name alongside the stored name, in both shapes', async () => {
const photoId = await addPhoto({
filename: 'wedding-smith_individual_1755892345.jpg',
originalFilename: 'IMG_1234.JPG',
sourceFilename: 'IMG_1234.JPG',
});
await like(photoId);
const [longRow] = (await feedbackService.exportEventFeedback(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755892345.jpg');
expect(longRow.original_filename).toBe('IMG_1234.JPG');
// The stored name is still there — this adds a column, it does not swap one.
expect(longRow.filename).toBe('wedding-smith_individual_1755892345.jpg');
const [pivotRow] = (await feedbackService.exportEventFeedbackPivoted(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755892345.jpg');
expect(pivotRow.original_filename).toBe('IMG_1234.JPG');
});
it('keeps naming the master after a replace overwrote original_filename', async () => {
// Exactly the post-round-trip row: the edited render's name landed in
// original_filename, source_filename still holds what the camera wrote.
const photoId = await addPhoto({
filename: 'wedding-smith_individual_1755892999.jpg',
originalFilename: 'Smith_Wedding_1234.jpg',
sourceFilename: 'IMG_1234.JPG',
});
await like(photoId);
const [longRow] = (await feedbackService.exportEventFeedback(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755892999.jpg');
expect(longRow.original_filename).toBe('IMG_1234.JPG');
const [pivotRow] = (await feedbackService.exportEventFeedbackPivoted(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755892999.jpg');
expect(pivotRow.original_filename).toBe('IMG_1234.JPG');
});
it('falls back to original_filename when nothing was captured at ingest', async () => {
const photoId = await addPhoto({
filename: 'wedding-smith_individual_1755893111.jpg',
originalFilename: 'DSC_9001.NEF',
sourceFilename: null,
});
await like(photoId);
const [longRow] = (await feedbackService.exportEventFeedback(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755893111.jpg');
expect(longRow.original_filename).toBe('DSC_9001.NEF');
});
it('leaves the column empty rather than repeating the sanitized name', async () => {
// Blank reads as "no match possible". Echoing the stored name would invite
// a match attempt against a file that does not exist under that name.
const photoId = await addPhoto({
filename: 'wedding-smith_individual_1755893222.jpg',
originalFilename: null,
sourceFilename: null,
});
await like(photoId);
const [longRow] = (await feedbackService.exportEventFeedback(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755893222.jpg');
expect(longRow.original_filename).toBeNull();
const [pivotRow] = (await feedbackService.exportEventFeedbackPivoted(eventId))
.filter((r) => r.filename === 'wedding-smith_individual_1755893222.jpg');
// The pivot builds plain objects, so its empty is '' rather than null.
expect(pivotRow.original_filename).toBe('');
});
});