* fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable) st-ivan's re-test after #904: statistics panel and event summary now agree, but the per-image Engagement column still shows 0. Root cause: the admin photos LIST endpoint maps rows to an explicit response object that includes like/comment/rating/favorite counts but never included view_count or download_count — the grid reads photo.view_count ?? 0, so the column showed 0 regardless of what the DB counted. This mapper, not stale data, is also why per-image downloads always displayed 0 in the original report. Suite extended with a list-endpoint assertion (beacon + download, then the admin list reflects 1/1 and untouched photos 0/0). The skip test now neutralizes the route's background pre-zip build, whose async ENOENT against the intentionally missing file could land mid-suite. Includes the one-line chunkedUploadService unref from #911 so the test suite can mount adminPhotos regardless of merge order (identical change, merges cleanly either way). * test: widen the fire-and-forget settle window (#895 follow-up) The 100ms settle was marginal on loaded CI runners — the counter increments are deliberately fire-and-forget, and the 909 PRs flaked on exactly these assertions. 400ms keeps the suite fast while giving slow runners room. --------- Co-authored-by: Paul Nothaft <[email protected]>
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
/**
|
|
* Per-photo engagement counters (#895).
|
|
*
|
|
* Pins the contract that the admin EVENT > IMAGES table depends on:
|
|
* - photos.view_count increments when the full-size photo is served
|
|
* (it existed in the schema + admin UI but had NO writer at all)
|
|
* - the slideshow kiosk never increments views (migration 138 design)
|
|
* - single-photo downloads increment download_count (regression pin)
|
|
* - zip downloads (download-all, download-selected) increment
|
|
* download_count for the contained photos — previously they didn't,
|
|
* so zip-heavy galleries showed 0 per-photo downloads forever
|
|
* - the admin event-detail total_downloads counts singles AND zips
|
|
* (it counted action='download' only, disagreeing with the dashboard)
|
|
*/
|
|
|
|
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-engagement-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
|
|
// Real files on disk so /photo and the zip routes actually stream bytes.
|
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const bcrypt = require('bcrypt');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
|
|
|
const SLUG = 'engagement-test-event';
|
|
|
|
describe('photo engagement counters (#895)', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
let eventId;
|
|
let photoIds;
|
|
let adminToken;
|
|
|
|
const galleryToken = (extra = {}) => jwt.sign(
|
|
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
|
|
const getPhoto = async (id) => db('photos').where('id', id).first();
|
|
// The counter writes are fire-and-forget on purpose — give the event
|
|
// loop a beat before asserting.
|
|
const settle = () => new Promise((r) => setTimeout(r, 400));
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
|
|
const inserted = await db('events').insert({
|
|
slug: SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Engagement Test',
|
|
event_date: '2026-08-01',
|
|
host_email: '[email protected]',
|
|
admin_email: '[email protected]',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${SLUG}/share`,
|
|
share_token: 'engagement-test-share',
|
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
|
is_active: 1,
|
|
is_archived: 0,
|
|
is_draft: 0,
|
|
allow_downloads: 1,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
eventId = inserted[0]?.id ?? inserted[0];
|
|
|
|
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
|
|
fs.mkdirSync(photoDir, { recursive: true });
|
|
|
|
photoIds = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const filename = `photo-${i}.jpg`;
|
|
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
|
|
const p = await db('photos').insert({
|
|
event_id: eventId,
|
|
filename,
|
|
path: `${SLUG}/${filename}`,
|
|
type: 'individual',
|
|
uploaded_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
photoIds.push(p[0]?.id ?? p[0]);
|
|
}
|
|
|
|
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
|
const [rootId] = await db('admin_users').insert({
|
|
username: 'engagement-admin',
|
|
email: '[email protected]',
|
|
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
|
|
role_id: superRole.id,
|
|
is_active: 1,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
|
adminToken = jwt.sign(
|
|
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
|
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
|
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
|
|
await db('access_logs').where('event_id', eventId).del();
|
|
});
|
|
|
|
describe('view_count via the view beacon (#895 — previously never written)', () => {
|
|
const beacon = (photoId, token = galleryToken()) => request(app)
|
|
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
it('increments exactly the beaconed photo', async () => {
|
|
expect((await beacon(photoIds[0])).status).toBe(204);
|
|
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
|
|
|
|
expect((await beacon(photoIds[0])).status).toBe(204);
|
|
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
|
|
// Other photos untouched
|
|
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
|
|
});
|
|
|
|
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`);
|
|
expect(res.status).toBe(200);
|
|
await settle();
|
|
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
|
|
});
|
|
|
|
it('rejects the slideshow kiosk (migration 138 design)', async () => {
|
|
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
|
|
});
|
|
|
|
it("404s a photo that isn't in the event", async () => {
|
|
const res = await beacon(999999);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('download_count', () => {
|
|
it('single-photo download increments (regression pin)', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`);
|
|
expect(res.status).toBe(200);
|
|
await settle();
|
|
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
|
|
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
|
|
});
|
|
|
|
it('download-selected increments exactly the selected photos (#895)', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/gallery/${SLUG}/download-selected`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`)
|
|
.send({ photo_ids: [photoIds[0], photoIds[1]] });
|
|
expect(res.status).toBe(200);
|
|
await settle();
|
|
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
|
|
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
|
|
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
|
|
});
|
|
|
|
it('download-all increments every downloadable photo (#895)', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${SLUG}/download-all`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`);
|
|
expect(res.status).toBe(200);
|
|
await settle();
|
|
for (const id of photoIds) {
|
|
expect((await getPhoto(id)).download_count).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('skipped archive entries do not count (missing source file)', async () => {
|
|
// Own event so the on-the-fly archiver path is guaranteed — the
|
|
// main event may have a cached zip from the previous test's
|
|
// background generation, and racing its build/invalidate hangs.
|
|
// The route also fires a background pre-zip build after streaming;
|
|
// against this event's intentionally missing file it crashes with
|
|
// an async ENOENT that jest attributes to whatever test is running
|
|
// by then — neutralize it, it's not under test here.
|
|
const downloadZipService = require('../../src/services/downloadZipService');
|
|
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
|
|
.mockResolvedValue({ success: false, error: 'disabled in test' });
|
|
const slug2 = `${SLUG}-skip`;
|
|
const ev = await db('events').insert({
|
|
slug: slug2,
|
|
event_type: 'wedding',
|
|
event_name: 'Engagement Skip Test',
|
|
event_date: '2026-08-01',
|
|
host_email: '[email protected]',
|
|
admin_email: '[email protected]',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${slug2}/share`,
|
|
share_token: 'engagement-skip-share',
|
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
|
is_active: 1,
|
|
is_archived: 0,
|
|
is_draft: 0,
|
|
allow_downloads: 1,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
const eventId2 = ev[0]?.id ?? ev[0];
|
|
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
|
|
fs.mkdirSync(dir2, { recursive: true });
|
|
const ids2 = [];
|
|
for (let i = 0; i < 2; i++) {
|
|
// Only photo 0 gets a real file — photo 1's source is missing.
|
|
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
|
|
const p = await db('photos').insert({
|
|
event_id: eventId2,
|
|
filename: `photo-${i}.jpg`,
|
|
path: `${slug2}/photo-${i}.jpg`,
|
|
type: 'individual',
|
|
uploaded_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
ids2.push(p[0]?.id ?? p[0]);
|
|
}
|
|
const token2 = jwt.sign(
|
|
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${slug2}/download-all`)
|
|
.set('Authorization', `Bearer ${token2}`);
|
|
expect(res.status).toBe(200);
|
|
await settle();
|
|
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
|
|
// photo-1's source was missing → skipped from the zip → not counted
|
|
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
|
|
generateZipSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('admin photos list exposes the counters (#895 follow-up)', () => {
|
|
it('returns view_count and download_count so the Engagement column can render them', async () => {
|
|
// The list mapper builds an explicit object — before this fix it
|
|
// omitted both fields, so the admin table showed 0 forever even
|
|
// though the DB counted correctly.
|
|
await request(app)
|
|
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`);
|
|
await request(app)
|
|
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
|
|
.set('Authorization', `Bearer ${galleryToken()}`);
|
|
await settle();
|
|
|
|
const res = await request(app)
|
|
.get(`/api/admin/photos/${eventId}/photos`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
expect(res.status).toBe(200);
|
|
const row = res.body.photos.find((p) => p.id === photoIds[0]);
|
|
expect(row.view_count).toBe(1);
|
|
expect(row.download_count).toBe(1);
|
|
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
|
|
expect(untouched.view_count).toBe(0);
|
|
expect(untouched.download_count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
|
|
it('counts singles and every zip variant, one row each', async () => {
|
|
const row = (action) => ({
|
|
event_id: eventId,
|
|
ip_address: '127.0.0.1',
|
|
user_agent: 'jest',
|
|
action,
|
|
});
|
|
await db('access_logs').insert([
|
|
row('download'),
|
|
row('download_all'),
|
|
row('download_all_presigned'),
|
|
row('download_selected'),
|
|
row('view'), // not a download
|
|
]);
|
|
|
|
const res = await request(app)
|
|
.get(`/api/admin/events/${eventId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.total_downloads).toBe(4);
|
|
});
|
|
});
|
|
});
|