mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
72c61f242e
Changes held in one step are applied in the order the chat shows them, so "undo all" walks them back newest first. A page put back is checked against the digest of the page as the change left it, so a save to another page no longer blocks the undo; restore_book_page takes these digests as an option and keeps the whole book check without them. The book card counts the user's own photos, the preview tool describes itself per role, a copy into another trip names no places, a held vacay toggle says up front whether it can be taken back, and a viewer's Studio prompt names none of the design's tools.
197 lines
9.3 KiB
TypeScript
197 lines
9.3 KiB
TypeScript
/**
|
|
* The Studio page images, end to end: the whole app (buildApp, every global
|
|
* guard, the Zod pipe, the response contracts) with a turn's wait primed on
|
|
* the container's own AssistantRenderRegistry, and the page uploaded the way
|
|
* the Studio tab uploads it (multipart, the image in `image`, the page and the
|
|
* photos it shows as text fields).
|
|
*
|
|
* Covered: the gates in the handler (the AI addon, the managed install, the
|
|
* journey addon, the journey itself), the image checks (none, not an image,
|
|
* over the edge or byte cap), a photo of another member refused, and the
|
|
* hand-over: only to the waiting turn of the same user and thread, for the
|
|
* page and version asked for, once.
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { JourneyPhotos } from '../../src/db/entities/JourneyPhotos.entity';
|
|
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
|
|
import { AssistantRenderRegistry, type PageImage } from '../../src/nest/assistant/assistant-render.registry';
|
|
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
|
|
import { insertRow } from '../helpers/factories/rows';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { resetRateLimits } from '../helpers/test-db';
|
|
import { sessionCookie } from './harness';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
let nestApp: INestApplication;
|
|
let orm: MikroORM;
|
|
let registry: AssistantRenderRegistry;
|
|
|
|
beforeAll(async () => {
|
|
nestApp = await buildApp();
|
|
orm = nestApp.get(MikroORM);
|
|
registry = nestApp.get(AssistantRenderRegistry);
|
|
await setAddonEnabled(orm, 'ai_assistant', true);
|
|
await setAddonEnabled(orm, 'journey', true);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetRateLimits(nestApp);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await nestApp.close();
|
|
});
|
|
|
|
/** The first bytes of a baseline JPEG of w x h: all the route reads. */
|
|
function jpeg(w: number, h: number, size = 64): Buffer {
|
|
const out = Buffer.alloc(size);
|
|
Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08]).copy(out, 0);
|
|
out.writeUInt16BE(h, 7);
|
|
out.writeUInt16BE(w, 9);
|
|
return out;
|
|
}
|
|
|
|
async function photoOf(journeyId: number, ownerId: number): Promise<number> {
|
|
const photoId = await insertRow(orm, TrekPhotos, { provider: 'local', owner: ownerId, file_path: 'journey/p.jpg' });
|
|
await insertRow(orm, JourneyPhotos, { journey: journeyId, photo: photoId, sort_order: 0, created_at: Date.now() });
|
|
return photoId;
|
|
}
|
|
|
|
async function studio() {
|
|
const { user } = await makeUser(orm);
|
|
const { user: friend } = await makeUser(orm);
|
|
const journey = await makeJourney(orm, user.id, { title: 'Portugal by train' });
|
|
await addJourneyContributor(orm, journey.id, friend.id, 'editor');
|
|
const own = await photoOf(journey.id, user.id);
|
|
const friends = await photoOf(journey.id, friend.id);
|
|
return { userId: user.id, friendId: friend.id, journeyId: journey.id, own, friends };
|
|
}
|
|
|
|
interface Upload {
|
|
userId: number;
|
|
journeyId: number | string;
|
|
threadId?: number;
|
|
requestId: string;
|
|
image?: Buffer;
|
|
fields?: Record<string, string>;
|
|
}
|
|
|
|
function upload(u: Upload) {
|
|
const path = `/api/journeys/${u.journeyId}/assistant/threads/${u.threadId ?? 4}/renders/${u.requestId}`;
|
|
let req = request(nestApp.getHttpServer()).post(path).set('Cookie', sessionCookie(u.userId));
|
|
for (const [key, value] of Object.entries(u.fields ?? { bookVersion: '3', spreadId: 'sp-2' })) {
|
|
req = req.field(key, value);
|
|
}
|
|
if (u.image === undefined) return req;
|
|
return req.attach('image', u.image, { filename: 'page.jpg', contentType: 'image/jpeg' });
|
|
}
|
|
|
|
/** A turn of the user waiting for page sp-2 at version 3 of their journey, in thread 4. */
|
|
function waitFor(userId: number, journeyId: number) {
|
|
const requestId = randomUUID();
|
|
const stop = new AbortController();
|
|
const image = registry.wait(
|
|
{ userId, journeyId, threadId: 4, requestId, spreadId: 'sp-2', bookVersion: 3 },
|
|
stop.signal,
|
|
15_000,
|
|
);
|
|
return { requestId, image, stop };
|
|
}
|
|
|
|
describe('Studio page images e2e', () => {
|
|
it('ASTRNE2E-001: the addons, the managed install and the journey are checked before the image', async () => {
|
|
const s = await studio();
|
|
const { user: stranger } = await makeUser(orm);
|
|
const turn = waitFor(s.userId, s.journeyId);
|
|
const page = { requestId: turn.requestId, image: jpeg(1280, 640) };
|
|
await setAddonEnabled(orm, 'ai_assistant', false);
|
|
try {
|
|
expect((await upload({ ...page, userId: s.userId, journeyId: s.journeyId })).status).toBe(404);
|
|
} finally {
|
|
await setAddonEnabled(orm, 'ai_assistant', true);
|
|
}
|
|
vi.stubEnv('TREK_MANAGED', 'true');
|
|
try {
|
|
const managed = await upload({ ...page, userId: s.userId, journeyId: s.journeyId });
|
|
expect(managed.status).toBe(403);
|
|
expect(managed.body).toMatchObject({ code: 'MANAGED_FORBIDDEN' });
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
await setAddonEnabled(orm, 'journey', false);
|
|
try {
|
|
const off = await upload({ ...page, userId: s.userId, journeyId: s.journeyId });
|
|
expect([off.status, off.body]).toEqual([404, { error: 'Journey addon is not enabled' }]);
|
|
} finally {
|
|
await setAddonEnabled(orm, 'journey', true);
|
|
}
|
|
const outsider = await upload({ ...page, userId: stranger.id, journeyId: s.journeyId });
|
|
expect([outsider.status, outsider.body]).toEqual([404, { error: 'Journey not found' }]);
|
|
expect((await upload({ ...page, userId: s.userId, journeyId: 'abc' })).status).toBe(404);
|
|
const path = `/api/journeys/${s.journeyId}/assistant/threads/4/renders/x`;
|
|
expect((await request(nestApp.getHttpServer()).post(path)).status).toBe(401);
|
|
turn.stop.abort();
|
|
await expect(turn.image).resolves.toBeNull();
|
|
});
|
|
|
|
it('ASTRNE2E-002: an upload that is no image, too large or without its fields is refused', async () => {
|
|
const s = await studio();
|
|
const turn = waitFor(s.userId, s.journeyId);
|
|
const base = { requestId: turn.requestId, userId: s.userId, journeyId: s.journeyId };
|
|
expect((await upload({ ...base })).status).toBe(400);
|
|
const text = await upload({ ...base, image: Buffer.from('not an image at all') });
|
|
expect([text.status, text.body]).toEqual([415, { error: 'The upload is not a JPEG, PNG or WebP image' }]);
|
|
expect((await upload({ ...base, image: jpeg(4000, 1000) })).status).toBe(413);
|
|
expect((await upload({ ...base, image: jpeg(2048, 2048) })).status).toBe(413);
|
|
expect((await upload({ ...base, image: jpeg(1280, 640, 1024 * 1024 + 1) })).status).toBe(413);
|
|
const noPage = await upload({ ...base, image: jpeg(1280, 640), fields: { bookVersion: '3' } });
|
|
expect(noPage.status).toBe(400);
|
|
const extra = { bookVersion: '3', spreadId: 'sp-2', data: 'x' };
|
|
expect((await upload({ ...base, image: jpeg(1280, 640), fields: extra })).status).toBe(400);
|
|
turn.stop.abort();
|
|
await expect(turn.image).resolves.toBeNull();
|
|
});
|
|
|
|
it('ASTRNE2E-003: an image that shows a photo of another member is refused; the turn goes on waiting', async () => {
|
|
const s = await studio();
|
|
const turn = waitFor(s.userId, s.journeyId);
|
|
const base = { requestId: turn.requestId, userId: s.userId, journeyId: s.journeyId, image: jpeg(1280, 640) };
|
|
const fields = { bookVersion: '3', spreadId: 'sp-2', drawnPhotoIds: `${s.own},${s.friends}` };
|
|
const foreign = await upload({ ...base, fields });
|
|
expect([foreign.status, foreign.body]).toEqual([
|
|
403,
|
|
{ error: 'The image shows a photo another member contributed' },
|
|
]);
|
|
const mine = await upload({ ...base, fields: { ...fields, drawnPhotoIds: String(s.own) } });
|
|
expect([mine.status, mine.body]).toEqual([200, { accepted: true }]);
|
|
const image = (await turn.image) as PageImage;
|
|
expect(image).toMatchObject({ mimeType: 'image/jpeg', width: 1280, height: 640 });
|
|
expect(image.data.equals(jpeg(1280, 640))).toBe(true);
|
|
});
|
|
|
|
it('ASTRNE2E-004: only the waiting turn of this user and thread takes the page it asked for, once', async () => {
|
|
const s = await studio();
|
|
const turn = waitFor(s.userId, s.journeyId);
|
|
const base = { requestId: turn.requestId, journeyId: s.journeyId, image: jpeg(1280, 640) };
|
|
// The friend may open the journey, but the turn is not theirs.
|
|
expect((await upload({ ...base, userId: s.friendId })).status).toBe(404);
|
|
expect((await upload({ ...base, userId: s.userId, threadId: 5 })).status).toBe(404);
|
|
// A thread id that is none asks for no page, before the file is looked at.
|
|
const noThread = await upload({ ...base, userId: s.userId, threadId: 0 });
|
|
expect([noThread.status, noThread.body]).toEqual([404, { error: 'No page was asked for' }]);
|
|
expect((await upload({ ...base, userId: s.userId, requestId: randomUUID() })).status).toBe(404);
|
|
const stale = await upload({ ...base, userId: s.userId, fields: { bookVersion: '2', spreadId: 'sp-2' } });
|
|
expect(stale.status).toBe(409);
|
|
expect((await upload({ ...base, userId: s.userId })).status).toBe(200);
|
|
await expect(turn.image).resolves.toMatchObject({ width: 1280, height: 640 });
|
|
const again = await upload({ ...base, userId: s.userId });
|
|
expect([again.status, again.body]).toEqual([404, { error: 'No page was asked for' }]);
|
|
});
|
|
});
|