mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
c2707b7eff
roel-de-vries pitched seventeen changes to Journey. Four were already built or rest on a misunderstanding, one cannot work as described, and three would take away more than they give; the rest are here, plus two things the list did not ask for. **The entry you write on the move now has a name.** Quick capture hid the title field outright and offered a note instead, so every entry caught on a phone arrived nameless and a day's timeline read as a column of identical placeholders. One line of input; it is the single change in this branch that users will feel most. **The map stops yanking.** Stepping through the timeline called flyTo with a zoom floor of 12, so reading a journey from a country view snapped to street level on the first scroll and stayed there — every marker filling the screen, and no sense of where anything was. Both renderers pan now and leave the frame the reader chose. The initial fitBounds still decides how close a journey opens. **The phone timeline is photos.** A 64px thumbnail beside three lines of text spent the card on a story nobody reads at that size and truncated the title after a few characters. The picture is the card now, with the name and place on it, the country's flag and the date in the corners, and the day carried as a colour rather than as a number nobody was reading. Above the carousel, a segment per day: it says how long the journey is, how far in you are, and takes you to day nine without swiping past days three to eight. Everything else, briefly: - A plus in each day header, starting a draft on that day instead of on today. - A journey opens on today when today is part of it, and at the beginning when it is not — a journey that ended last spring is something you read. - Suggestions can be waved away one at a time. The row survives so the trip sync does not offer the same place again; journey settings brings them all back. - Mood, weather and the pros/cons list can be switched off per journey. Nothing written is erased — the form stops asking, the values stay. - The weather fills itself in at the desk the way it already did on the phone. The date picks the source, so writing up last Tuesday gets last Tuesday's. - Enter opens the next pro or con, below the one you are in. - The verdict moved behind the story's fold, and the fold itself came in from nine lines to four. - The external picker lost the count it was printing twice and some padding it could not afford inside the editor. - The phone can reorder entries within a day at last; the desktop arrows finally have translated labels. - Marker labels are off where a carousel already names the entry, and on where they are the only name a marker has. - Search over a journey's titles, stories, places and tags, accent-blind. - An entry knows which country it happened in, resolved once from its coordinates against the bundled boundaries. Not built, and why: the photos-for-markers idea needs clustering first or thirty markers become soup, and the active entry's photo is already on screen in the card below. Removing mood, weather and the verdict outright would take them from everyone who uses them, so they became switches. `capture` on the file picker takes the photo library away on a phone, which is why the camera is a second input; it is hidden on desktop now instead. The suggestion strip in the new-entry flow would be a third surface over the same data — tapping a suggestion already opens the editor filled in. The one gap the list missed and this branch does not close: Journey is the only domain with no offline path. That belongs in its own change.
425 lines
19 KiB
TypeScript
425 lines
19 KiB
TypeScript
/**
|
|
* Journey e2e — exercises the migrated /api/journeys and /api/public/journey
|
|
* endpoints through the real JwtAuthGuard against a temp SQLite db. The journey
|
|
* services + addon gate are mocked; this focuses on the addon-gate-before-auth
|
|
* ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status
|
|
* codes and the unguarded public route.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { Test } from '@nestjs/testing';
|
|
import { seedUser, sessionCookie } from './harness';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
|
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
|
// this at onModuleInit.
|
|
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
|
return { db: tmp };
|
|
});
|
|
|
|
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
|
|
|
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
|
// The controller's pure helpers (isVideoExtension/isVideoMime/MAX_VIDEO_SIZE)
|
|
// now come from the real files.constants; only the request-time app_settings
|
|
// read is mocked, preserving the old '*'-allowlist semantics for the fixtures.
|
|
// The memories providers are injected since the fold — stubbed on the prototype
|
|
// so JourneyModule still resolves them through DI.
|
|
vi.mock('../../src/nest/memories/immich.service', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/nest/memories/immich.service')>();
|
|
actual.ImmichService.prototype.uploadToImmich = vi.fn();
|
|
actual.ImmichService.prototype.streamImmichAsset = vi.fn();
|
|
return actual;
|
|
});
|
|
vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/nest/memories/photo-resolver.service')>();
|
|
actual.PhotoResolverService.prototype.streamPhoto = vi.fn();
|
|
return actual;
|
|
});
|
|
|
|
const { jsvc } = vi.hoisted(() => ({
|
|
jsvc: {
|
|
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
|
|
journeyStats: vi.fn(), updateEntry: vi.fn(), restoreDismissedSuggestions: vi.fn(),
|
|
},
|
|
}));
|
|
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
|
|
|
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
|
|
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
|
|
|
|
const { booksvc } = vi.hoisted(() => ({
|
|
booksvc: {
|
|
getBook: vi.fn(), canOpen: vi.fn(), saveBook: vi.fn(),
|
|
deleteBook: vi.fn(), broadcastSaved: vi.fn(),
|
|
},
|
|
}));
|
|
import { JourneyBookService } from '../../src/nest/journey/journey-book.service';
|
|
import { MAX_SPREAD_ELEMENTS } from '@trek/shared';
|
|
|
|
import { JourneyModule } from '../../src/nest/journey/journey.module';
|
|
import { AddonsService } from '../../src/nest/addons/addons.service';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
|
|
describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] })
|
|
.overrideProvider(JourneyDomainService)
|
|
.useValue(jsvc)
|
|
.overrideProvider(JourneyShareService)
|
|
.useValue(sharesvc)
|
|
.overrideProvider(JourneyBookService)
|
|
.useValue(booksvc)
|
|
.overrideProvider(AddonsService)
|
|
.useValue({ isAddonEnabled })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
|
|
// by metatype, exactly as they do under buildApp(). Without it a book save
|
|
// would reach the service unvalidated here and the e2e would pass on a
|
|
// route that 400s in production.
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
seedUser(db as never, { id: 1 });
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
|
|
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
|
|
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
isAddonEnabled.mockReturnValue(true);
|
|
booksvc.getBook.mockReset();
|
|
booksvc.canOpen.mockReset();
|
|
booksvc.saveBook.mockReset();
|
|
booksvc.deleteBook.mockReset();
|
|
booksvc.broadcastSaved.mockReset();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('404 (addon gate wins over auth) when the Journey addon is disabled', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).get('/api/journeys');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
|
|
});
|
|
|
|
it('401 with the addon enabled but no session cookie', async () => {
|
|
expect((await request(server).get('/api/journeys')).status).toBe(401);
|
|
});
|
|
|
|
it('200 list with a session', async () => {
|
|
const res = await request(server).get('/api/journeys').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ journeys: [{ id: 1, title: 'J' }] });
|
|
});
|
|
|
|
it('201 create, 400 without a title', async () => {
|
|
const ok = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({ title: 'J' });
|
|
expect(ok.status).toBe(201);
|
|
expect(ok.body).toEqual({ id: 9, title: 'J' });
|
|
const bad = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({});
|
|
expect(bad.status).toBe(400);
|
|
expect(bad.body).toEqual({ error: 'Title is required' });
|
|
});
|
|
|
|
it('404 for an inaccessible journey', async () => {
|
|
jsvc.getJourneyFull.mockReturnValue(null);
|
|
const res = await request(server).get('/api/journeys/9').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey not found' });
|
|
});
|
|
|
|
/*
|
|
* The stop switch (discussion #2064). The entry body is a loose contract, so
|
|
* what e2e pins is that the boolean reaches the service as it was sent,
|
|
* through the addon gate and the auth guard, and that the entry comes back
|
|
* bare rather than in an envelope.
|
|
*/
|
|
it('PATCH entries/:entryId hands stats_excluded to the service as the boolean it was sent', async () => {
|
|
jsvc.updateEntry.mockReturnValue({ id: 3, journey_id: 9, stats_excluded: true });
|
|
const res = await request(server)
|
|
.patch('/api/journeys/entries/3')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ stats_excluded: true });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ id: 3, journey_id: 9, stats_excluded: true });
|
|
const [entryId, userId, body] = jsvc.updateEntry.mock.calls[0];
|
|
expect([entryId, userId]).toEqual([3, 1]);
|
|
expect(body).toEqual({ stats_excluded: true });
|
|
});
|
|
|
|
it('401 for PATCH entries/:entryId without a session', async () => {
|
|
const res = await request(server).patch('/api/journeys/entries/3').send({ stats_excluded: true });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
/*
|
|
* The journey figures TREK Studio prints (#1973). Read-only and derived, so
|
|
* what e2e adds over the controller unit test is that the addon gate and the
|
|
* auth guard both run in front of it — a route that reports where someone has
|
|
* been must not be reachable without a session.
|
|
*/
|
|
it('401 for the journey stats without a session', async () => {
|
|
expect((await request(server).get('/api/journeys/9/stats')).status).toBe(401);
|
|
});
|
|
|
|
it('404 for the stats of an inaccessible journey', async () => {
|
|
jsvc.journeyStats.mockReturnValue(null);
|
|
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey not found' });
|
|
});
|
|
|
|
it('200 with the figures, returned bare rather than in an envelope', async () => {
|
|
jsvc.journeyStats.mockReturnValue({
|
|
journeyId: 9, distance: 1_189_000, days: 14, steps: 14, photos: 57, places: 0,
|
|
furthest: 408_000,
|
|
countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }],
|
|
points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' }],
|
|
start: '2026-06-02', end: '2026-06-15',
|
|
});
|
|
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.distance).toBe(1_189_000);
|
|
expect(res.body.countries).toEqual([{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }]);
|
|
expect(jsvc.journeyStats).toHaveBeenCalledWith(9, 1);
|
|
});
|
|
|
|
it('404 (addon gate) for the stats when the Journey addon is disabled', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
|
|
});
|
|
|
|
/*
|
|
* ── The Studio book (#1973) ────────────────────────────────────────────
|
|
*
|
|
* What e2e adds over the service test: the addon gate and the auth guard both
|
|
* run in front of these, the 409 keeps its body (an exception filter that
|
|
* flattened it would cost the client the other version), and DELETE really
|
|
* answers 204 rather than Nest's default 200.
|
|
*/
|
|
it('401 for the book without a session', async () => {
|
|
expect((await request(server).get('/api/journeys/9/book')).status).toBe(401);
|
|
expect((await request(server).put('/api/journeys/9/book').send({ document: {} })).status).toBe(401);
|
|
expect((await request(server).delete('/api/journeys/9/book')).status).toBe(401);
|
|
});
|
|
|
|
it('404 (addon gate) for the book when the Journey addon is disabled', async () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
|
|
});
|
|
|
|
/*
|
|
* "No book yet" is not "no journey". Studio opens on an empty journey, lays a
|
|
* book out and saves it, so a 404 here would make the ordinary first visit
|
|
* look like a missing trip.
|
|
*/
|
|
it('200 with a null book for a journey that has none yet', async () => {
|
|
booksvc.getBook.mockReturnValue(null);
|
|
booksvc.canOpen.mockReturnValue(true);
|
|
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ book: null });
|
|
});
|
|
|
|
it('404 for the book of an inaccessible journey', async () => {
|
|
booksvc.getBook.mockReturnValue(null);
|
|
booksvc.canOpen.mockReturnValue(false);
|
|
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey not found' });
|
|
});
|
|
|
|
it('200 with the stored book', async () => {
|
|
booksvc.getBook.mockReturnValue({ id: 3, journeyId: 9, title: 'Iceland', version: 4 });
|
|
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.book.version).toBe(4);
|
|
expect(booksvc.getBook).toHaveBeenCalledWith(9, 1);
|
|
});
|
|
|
|
it('200 on save — not 201, the book is one object being updated', async () => {
|
|
booksvc.saveBook.mockReturnValue({ record: { id: 3, journeyId: 9, title: 'T', version: 5 } });
|
|
const res = await request(server)
|
|
.put('/api/journeys/9/book')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'T', document: { version: 1 }, baseVersion: 4 });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.version).toBe(5);
|
|
// The pipe fills the document's defaults on the way in, so the service is
|
|
// never handed a half-document to guess at.
|
|
const [journeyId, userId, input] = booksvc.saveBook.mock.calls[0];
|
|
expect([journeyId, userId]).toEqual([9, 1]);
|
|
expect(input.title).toBe('T');
|
|
expect(input.baseVersion).toBe(4);
|
|
expect(input.document.page.pageWidth).toBe(210);
|
|
expect(input.document.spreads).toEqual([]);
|
|
});
|
|
|
|
it('forwards the socket id so the saving client does not echo its own change', async () => {
|
|
booksvc.saveBook.mockReturnValue({ record: { id: 3, version: 5 } });
|
|
await request(server)
|
|
.put('/api/journeys/9/book')
|
|
.set('Cookie', sessionCookie(1))
|
|
.set('X-Socket-Id', 'sock-7')
|
|
.send({ document: {} });
|
|
expect(booksvc.broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
|
|
});
|
|
|
|
it('409 with the current record in the body, not a bare status', async () => {
|
|
booksvc.saveBook.mockReturnValue({ conflict: { id: 3, journeyId: 9, title: 'Theirs', version: 6 } });
|
|
const res = await request(server)
|
|
.put('/api/journeys/9/book')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ document: {}, baseVersion: 4 });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toBe('Book was changed by someone else');
|
|
expect(res.body.current.version).toBe(6);
|
|
expect(booksvc.broadcastSaved).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('404 on saving into an inaccessible journey', async () => {
|
|
booksvc.saveBook.mockReturnValue(null);
|
|
const res = await request(server)
|
|
.put('/api/journeys/9/book')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ document: {} });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey not found' });
|
|
});
|
|
|
|
it('400 for a save with no document at all', async () => {
|
|
const res = await request(server)
|
|
.put('/api/journeys/9/book')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'T' });
|
|
expect(res.status).toBe(400);
|
|
expect(booksvc.saveBook).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/*
|
|
* The refusal Studio hit on its own auto layout (#2085).
|
|
*
|
|
* A spread past MAX_SPREAD_ELEMENTS is refused whole, and the editor can only
|
|
* report that as "couldn't save" — for the rest of the session, because
|
|
* autosave keeps offering the same document. The layout is what was fixed;
|
|
* this pins the other half, that the route does refuse such a book, so the
|
|
* client-side guarantee is a guarantee about something.
|
|
*/
|
|
it('400 for a spread carrying more elements than the contract allows', async () => {
|
|
const element = (i: number) => ({
|
|
id: 'e' + i, kind: 'shape', frame: { x: 0, y: 0, w: 10, h: 10 }, shape: 'rect',
|
|
});
|
|
const res = await request(server)
|
|
.put('/api/journeys/9/book')
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
title: 'T',
|
|
document: {
|
|
version: 1,
|
|
spreads: [{
|
|
id: 'sp1',
|
|
role: 'inner',
|
|
elements: Array.from({ length: MAX_SPREAD_ELEMENTS + 1 }, (_, i) => element(i)),
|
|
}],
|
|
},
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(booksvc.saveBook).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('204 on delete, and 404 when the journey is out of reach', async () => {
|
|
booksvc.deleteBook.mockReturnValue(true);
|
|
const ok = await request(server).delete('/api/journeys/9/book').set('Cookie', sessionCookie(1));
|
|
expect(ok.status).toBe(204);
|
|
|
|
booksvc.deleteBook.mockReturnValue(null);
|
|
const gone = await request(server).delete('/api/journeys/9/book').set('Cookie', sessionCookie(1));
|
|
expect(gone.status).toBe(404);
|
|
expect(gone.body).toEqual({ error: 'Journey not found' });
|
|
});
|
|
|
|
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
|
|
const res = await request(server).get('/api/public/journey/tok');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ id: 9 });
|
|
});
|
|
|
|
it('public journey read still answers the addon gate when Journey is off', async () => {
|
|
// The authenticated surface goes dark with the addon; a published journey
|
|
// has to go with it, or turning the switch off does not take the feature away.
|
|
isAddonEnabled.mockReturnValue(false);
|
|
const res = await request(server).get('/api/public/journey/tok');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
|
|
});
|
|
|
|
it('the image fileFilter is wired: a disallowed extension is rejected, not stored', async () => {
|
|
// The multer options are built by MulterModule.registerAsync now, and Nest
|
|
// injects MULTER_MODULE_OPTIONS with @Optional(): a token that fails to
|
|
// resolve is not a boot error, it silently falls back to defaults, which
|
|
// means no fileFilter and no size cap. This case is what turns that into a
|
|
// visible failure, since an unfiltered upload would answer 201.
|
|
const res = await request(server)
|
|
.post('/api/journeys/9/cover')
|
|
.set('Cookie', sessionCookie(1))
|
|
.attach('cover', Buffer.from('MZ'), { filename: 'payload.exe', contentType: 'application/octet-stream' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('restoring suggestions answers 200 with the count, not 201', async () => {
|
|
// POST defaults to 201 in Nest, and the route carries @HttpCode(200) to match
|
|
// every other action-shaped POST in this controller.
|
|
jsvc.restoreDismissedSuggestions.mockReturnValueOnce({ restored: 2 });
|
|
const res = await request(server)
|
|
.post('/api/journeys/9/suggestions/restore')
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ restored: 2 });
|
|
});
|
|
|
|
it('restoring suggestions 403 for someone who may not edit the journey', async () => {
|
|
jsvc.restoreDismissedSuggestions.mockReturnValueOnce(null);
|
|
const res = await request(server)
|
|
.post('/api/journeys/9/suggestions/restore')
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Not allowed' });
|
|
});
|
|
|
|
it('public journey 404 for an unknown token', async () => {
|
|
sharesvc.getPublicJourney.mockReturnValueOnce(null);
|
|
const res = await request(server).get('/api/public/journey/bad');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Not found' });
|
|
});
|
|
});
|