// services/api/test/photos.e2e-spec.ts —— Task 24 照片可见性生命周期 e2e // (先写后实现)。真实 app + MySQL + 真实 MinIO(变体对象)。覆盖: // ① 公开单图 getPublicPhoto:published → 200 PublicPhotoDetail(+categoryId, // 签名 URL 现签可取回);换册 token / hidden / unpublished / 幽灵 id → // 404 PHOTO_NOT_PUBLIC(旧深链不泄露状态,A16); // ② PATCH /photos/{id} 隐藏:上传协作者(自己照片)→ 200 PhotoRecord; // 相册 owner(他人上传)→ 200;其他协作者 → 403;外人员工 → 404 // (PHOTO_NOT_FOUND,防枚举);隐藏后公开列表即刻排除、单图 404; // ③ outbox photo.hidden(entityVersion+1)+ audit photo.hide; // ④ 恢复发布:hidden→published 仅 owner/admin → 200(上传协作者 403, // P1a 从严默认,待定需求决策);列表重新出现;outbox 再发 // photo.published(新行、entityVersion 再+1);publishedAt 不变(时间轴 // 不因隐藏/恢复漂移); // ⑤ DELETE 软删除 → 204;DB visibility=deleted 且行/对象键保留;公开面 // 即刻消失;outbox photo.deleted + audit photo.delete;deleted 后恢复 // 发布 → 400(员工侧终态,物理清除/恢复属 P4a 管理流程);协作者删自己 // ✓、删他人 ✗ 403; // ⑥ 状态机 400:非法 visibility 值;unpublished→published(发布属 worker // 处理链路);unpublished→hidden;幂等同值 PATCH → 200 且无新事件。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import sharp from 'sharp'; import { PrismaClient } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { createStorageFromEnv, type MinioStorageAdapter, } from '@photo/storage'; import { AppCnModule } from '../src/app-cn.module'; import { hashSessionToken } from '../src/auth/session.guard'; import { API_PREFIX } from '../src/shared/constants'; import { resetDb } from './reset'; jest.setTimeout(120_000); /** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */ function makePublicToken(): string { const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; let token = ''; const bytes = randomBytes(24); for (let i = 0; i < 24; i++) { token += alphabet[bytes[i] % alphabet.length]; } return token; } /** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */ function cuidLike(alias: string): string { return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`; } /** 测试内生成真实 JPEG(无二进制入库,同 worker publish e2e)。 */ async function makeJpeg(width: number, height: number): Promise { return sharp({ create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } }, }) .jpeg({ quality: 85 }) .toBuffer(); } const KEY_ROOT = 'photo-live/dev'; const variantKey = ( albumId: string, photoId: string, variant: 'thumb' | 'browse', ) => `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`; /** 契约 PhotoRecord 的必返字段全集(形状精确断言用)。 */ const PHOTO_RECORD_KEYS = [ 'albumId', 'bytes', 'categoryId', 'height', 'likeCount', 'photoId', 'processingState', 'publishedAt', 'shotAt', 'uploadedAt', 'uploaderId', 'visibility', 'width', ].sort(); describe('photos visibility (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let storage: MinioStorageAdapter; let albumId: string; let publicToken: string; let catAId: string; let ownerId: string; let collabId: string; let otherCollabId: string; let adminId: string; let ownerToken: string; let collabToken: string; let otherCollabToken: string; let outsiderToken: string; let adminToken: string; // 生命周期照片(依序:隐藏→恢复→软删除)。 let lifecycleId: string; let lifecyclePublishedAt: Date; beforeAll(async () => { await resetDb(); const moduleRef = await Test.createTestingModule({ imports: [AppCnModule], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix(API_PREFIX); await app.init(); prisma = new PrismaClient(); storage = createStorageFromEnv(); // 账号:owner(相册创建者)/ collab(上传协作者)/ otherCollab / admin / // outsider(外人员工,非成员)。 const mkUser = (account: string, role = 'staff') => prisma.users.create({ data: { account, role, passwordHash: 'x', displayName: account }, select: { id: true }, }); ownerId = (await mkUser('ph-owner')).id; collabId = (await mkUser('ph-collab')).id; otherCollabId = (await mkUser('ph-other')).id; adminId = (await mkUser('ph-admin', 'admin')).id; const outsiderId = (await mkUser('ph-outsider')).id; const mkToken = async (userId: string): Promise => { const token = randomBytes(32).toString('hex'); await prisma.sessions.create({ data: { userId, tokenHash: hashSessionToken(token), expiresAt: new Date(Date.now() + 3_600_000), }, }); return token; }; ownerToken = await mkToken(ownerId); collabToken = await mkToken(collabId); otherCollabToken = await mkToken(otherCollabId); outsiderToken = await mkToken(outsiderId); adminToken = await mkToken(adminId); // 主相册:owner + 两协作者成员行;分类 catA。 const album = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true, publicToken: true }, }); albumId = album.id; publicToken = album.publicToken; await prisma.album_translations.create({ data: { albumId, locale: 'zh-CN', title: '生命周期相册' }, }); await prisma.album_members.createMany({ data: [ { albumId, userId: ownerId, role: 'owner', status: 'active' }, { albumId, userId: collabId, role: 'collaborator', status: 'active' }, { albumId, userId: otherCollabId, role: 'collaborator', status: 'active', }, ], }); const cat = await prisma.categories.create({ data: { albumId, sortOrder: 1 }, select: { id: true }, }); catAId = cat.id; await prisma.category_translations.create({ data: { categoryId: catAId, locale: 'zh-CN', name: '主分类' }, }); // 另一册(token 不匹配用例):独立相册 + 一张 published 照片。 const otherAlbum = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId: 'u-ph-other-album', startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); await prisma.album_translations.create({ data: { albumId: otherAlbum.id, locale: 'zh-CN', title: '另一册' }, }); await seedPhoto('phOtherAlbum', otherAlbum.id, 'u-ph-other-album', { visibility: 'published', }); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); /** * 直种照片:photos 行 + 两条 photo_variants + MinIO 真实变体对象(默认)。 * entityVersion=2 对齐 worker 发布事务落库形状(unpublished 种 1 亦可,此处 * 统一 2,版本断言以「迁移前实测值」为基线,不依赖种子常量)。 */ async function seedPhoto( alias: string, targetAlbumId: string, uploaderId: string, options: { visibility?: string; categoryId?: string | null; withVariants?: boolean; processingState?: string; } = {}, ): Promise { const photoId = cuidLike(alias); const [thumb, browse] = await Promise.all([ makeJpeg(480, 300), makeJpeg(800, 500), ]); if (options.withVariants !== false) { await storage.put(variantKey(targetAlbumId, photoId, 'thumb'), thumb, 'image/jpeg'); await storage.put(variantKey(targetAlbumId, photoId, 'browse'), browse, 'image/jpeg'); } await prisma.photos.create({ data: { id: photoId, albumId: targetAlbumId, uploaderId, categoryId: options.categoryId ?? null, objectKey: `${KEY_ROOT}/albums/${targetAlbumId}/originals/${photoId}.jpg`, bytes: browse.length, width: 800, height: 500, uploadedAt: new Date(), publishedAt: options.visibility === 'unpublished' ? null : new Date(), processingState: options.processingState ?? 'ready', visibility: options.visibility ?? 'published', entityVersion: 2, }, }); if (options.withVariants !== false) { await prisma.photo_variants.createMany({ data: [ { photoId, variant: 'thumb', objectKey: variantKey(targetAlbumId, photoId, 'thumb'), bytes: thumb.length, version: 1, width: 480, height: 300, }, { photoId, variant: 'browse', objectKey: variantKey(targetAlbumId, photoId, 'browse'), bytes: browse.length, version: 1, width: 800, height: 500, }, ], }); } return photoId; } function patchPhoto(token: string, photoId: string, body: object) { return request(app.getHttpServer()) .patch(`/${API_PREFIX}/photos/${photoId}`) .set('Authorization', `Bearer ${token}`) .send(body); } function deletePhoto(token: string, photoId: string) { return request(app.getHttpServer()) .delete(`/${API_PREFIX}/photos/${photoId}`) .set('Authorization', `Bearer ${token}`); } function getPublicPhoto(token: string, photoId: string) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${token}/photos/${photoId}`); } function listPublicPhotos(query?: Record) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos`) .query(query ?? {}); } /** 全量公开列表的 id 集(hasMore 分页下循环取尽,断言「出现/消失」用)。 */ async function publicPhotoIds(): Promise { const ids: string[] = []; let cursor: string | undefined; for (;;) { const res = await listPublicPhotos( cursor === undefined ? { limit: 60 } : { limit: 60, cursor }, ); expect(res.status).toBe(200); ids.push(...res.body.items.map((p: { photoId: string }) => p.photoId)); if (res.body.nextCursor === null) { return ids; } cursor = res.body.nextCursor as string; } } describe('公开单图 getPublicPhoto', () => { let singleId: string; let hiddenId: string; let unpubId: string; let otherAlbumPhotoId: string; beforeAll(async () => { singleId = await seedPhoto('phSingle', albumId, collabId, { categoryId: catAId, }); hiddenId = await seedPhoto('phHidden', albumId, collabId, { visibility: 'hidden', }); unpubId = await seedPhoto('phUnpub', albumId, collabId, { visibility: 'unpublished', processingState: 'uploaded', withVariants: false, }); const other = await prisma.photos.findFirstOrThrow({ where: { albumId: { not: albumId }, visibility: 'published' }, select: { id: true }, }); otherAlbumPhotoId = other.id; }); it('published → 200 PublicPhotoDetail:categoryId + 现签 thumb/browse 可取回', async () => { const res = await getPublicPhoto(publicToken, singleId); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual([ 'browse', 'categoryId', 'height', 'likeCount', 'photoId', 'publishedAt', 'thumb', 'width', ]); expect(res.body.photoId).toBe(singleId); expect(res.body.categoryId).toBe(catAId); expect(res.body.width).toBe(800); expect(res.body.height).toBe(500); expect(res.body.likeCount).toBe(0); for (const key of ['thumb', 'browse'] as const) { const signed = res.body[key] as { url: string; expiresAt: string }; expect(Date.parse(signed.expiresAt)).toBeGreaterThan(Date.now()); const fetched = await fetch(signed.url); expect(fetched.status).toBe(200); expect( Buffer.from(await fetched.arrayBuffer()).length, ).toBeGreaterThan(0); } }); it('换册照片 / hidden / unpublished / 幽灵 id → 404 PHOTO_NOT_PUBLIC', async () => { // 换册:照片真实存在且 published,但不属于本 token 相册。 const mismatch = await getPublicPhoto(publicToken, otherAlbumPhotoId); expect(mismatch.status).toBe(404); expect(mismatch.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); for (const photoId of [hiddenId, unpubId, 'no-such-photo']) { const res = await getPublicPhoto(publicToken, photoId); expect(res.status).toBe(404); expect(res.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); expect(typeof res.body.requestId).toBe('string'); } }); }); describe('PATCH /photos/{id} 隐藏与权限矩阵', () => { let ownId: string; // collab 上传 let ownerPhotoId: string; // owner 上传 let otherTargetId: string; // collab 上传(otherCollab 尝试 → 403) beforeAll(async () => { ownId = await seedPhoto('phOwn', albumId, collabId); ownerPhotoId = await seedPhoto('phOwnerUp', albumId, ownerId); otherTargetId = await seedPhoto('phOtherTarget', albumId, collabId); lifecycleId = await seedPhoto('phLife', albumId, collabId); lifecyclePublishedAt = ( await prisma.photos.findUniqueOrThrow({ where: { id: lifecycleId }, select: { publishedAt: true }, }) ).publishedAt as Date; }); it('上传协作者隐藏自己的照片 → 200 PhotoRecord;公开列表即刻排除、单图 404', async () => { const before = await prisma.photos.findUniqueOrThrow({ where: { id: lifecycleId }, select: { entityVersion: true }, }); const ids = await publicPhotoIds(); expect(ids).toContain(lifecycleId); const res = await patchPhoto(collabToken, lifecycleId, { visibility: 'hidden', }); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual(PHOTO_RECORD_KEYS); expect(res.body).toMatchObject({ photoId: lifecycleId, albumId, uploaderId: collabId, visibility: 'hidden', processingState: 'ready', likeCount: 0, }); expect(res.body.publishedAt).not.toBeNull(); // A16 核心:公开面即刻不可见。 expect(await publicPhotoIds()).not.toContain(lifecycleId); const single = await getPublicPhoto(publicToken, lifecycleId); expect(single.status).toBe(404); expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); // 版本推进:2 → 3。 const after = await prisma.photos.findUniqueOrThrow({ where: { id: lifecycleId }, select: { entityVersion: true, visibility: true }, }); expect(after.entityVersion).toBe(before.entityVersion + 1); expect(after.visibility).toBe('hidden'); }); it('outbox photo.hidden + audit photo.hide(entityVersion 对齐)', async () => { const event = await prisma.outbox_events.findFirstOrThrow({ where: { photoId: lifecycleId, type: 'photo.hidden' }, }); expect(event.albumId).toBe(albumId); expect(event.entityVersion).toBe(3); expect(event.deliveredAt).toBeNull(); expect(event.payload).toMatchObject({ photoId: lifecycleId, albumId, visibility: 'hidden', entityVersion: 3, }); const audit = await prisma.audit_logs.findFirstOrThrow({ where: { action: 'photo.hide', actorId: collabId, resourceId: lifecycleId, }, }); expect(audit.metadata).toMatchObject({ from: 'published', to: 'hidden' }); }); it('相册 owner 隐藏他人上传 → 200;其他协作者 → 403;外人员工 → 404(防枚举)', async () => { const ownerRes = await patchPhoto(ownerToken, ownId, { visibility: 'hidden', }); expect(ownerRes.status).toBe(200); expect(ownerRes.body.visibility).toBe('hidden'); const otherRes = await patchPhoto(otherCollabToken, otherTargetId, { visibility: 'hidden', }); expect(otherRes.status).toBe(403); expect(otherRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsiderRes = await patchPhoto(outsiderToken, otherTargetId, { visibility: 'hidden', }); expect(outsiderRes.status).toBe(404); expect(outsiderRes.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND); // owner 自己上传的照片隐藏(同一人两径均 200)。 const ownerOwn = await patchPhoto(ownerToken, ownerPhotoId, { visibility: 'hidden', }); expect(ownerOwn.status).toBe(200); // 幽灵照片 id:任何角色 404。 const ghost = await patchPhoto(ownerToken, 'no-such-photo', { visibility: 'hidden', }); expect(ghost.status).toBe(404); expect(ghost.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND); }); it('恢复发布:上传协作者(自己 hidden 照片)→ 403;owner → 200,列表重现,outbox 再发 photo.published', async () => { // 协作者不可恢复(P1a 从严默认)。 const collabRes = await patchPhoto(collabToken, lifecycleId, { visibility: 'published', }); expect(collabRes.status).toBe(403); expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const ownerRes = await patchPhoto(ownerToken, lifecycleId, { visibility: 'published', }); expect(ownerRes.status).toBe(200); expect(ownerRes.body.visibility).toBe('published'); // 公开面恢复。 expect(await publicPhotoIds()).toContain(lifecycleId); const single = await getPublicPhoto(publicToken, lifecycleId); expect(single.status).toBe(200); // publishedAt 不变:隐藏/恢复不重排时间轴。 const row = await prisma.photos.findUniqueOrThrow({ where: { id: lifecycleId }, select: { publishedAt: true, entityVersion: true }, }); expect((row.publishedAt as Date).getTime()).toBe( lifecyclePublishedAt.getTime(), ); expect(row.entityVersion).toBe(4); // outbox:photo.published 新行(entityVersion=4),载荷为契约全集。 const events = await prisma.outbox_events.findMany({ where: { photoId: lifecycleId }, orderBy: { entityVersion: 'asc' }, }); expect(events.map((e) => [e.type, e.entityVersion])).toEqual([ ['photo.hidden', 3], ['photo.published', 4], ]); expect(events[1].payload).toEqual({ photoId: lifecycleId, albumId, categoryId: null, // 本 spec 种子未分类(P2 Task 6:categoryId 随载荷同步 ov) objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`, thumbKey: variantKey(albumId, lifecycleId, 'thumb'), browseKey: variantKey(albumId, lifecycleId, 'browse'), width: 800, height: 500, publishedAt: lifecyclePublishedAt.toISOString(), entityVersion: 4, }); const audit = await prisma.audit_logs.findFirstOrThrow({ where: { action: 'photo.publish', actorId: ownerId, resourceId: lifecycleId, }, }); expect(audit.metadata).toMatchObject({ from: 'hidden', to: 'published' }); }); it('admin 恢复发布 → 200(公司管理员全量)', async () => { const adminHiddenId = await seedPhoto('phAdminRepublish', albumId, collabId, { visibility: 'hidden', }); const res = await patchPhoto(adminToken, adminHiddenId, { visibility: 'published', }); expect(res.status).toBe(200); expect(res.body.visibility).toBe('published'); }); it('状态机 400:unpublished→published(发布属处理链路)/ unpublished→hidden;非法值 → 400', async () => { const unpubId = await seedPhoto('phSmUnpub', albumId, collabId, { visibility: 'unpublished', processingState: 'uploaded', withVariants: false, }); const toPublished = await patchPhoto(collabToken, unpubId, { visibility: 'published', }); expect(toPublished.status).toBe(400); expect(toPublished.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const toHidden = await patchPhoto(collabToken, unpubId, { visibility: 'hidden', }); expect(toHidden.status).toBe(400); expect(toHidden.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 契约 enum 之外的任何值(含 deleted——软删除只走 DELETE)。 for (const garbage of ['deleted', 'public', 42]) { const res = await patchPhoto(collabToken, lifecycleId, { visibility: garbage, }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } // visibility 缺失 → 400。 const missing = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/photos/${lifecycleId}`) .set('Authorization', `Bearer ${collabToken}`) .send({}); expect(missing.status).toBe(400); // 状态机拒绝不产生事件/审计。 expect( await prisma.outbox_events.count({ where: { photoId: unpubId } }), ).toBe(0); expect( await prisma.audit_logs.count({ where: { resourceId: unpubId } }), ).toBe(0); }); it('幂等同值 PATCH → 200 当前记录且无新事件/审计(entityVersion 不动)', async () => { const eventsBefore = await prisma.outbox_events.count({ where: { photoId: lifecycleId }, }); const res = await patchPhoto(collabToken, lifecycleId, { visibility: 'published', }); expect(res.status).toBe(200); expect(res.body.visibility).toBe('published'); expect(await prisma.outbox_events.count({ where: { photoId: lifecycleId } })).toBe( eventsBefore, ); const row = await prisma.photos.findUniqueOrThrow({ where: { id: lifecycleId }, select: { entityVersion: true }, }); expect(row.entityVersion).toBe(4); }); }); describe('DELETE /photos/{id} 软删除', () => { let otherOwnedId: string; beforeAll(async () => { otherOwnedId = await seedPhoto('phDelOther', albumId, ownerId); }); it('其他协作者删他人上传 → 403;协作者删自己 → 204;行/对象键保留', async () => { const forbidden = await deletePhoto(otherCollabToken, otherOwnedId); expect(forbidden.status).toBe(403); expect(forbidden.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const res = await deletePhoto(collabToken, lifecycleId); expect(res.status).toBe(204); expect(res.body).toEqual({}); // DB:软删除——行保留、visibility=deleted、对象键原样、版本推进。 const row = await prisma.photos.findUniqueOrThrow({ where: { id: lifecycleId }, }); expect(row.visibility).toBe('deleted'); expect(row.entityVersion).toBe(5); expect(row.objectKey).toBe( `${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`, ); // 公开面即刻消失(列表 + 旧深链单图)。 expect(await publicPhotoIds()).not.toContain(lifecycleId); const single = await getPublicPhoto(publicToken, lifecycleId); expect(single.status).toBe(404); expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); }); it('outbox photo.deleted + audit photo.delete', async () => { const event = await prisma.outbox_events.findFirstOrThrow({ where: { photoId: lifecycleId, type: 'photo.deleted' }, }); expect(event.entityVersion).toBe(5); expect(event.payload).toMatchObject({ photoId: lifecycleId, albumId, visibility: 'deleted', entityVersion: 5, }); const audit = await prisma.audit_logs.findFirstOrThrow({ where: { action: 'photo.delete', actorId: collabId, resourceId: lifecycleId, }, }); expect(audit.metadata).toMatchObject({ from: 'published', to: 'deleted' }); // 生命周期全事件序:hidden(3) → published(4) → deleted(5)。 const events = await prisma.outbox_events.findMany({ where: { photoId: lifecycleId }, orderBy: { entityVersion: 'asc' }, }); expect(events.map((e) => [e.type, e.entityVersion])).toEqual([ ['photo.hidden', 3], ['photo.published', 4], ['photo.deleted', 5], ]); }); it('deleted 为员工侧终态:恢复发布 → 400;重复 DELETE → 幂等 204 无新事件', async () => { const res = await patchPhoto(ownerToken, lifecycleId, { visibility: 'published', }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const eventsBefore = await prisma.outbox_events.count({ where: { photoId: lifecycleId }, }); const again = await deletePhoto(collabToken, lifecycleId); expect(again.status).toBe(204); expect( await prisma.outbox_events.count({ where: { photoId: lifecycleId } }), ).toBe(eventsBefore); }); it('owner 删除他人上传 → 204;外人员工 → 404;幽灵 id → 404', async () => { const ownerRes = await deletePhoto(ownerToken, otherOwnedId); expect(ownerRes.status).toBe(204); const outsiderRes = await deletePhoto(outsiderToken, otherOwnedId); expect(outsiderRes.status).toBe(404); expect(outsiderRes.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND); const ghost = await deletePhoto(ownerToken, 'no-such-photo'); expect(ghost.status).toBe(404); expect(ghost.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND); }); it('未认证 PATCH/DELETE → 401', async () => { const patch = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/photos/${otherOwnedId}`) .send({ visibility: 'hidden' }); expect(patch.status).toBe(401); expect(patch.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); const del = await request(app.getHttpServer()) .delete(`/${API_PREFIX}/photos/${otherOwnedId}`); expect(del.status).toBe(401); }); }); });