From 31478e232aa467cf90c90b3ae78c254c6af58b0a Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 16 Aug 2026 18:36:38 +0200 Subject: [PATCH] test(server): close serving-swap gaps surfaced by the full tier --- server/tests/e2e/share.e2e.test.ts | 29 ++++++++++------- .../tests/integration/uploads-static.test.ts | 7 ++++ .../unit/nest/photo-resolver.service.test.ts | 12 +++++++ .../unit/nest/platform.controller.test.ts | 32 +++++++++++++++++++ 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/server/tests/e2e/share.e2e.test.ts b/server/tests/e2e/share.e2e.test.ts index 4131bfc33..09ffd59ac 100644 --- a/server/tests/e2e/share.e2e.test.ts +++ b/server/tests/e2e/share.e2e.test.ts @@ -46,9 +46,9 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi let checkPermission: MockInstance; // Overridden in the container rather than path-mocked: the cache is a provider now. -const serveFilePath = vi.fn(); +// serveKey hands the controller a bare photos-google storage name (slice 3). +const serveKey = vi.fn(); -import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { createTables } from '../../src/db/schema'; @@ -66,7 +66,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, ShareModule] }) - .overrideProvider(PlacePhotoCacheService).useValue({ serveFilePath }) + .overrideProvider(PlacePhotoCacheService).useValue({ serveKey }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); @@ -97,7 +97,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { db.exec('DELETE FROM trips'); tripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Trip').lastInsertRowid); checkPermission.mockReturnValue(true); - serveFilePath.mockReset(); + serveKey.mockReset(); }); afterAll(async () => { @@ -166,29 +166,36 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { }); describe('public place-photo proxy (/api/shared/:token/place-photo/:placeId/bytes)', () => { - const photoFile = path.join(os.tmpdir(), 'trek-share-photo.e2e.jpg'); + // The controller streams (category 'photos-google', name) through the real + // storage facade, so the fixture has to live in the real photos-google root. + const photoName = 'trek-share-photo.e2e.jpg'; + const photoFile = path.join(__dirname, '../../uploads/photos/google', photoName); const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG-ish header - beforeAll(() => fs.writeFileSync(photoFile, photoBytes)); + beforeAll(() => { + fs.mkdirSync(path.dirname(photoFile), { recursive: true }); + fs.writeFileSync(photoFile, photoBytes); + }); afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } }); it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => { const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); db.prepare('INSERT INTO places (trip_id, name, image_url) VALUES (?, ?, ?)') .run(tripId, 'Louvre', '/api/maps/place-photo/ChIJabc/bytes'); - serveFilePath.mockReturnValueOnce(photoFile); + serveKey.mockReturnValueOnce(photoName); const res = await request(server).get(`/api/shared/${created.body.token}/place-photo/ChIJabc/bytes`); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/jpeg'); expect(res.headers['cache-control']).toContain('immutable'); expect(Buffer.from(res.body)).toEqual(photoBytes); - expect(serveFilePath).toHaveBeenCalledWith('ChIJabc'); + expect(serveKey).toHaveBeenCalledWith('ChIJabc'); }); - it('404 when the token/place does not resolve to a cached photo', async () => { + it('answers an empty 204 when the token/place does not resolve to a cached photo', async () => { const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes'); - expect(res.status).toBe(404); - expect(res.body).toEqual({ error: 'Photo not cached' }); + expect(res.status).toBe(204); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.text ?? '').toBe(''); }); }); }); diff --git a/server/tests/integration/uploads-static.test.ts b/server/tests/integration/uploads-static.test.ts index 3e37c26f4..e96c0a60d 100644 --- a/server/tests/integration/uploads-static.test.ts +++ b/server/tests/integration/uploads-static.test.ts @@ -246,6 +246,11 @@ describe('/uploads static parity — miss falls through to the Nest 404 envelope describe('/uploads static parity — all four mounts', () => { it('UPLOADS-P21 — covers/journey(nested)/places each serve a hit and 404 a miss', async () => { + // Fixtures are (re)written immediately before each request: other suites + // rm their whole category dir in afterAll (collections/trips → covers, + // collections → places, journey.test → journey), so a beforeAll-written + // file can vanish mid-run when the tier runs in parallel workers. + writeFixture(`covers/${NAME}`); const coverHit = await request(app).get(`/uploads/covers/${NAME}`); expect(coverHit.status).toBe(200); const coverMiss = await request(app).get('/uploads/covers/nope.png'); @@ -254,12 +259,14 @@ describe('/uploads static parity — all four mounts', () => { // Nested name — journey thumbs live at journey/thumbs/.jpg and are // served through the same mount; pins multi-segment storage keys. + writeFixture('journey/thumbs/uploads-static-parity.jpg'); const journeyHit = await request(app).get('/uploads/journey/thumbs/uploads-static-parity.jpg'); expect(journeyHit.status).toBe(200); expect(journeyHit.headers['content-type']).toBe('image/jpeg'); const journeyMiss = await request(app).get('/uploads/journey/thumbs/nope.jpg'); expect(journeyMiss.status).toBe(404); + writeFixture(`places/${NAME}`); const placeHit = await request(app).get(`/uploads/places/${NAME}`); expect(placeHit.status).toBe(200); const placeMiss = await request(app).get('/uploads/places/nope.png'); diff --git a/server/tests/unit/nest/photo-resolver.service.test.ts b/server/tests/unit/nest/photo-resolver.service.test.ts index 1a659b81c..8861b3296 100644 --- a/server/tests/unit/nest/photo-resolver.service.test.ts +++ b/server/tests/unit/nest/photo-resolver.service.test.ts @@ -221,6 +221,18 @@ describe('streamPhoto — dispatch', () => { expect(storage.sendToResponse).not.toHaveBeenCalled(); }); + it('RESOLVE-019: a rejecting exists check (invalid key) reads as a local miss for thumb and original', async () => { + const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); + storage.exists.mockRejectedValue(new Error('invalid storage key')); + const res = makeRes(); + + await svc.streamPhoto(res as never, 1, id, 'thumbnail'); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'File not found' }); + expect(storage.sendToResponse).not.toHaveBeenCalled(); + }); + it('RESOLVE-018: a file_path outside journey/ reads as a local miss without touching storage', async () => { const id = insertPhoto({ provider: 'local', file_path: 'nope/other.jpg' }); const res = makeRes(); diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts index 9efcb7d3e..b48c44b9a 100644 --- a/server/tests/unit/nest/platform.controller.test.ts +++ b/server/tests/unit/nest/platform.controller.test.ts @@ -236,6 +236,38 @@ describe('applyPlatformUploads', () => { expect(res.statusCode).toBe(404); expect(res.body).toBe('Not found'); }); + + it('rethrows a non-miss send failure to the route error handler', async () => { + h.exists.mockResolvedValue(true); + h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 }); + const boom = new Error('disk on fire'); + h.sendToResponse.mockRejectedValue(boom); + const res = makeRes(); + const localNext = vi.fn(); + await photoHandler()( + { params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} }, + res, + localNext, + ); + expect(localNext).toHaveBeenCalledWith(boom); + expect(res.statusCode).toBe(200); // untouched — finalhandler owns it now + }); + + it('does not write a second 404 when the send fails after headers were flushed', async () => { + h.exists.mockResolvedValue(true); + h.verifyJwtAndLoadUser.mockReturnValue({ id: 1 }); + h.sendToResponse.mockRejectedValue(new StorageNotFoundError('photos/a.jpg')); + const res = makeRes(); + (res as unknown as { headersSent: boolean }).headersSent = true; + const localNext = vi.fn(); + await photoHandler()( + { params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer jwt123' }, query: {} }, + res, + localNext, + ); + expect(res.status).not.toHaveBeenCalled(); + expect(localNext).toHaveBeenCalledWith(expect.any(StorageNotFoundError)); + }); }); });