Files
TREK/server/tests/unit/nest/share.controller.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

290 lines
12 KiB
TypeScript

import { TripShareController, SharedController } from '../../../src/nest/share/share.controller';
import type { ShareService } from '../../../src/nest/share/share.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import type { Response } from 'express';
import { PassThrough } from 'node:stream';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Only the shared place-photo proxy consumes storage.
const getStream = vi.fn();
const storageStub = { getStream } as unknown as StorageService;
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
function svc(o: Partial<ShareService> = {}): ShareService {
return {
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
canManage: vi.fn().mockReturnValue(true),
...o,
} as unknown as ShareService;
}
function res() {
const r = {
statusCode: 200,
status: vi.fn((c: number) => {
r.statusCode = c;
return r;
}),
};
return r as unknown as Response & { statusCode: number };
}
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('TripShareController', async () => {
it('POST 404 without access, 403 without share_manage', async () => {
expect(
await thrown(() =>
new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(
user,
'5',
{},
res(),
),
),
).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(
await thrown(() =>
new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).create(user, '5', {}, res()),
),
).toEqual({ status: 403, body: { error: 'No permission' } });
});
it('POST answers 201 on create, 200 on update', async () => {
const createdRes = res();
const c1 = new TripShareController(
svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: true }) } as Partial<ShareService>),
);
expect(await c1.create(user, '5', { share_map: true }, createdRes)).toEqual({ token: 't' });
expect(createdRes.statusCode).toBe(201);
const updatedRes = res();
const c2 = new TripShareController(
svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: false }) } as Partial<ShareService>),
);
expect(await c2.create(user, '5', {}, updatedRes)).toEqual({ token: 't' });
expect(updatedRes.statusCode).toBe(200);
});
it('POST forwards the two narrowing options, as the MCP tool does (#1712)', async () => {
const createOrUpdate = vi.fn().mockReturnValue({ token: 't', created: true });
await new TripShareController(svc({ createOrUpdate } as Partial<ShareService>)).create(
user,
'5',
{ share_travel_only: true, share_hide_images: true },
res(),
);
expect(createOrUpdate).toHaveBeenCalledWith(
'5',
user.id,
expect.objectContaining({ share_travel_only: true, share_hide_images: true }),
);
});
it('GET 404 without access, 403 without share_manage, returns info or a null token', async () => {
expect(
await thrown(() =>
new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).get(user, '5'),
),
).toEqual({ status: 404, body: { error: 'Trip not found' } });
// Reading returns the token itself, so it needs the same permission as
// creating it — a member with plain trip access must not get a copy.
const get = vi.fn();
expect(
await thrown(() =>
new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false), get } as Partial<ShareService>)).get(
user,
'5',
),
),
).toEqual({ status: 403, body: { error: 'No permission' } });
expect(get).not.toHaveBeenCalled();
expect(
await new TripShareController(svc({ get: vi.fn().mockReturnValue({ token: 't' }) } as Partial<ShareService>)).get(
user,
'5',
),
).toEqual({ token: 't' });
expect(
await new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).get(
user,
'5',
),
).toEqual({ token: null });
});
it('DELETE 403 without share_manage, else removes', async () => {
expect(
await thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).remove(user, '5')),
).toEqual({ status: 403, body: { error: 'No permission' } });
const remove = vi.fn();
expect(await new TripShareController(svc({ remove } as Partial<ShareService>)).remove(user, '5')).toEqual({
success: true,
});
expect(remove).toHaveBeenCalledWith('5');
});
});
describe('SharedController', () => {
it('404 for an invalid token, else returns the snapshot', async () => {
expect(
await thrown(() =>
new SharedController(
svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial<ShareService>),
storageStub,
).read('bad'),
),
).toEqual({ status: 404, body: { error: 'Invalid or expired link' } });
expect(
await new SharedController(
svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial<ShareService>),
storageStub,
).read('tok'),
).toEqual({ trip: { id: 9 } });
});
describe('place-photo proxy', () => {
// A real PassThrough (like storage.service.test.ts's makeRes) rather than
// a { on, pipe } stub: pipeline() needs authentic stream/eos semantics
// (close/error/finish wiring) that a bare mock can't satisfy.
function photoRes() {
const sink = new PassThrough();
const chunks: Buffer[] = [];
sink.on('data', (c: Buffer) => chunks.push(c));
// Capture the real PassThrough#end BEFORE overwriting the property
// below — r and sink are the same object (Object.assign mutates and
// returns its target), so referencing sink.end inside the wrapper
// would recurse into itself.
const realEnd = sink.end.bind(sink) as (...a: unknown[]) => unknown;
const r = Object.assign(sink, {
statusCode: 200,
status: vi.fn(function (this: unknown, c: number) {
(r as { statusCode: number }).statusCode = c;
return r;
}),
json: vi.fn(),
set: vi.fn(),
type: vi.fn(),
end: vi.fn((...args: unknown[]) => realEnd(...args)),
body: () => Buffer.concat(chunks).toString(),
});
return r as unknown as Response & {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
set: ReturnType<typeof vi.fn>;
type: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
body: () => string;
};
}
// Braced body on purpose: mockReset() returns the mock, and a function
// returned from beforeEach is invoked as a teardown callback — which would
// call getStream() bare and turn a rejecting impl into an unhandled
// rejection.
beforeEach(() => {
getStream.mockReset();
});
function controller(key: string | null) {
return new SharedController(
svc({ getSharedPlacePhotoKey: vi.fn().mockReturnValue(key) } as Partial<ShareService>),
storageStub,
);
}
// #1727's rationale extended to public share pages: shared payloads keep
// this URL in place image_urls, so an evicted cache entry means one request
// per place per shared-page render — an empty 204 instead of 404 noise.
it('204 without a body when the photo is not cached for the token', async () => {
const res = photoRes();
await controller(null).placePhotoBytes('tok', 'p1', res);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
expect(res.set).toHaveBeenLastCalledWith('Cache-Control', 'no-store');
expect(getStream).not.toHaveBeenCalled();
});
it('streams the cached bytes with image/jpeg + an immutable cache header on a hit', async () => {
const stream = new PassThrough();
stream.end('hello');
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
const res = photoRes();
await controller('abc.jpg').placePhotoBytes('tok', 'p1', res);
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
expect(res.type).toHaveBeenCalledWith('image/jpeg');
expect(getStream).toHaveBeenCalledWith('photos-google', 'abc.jpg');
expect(res.body()).toBe('hello');
});
it('falls back to an empty 204 when the stream cannot be opened (cache-delete race)', async () => {
getStream.mockRejectedValue(new Error('storage object not found'));
const res = photoRes();
await controller('abc.jpg').placePhotoBytes('tok', 'p1', res);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
// The hit path already asked for a month of immutable caching — that
// header must not survive onto the empty answer.
expect(res.set).toHaveBeenLastCalledWith('Cache-Control', 'no-store');
});
it('falls back to an empty 204 when the stream errors before headers were sent', async () => {
const stream = new PassThrough();
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
const res = photoRes();
const pending = controller('abc.jpg').placePhotoBytes('tok', 'p1', res);
// Let pipeline() attach its listeners before erroring the source, so
// the event isn't missed to a scheduling race.
await new Promise((resolve) => setImmediate(resolve));
stream.destroy(new Error('source boom'));
await pending;
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
expect(res.set).toHaveBeenLastCalledWith('Cache-Control', 'no-store');
});
it('swallows an abort-like error once headers are flushed (client walked away)', async () => {
const stream = new PassThrough();
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
const res = photoRes();
(res as unknown as { headersSent: boolean }).headersSent = true;
const pending = controller('abc.jpg').placePhotoBytes('tok', 'p1', res);
await new Promise((resolve) => setImmediate(resolve));
stream.destroy(Object.assign(new Error('reset'), { code: 'ECONNRESET' }));
await expect(pending).resolves.toBeUndefined();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
expect(res.end).not.toHaveBeenCalled();
});
it('re-throws a real (non-abort) error once headers are flushed', async () => {
const stream = new PassThrough();
getStream.mockResolvedValue({ stream, stat: { key: 'photos/google/abc.jpg', size: 5, mtimeMs: 0 } });
const res = photoRes();
(res as unknown as { headersSent: boolean }).headersSent = true;
const pending = controller('abc.jpg').placePhotoBytes('tok', 'p1', res);
await new Promise((resolve) => setImmediate(resolve));
stream.destroy(Object.assign(new Error('disk read error'), { code: 'EIO' }));
await expect(pending).rejects.toThrow('disk read error');
expect(res.status).not.toHaveBeenCalled();
expect(res.end).not.toHaveBeenCalled();
});
});
});