Files
photo-live/services/api/test/ui01-contracts.e2e-spec.ts
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:13:08 +08:00

669 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/test/ui01-contracts.e2e-spec.ts —— UI-01 Task 2 契约扩展 e2e:
// cover 封面摘要 / preview 受权限预览 / scope=own 数据范围(真实 app + MySQL +
// MinIO:签名 URL 实取回字节,N+1 用 Prisma 中间件计真实查询数)。覆盖:
// ① listAlbums cover:有封面 → url 短期签名且可取回真实字节;同 kind 多行
// 取最新 effective(createdAt/id DESC 首行);pending 行不计;无封面 →
// null;getAlbum 详情同形;批量组装无 N+1(6 相册单页,相册域 Prisma 查
// 询数恒定:admin=3 albums+translations+album_assets / 非 admin=4 另加成
// 员行查询,与封面相册数无关);
// ② preview 权限矩阵(scope=all,owner/admin):published 签发(thumb+browse
// 可取回字节);hidden 对 manager 签发;deleted / 无变体 → null(记录
// 本身仍如实返回);
// ③ scope=own:collab 仅见自己上传(服务端过滤,uploaderId 全等于本人);
// 自己的 hidden 行可见但 preview=null(隐藏签发仅 admin/owner);owner
// scope=own 过滤为自己上传;旧请求(无 scope)owner 全量行为不变;
// ④ 权限边界:collab scope=all(缺省与显式)→ 403;被移除成员 scope=own →
// 404(成员资格逐请求);非成员跨相册 → 404;scope 非法值 → 400。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { PrismaService } from '../src/prisma/prisma.service';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 契约 pattern ^[0-9a-z]{20,32}$(同 albums.service 生成式口径)。 */
function makePublicToken(): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const bytes = randomBytes(24);
let token = '';
for (let i = 0; i < 24; i++) {
token += alphabet[bytes[i] % alphabet.length];
}
return token;
}
/** 固定时间基(分钟偏移构造确定性 uploadedAt/createdAt 序列)。 */
const T0 = Date.UTC(2026, 8, 20, 8, 0, 0);
const atMinute = (m: number): Date => new Date(T0 + m * 60_000);
describe('UI-01 契约扩展:cover / preview / scope=own (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let s3: S3Client;
let ownerId: string;
let collabId: string;
let adminId: string;
let outsiderId: string;
let ownerToken: string;
let collabToken: string;
let adminToken: string;
let outsiderToken: string;
/** 主相册 A(owner + collab):封面 + 全状态照片矩阵。 */
let albumAId: string;
/** 相册 B(owner 独有,无封面无照片):无封面对照 + 跨册 404。 */
let albumBId: string;
/** 其余 4 个带封面相册(N+1 批量断言用,owner 名下)。 */
const fillerIds: string[] = [];
let coverOldAssetId: string;
let coverNewAssetId: string;
const coverOldBytes = Buffer.from([0x11, 0x12, 0x13, 0x14]);
const coverNewBytes = Buffer.from([0x21, 0x22, 0x23, 0x24, 0x25]);
let phPubC: string; // published / collab / 有变体
let phPubO: string; // published / owner / 有变体(字节取回断言用)
let phHidC: string; // hidden / collab / 有变体
let phHidO: string; // hidden / owner / 有变体
let phDelO: string; // deleted / owner / 有变体
let phNovarC: string; // unpublished / collab / 无变体
const pubOBrowseBytes = Buffer.from([0x31, 0x32, 0x33, 0x34, 0x35, 0x36]);
/** MinIO 实写对象(封面/预览可取回性)。 */
async function putObject(key: string, body: Buffer): Promise<void> {
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: body,
ContentType: 'image/jpeg',
}),
);
}
async function fetchBody(url: string): Promise<Buffer> {
const res = await fetch(url);
expect(res.status).toBe(200);
return Buffer.from(await res.arrayBuffer());
}
function listAlbums(token: string, query?: Record<string, string | number>) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
}
function listPhotos(
token: string,
albumId: string,
query?: Record<string, string | number>,
) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumId}/photos`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
}
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();
s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY as string,
secretAccessKey: process.env.S3_SECRET_KEY as string,
},
forcePathStyle: true,
});
const mkUser = (account: string, displayName: string, role = 'staff') =>
prisma.users.create({
data: { account, role, passwordHash: 'x', displayName },
select: { id: true },
});
ownerId = (await mkUser('u01-owner', '欧阳雯')).id;
collabId = (await mkUser('u01-collab', '程澜')).id;
adminId = (await mkUser('u01-admin', '艾管', 'admin')).id;
outsiderId = (await mkUser('u01-outsider', '韦外')).id;
const mkToken = async (userId: string): Promise<string> => {
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);
adminToken = await mkToken(adminId);
outsiderToken = await mkToken(outsiderId);
// 相册 A:owner + collab;zh-CN 翻译。
const albumA = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
albumAId = albumA.id;
await prisma.album_translations.create({
data: { albumId: albumAId, locale: 'zh-CN', title: 'UI01 契约相册' },
});
await prisma.album_members.createMany({
data: [
{ albumId: albumAId, userId: ownerId, role: 'owner', status: 'active' },
{
albumId: albumAId,
userId: collabId,
role: 'collaborator',
status: 'active',
},
],
});
// 相册 B:owner 独有(无 collab)——无封面对照 + 跨册 404。
const albumB = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
albumBId = albumB.id;
await prisma.album_translations.create({
data: { albumId: albumBId, locale: 'zh-CN', title: 'UI01 无封面相册' },
});
await prisma.album_members.create({
data: { albumId: albumBId, userId: ownerId, role: 'owner', status: 'active' },
});
// 4 个补充相册(带封面):N+1 批量断言(单页 6 相册,5 有封面)。
for (let i = 0; i < 4; i++) {
const filler = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
fillerIds.push(filler.id);
await prisma.album_translations.create({
data: { albumId: filler.id, locale: 'zh-CN', title: `UI01 补充 ${i}` },
});
await prisma.album_members.create({
data: {
albumId: filler.id,
userId: ownerId,
role: 'owner',
status: 'active',
},
});
const assetId = `u01assetfill${i}0000000000000`;
const key = `photo-live/dev/albums/${filler.id}/assets/${assetId}.jpg`;
await prisma.album_assets.create({
data: {
id: assetId,
albumId: filler.id,
kind: 'cover',
locale: null,
objectKey: key,
state: 'effective',
expectedBytes: 4,
contentType: 'image/jpeg',
},
});
await putObject(key, Buffer.from([0x41, 0x42, 0x43]));
}
// 相册 A 封面矩阵:pending 行(不计)→ 旧 effective 行 → 新 effective 行
//(最新生效胜出)。
coverOldAssetId = 'u01assetold00000000000000000';
coverNewAssetId = 'u01assetnew00000000000000000';
const coverOldKey = `photo-live/dev/albums/${albumAId}/assets/${coverOldAssetId}.jpg`;
const coverNewKey = `photo-live/dev/albums/${albumAId}/assets/${coverNewAssetId}.jpg`;
await prisma.album_assets.createMany({
data: [
{
id: 'u01assetpending0000000000000',
albumId: albumAId,
kind: 'cover',
locale: null,
objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetpending0000000000000.jpg`,
state: 'pending',
expectedBytes: 4,
contentType: 'image/jpeg',
createdAt: atMinute(50), // 最新但 pending → 不计
},
{
id: coverOldAssetId,
albumId: albumAId,
kind: 'cover',
locale: null,
objectKey: coverOldKey,
state: 'effective',
expectedBytes: 4,
contentType: 'image/jpeg',
createdAt: atMinute(10),
},
{
id: coverNewAssetId,
albumId: albumAId,
kind: 'cover',
locale: null,
objectKey: coverNewKey,
state: 'effective',
expectedBytes: 5,
contentType: 'image/jpeg',
createdAt: atMinute(20), // 最新 effective → 胜出
},
{
id: 'u01assetbanner000000000000',
albumId: albumAId,
kind: 'banner', // 非 cover kind → 不计
locale: null,
objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetbanner000000000000.jpg`,
state: 'effective',
expectedBytes: 4,
contentType: 'image/jpeg',
createdAt: atMinute(30),
},
],
});
await putObject(coverOldKey, coverOldBytes);
await putObject(coverNewKey, coverNewBytes);
// 照片矩阵(album A;uploadedAt 递减稳定序)。
const seedPhoto = async (spec: {
id: string;
uploaderId: string;
visibility: string;
processingState: string;
uploadedAt: Date;
withVariants: boolean;
}): Promise<void> => {
await prisma.photos.create({
data: {
id: spec.id,
albumId: albumAId,
uploaderId: spec.uploaderId,
objectKey: `photo-live/dev/albums/${albumAId}/originals/${spec.id}.jpg`,
bytes: 9999,
width: 1600,
height: 1000,
uploadedAt: spec.uploadedAt,
publishedAt:
spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt),
processingState: spec.processingState,
visibility: spec.visibility,
entityVersion: 2,
},
});
if (!spec.withVariants) {
return;
}
await prisma.photo_variants.createMany({
data: (['thumb', 'browse'] as const).map((variant, i) => ({
photoId: spec.id,
variant,
objectKey: `photo-live/dev/albums/${albumAId}/variants/${spec.id}/1/${variant}.jpg`,
bytes: 100 + i,
version: 1,
width: variant === 'thumb' ? 320 : 1600,
height: variant === 'thumb' ? 200 : 1000,
})),
});
};
phPubC = 'u01-photo-pub-c';
phPubO = 'u01-photo-pub-o';
phHidC = 'u01-photo-hid-c';
phHidO = 'u01-photo-hid-o';
phDelO = 'u01-photo-del-o';
phNovarC = 'u01-photo-novar-c';
await seedPhoto({
id: phPubC,
uploaderId: collabId,
visibility: 'published',
processingState: 'ready',
uploadedAt: atMinute(60),
withVariants: true,
});
await seedPhoto({
id: phPubO,
uploaderId: ownerId,
visibility: 'published',
processingState: 'ready',
uploadedAt: atMinute(50),
withVariants: true,
});
await seedPhoto({
id: phHidC,
uploaderId: collabId,
visibility: 'hidden',
processingState: 'ready',
uploadedAt: atMinute(40),
withVariants: true,
});
await seedPhoto({
id: phHidO,
uploaderId: ownerId,
visibility: 'hidden',
processingState: 'ready',
uploadedAt: atMinute(30),
withVariants: true,
});
await seedPhoto({
id: phDelO,
uploaderId: ownerId,
visibility: 'deleted',
processingState: 'ready',
uploadedAt: atMinute(20),
withVariants: true,
});
await seedPhoto({
id: phNovarC,
uploaderId: collabId,
visibility: 'unpublished',
processingState: 'processing_failed',
uploadedAt: atMinute(10),
withVariants: false,
});
// 预览字节取回断言用:phPubO 的 browse 对象实写 MinIO。
await putObject(
`photo-live/dev/albums/${albumAId}/variants/${phPubO}/1/browse.jpg`,
pubOBrowseBytes,
);
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
await s3.destroy();
});
it('① listAlbums cover:最新 effective 胜出、pending/非 cover 不计、URL 可取回字节', async () => {
const res = await listAlbums(ownerToken);
expect(res.status).toBe(200);
const items = res.body.items as {
id: string;
cover: { url: string; expiresAt: string } | null;
}[];
expect(items).toHaveLength(6); // A + B + 4 filler(owner 全部可见)
const byId = new Map(items.map((item) => [item.id, item]));
// A:cover 非 null,签名 URL 实取回新 effective 行字节(非旧行/pending)。
const coverA = byId.get(albumAId)?.cover;
expect(coverA).not.toBeNull();
expect(coverA?.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(await fetchBody(coverA!.url)).toEqual(coverNewBytes);
// B:无封面 → null(键在,值为 null;旧字段不受影响)。
expect(byId.get(albumBId)?.cover).toBeNull();
// filler:全部非 null。
for (const id of fillerIds) {
expect(byId.get(id)?.cover).not.toBeNull();
}
});
it('① getAlbum 详情:cover 同形(批量路径与单查路径一致)', async () => {
const res = await request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumAId}`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(res.status).toBe(200);
expect(res.body.cover).not.toBeNull();
expect(await fetchBody(res.body.cover.url as string)).toEqual(coverNewBytes);
const resB = await request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumBId}`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(resB.status).toBe(200);
expect(resB.body.cover).toBeNull();
});
it('① 批量无 N+1:6 相册(5 封面)单页列表 = 恒定 Prisma 查询数', async () => {
// Prisma 5 $use 无卸载钩子:只挂一次(纯计数透传,不改变行为),以
// 计数器清零分隔两次测量。只计相册域模型——SessionGuard 的 sessions
// 查询等基础设施流量不入断言(防守卫演进误伤本断言)。
const prismaService = app.get(PrismaService);
const ALBUM_DOMAIN_MODELS = new Set([
'albums',
'album_translations',
'album_assets',
'album_members',
]);
let ops = 0;
prismaService.$use((params, next) => {
if (ALBUM_DOMAIN_MODELS.has(params.model as string)) {
ops += 1;
}
return next(params);
});
try {
const res = await listAlbums(ownerToken);
expect(res.status).toBe(200);
expect((res.body.items as unknown[]).length).toBe(6);
// owner:成员行 1 + albums 1 + translations 1 + album_assets 1 = 4。
// 封面相册数(5)不进入查询数——若退化为逐相册查封面(N+1)至少 +5。
expect(ops).toBe(4);
// admin 对照:无成员行查询,恒 3(与封面相册数无关)。
ops = 0;
const adminRes = await listAlbums(adminToken);
expect(adminRes.status).toBe(200);
expect((adminRes.body.items as unknown[]).length).toBe(6);
expect(ops).toBe(3);
} finally {
// 中间件无法卸载(Prisma 5),保留纯计数透传不影响后续用例;置回 0
// 仅为语义整洁。
ops = 0;
}
});
it('② owner scope=all(缺省):preview 矩阵 + browse 字节取回', async () => {
const res = await listPhotos(ownerToken, albumAId);
expect(res.status).toBe(200);
const byId = new Map(
(res.body.items as { photoId: string; preview: unknown }[]).map(
(item) => [item.photoId, item.preview],
),
);
expect(res.body.items).toHaveLength(6);
// published/hidden(含他人上传)对 manager 签发;deleted/无变体 null。
expect(byId.get(phPubC)).not.toBeNull();
expect(byId.get(phPubO)).not.toBeNull();
expect(byId.get(phHidC)).not.toBeNull();
expect(byId.get(phHidO)).not.toBeNull();
expect(byId.get(phDelO)).toBeNull();
expect(byId.get(phNovarC)).toBeNull();
// 签名 URL 实取回字节(thumb/browse 双通道,preview.browse 为浏览图)。
const previewO = byId.get(phPubO) as {
thumb: { url: string };
browse: { url: string };
};
expect(await fetchBody(previewO.browse.url)).toEqual(pubOBrowseBytes);
});
it('② admin scope=all:全部签发(他人 hidden 亦签)+ preview 形状', async () => {
const res = await listPhotos(adminToken, albumAId, { scope: 'all' });
expect(res.status).toBe(200);
const items = res.body.items as {
photoId: string;
preview: { thumb: { url: string; expiresAt: string }; browse: { url: string } } | null;
}[];
const byId = new Map(items.map((item) => [item.photoId, item.preview]));
expect(byId.get(phPubC)).not.toBeNull();
expect(byId.get(phHidC)).not.toBeNull(); // 他人 hidden 对 admin 签发
expect(byId.get(phHidO)).not.toBeNull();
expect(byId.get(phDelO)).toBeNull();
expect(byId.get(phNovarC)).toBeNull();
for (const item of items) {
if (item.preview !== null) {
expect(Object.keys(item.preview).sort()).toEqual(['browse', 'thumb']);
expect(item.preview.thumb.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
}
});
it('③ collab scope=own:仅自己上传(服务端过滤)+ 自己 hidden 行 preview=null', async () => {
const res = await listPhotos(collabToken, albumAId, { scope: 'own' });
expect(res.status).toBe(200);
const items = res.body.items as {
photoId: string;
uploaderId: string;
preview: unknown;
}[];
expect(items.map((i) => i.photoId)).toEqual([phPubC, phHidC, phNovarC]);
for (const item of items) {
expect(item.uploaderId).toBe(collabId); // 服务端过滤,无 owner 照片
}
// 自己的 published/未完成处理照常签发;hidden 仅 admin/owner 签发 → null
//(记录仍可见,图片不可见)。
const byId = new Map(items.map((i) => [i.photoId, i.preview]));
expect(byId.get(phPubC)).not.toBeNull();
expect(byId.get(phHidC)).toBeNull();
expect(byId.get(phNovarC)).toBeNull();
});
it('③ owner scope=own:过滤为自己上传(collab 照片不入列表)', async () => {
const res = await listPhotos(ownerToken, albumAId, { scope: 'own' });
expect(res.status).toBe(200);
const ids = (res.body.items as { photoId: string }[]).map((i) => i.photoId);
expect(ids).toEqual([phPubO, phHidO, phDelO]);
// 组合过滤:scope=own + visibility=published。
const combined = await listPhotos(ownerToken, albumAId, {
scope: 'own',
visibility: 'published',
});
expect(combined.status).toBe(200);
expect(
(combined.body.items as { photoId: string }[]).map((i) => i.photoId),
).toEqual([phPubO]);
});
it('④ collab scope=all(缺省与显式)→ 403;非成员任何 scope → 404;非法 scope → 400', async () => {
const implicit = await listPhotos(collabToken, albumAId);
expect(implicit.status).toBe(403);
expect(implicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const explicit = await listPhotos(collabToken, albumAId, { scope: 'all' });
expect(explicit.status).toBe(403);
expect(explicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
// 非成员(outsider 对 A)任何 scope → 404(防枚举)。
for (const scope of ['own', 'all', undefined]) {
const res = await listPhotos(outsiderToken, albumAId, scope === undefined ? {} : { scope });
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
}
// collab 对非成员相册 B → 404。
const cross = await listPhotos(collabToken, albumBId, { scope: 'own' });
expect(cross.status).toBe(404);
expect(cross.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// scope 非法值 → 400;空串视为缺省(collab → 403,同缺省口径)。
const garbage = await listPhotos(ownerToken, albumAId, { scope: 'mine' });
expect(garbage.status).toBe(400);
expect(garbage.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const emptyScope = await listPhotos(collabToken, albumAId, { scope: '' });
expect(emptyScope.status).toBe(403);
});
it('④ 被移除成员 scope=own → 404(成员资格逐请求检查)', async () => {
// 基线:collab scope=own 可用(③ 已证,此处再验一次后移除)。
expect(
(await listPhotos(collabToken, albumAId, { scope: 'own' })).status,
).toBe(200);
await prisma.album_members.deleteMany({
where: { albumId: albumAId, userId: collabId },
});
const removed = await listPhotos(collabToken, albumAId, { scope: 'own' });
expect(removed.status).toBe(404);
expect(removed.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// 恢复成员(后续若有 spec 复用;本 spec 至此为止)。
await prisma.album_members.create({
data: {
albumId: albumAId,
userId: collabId,
role: 'collaborator',
status: 'active',
},
});
});
it('cover 回退 banner(宣传图/封面共用:仅 banner 相册 → cover=banner 对象)', async () => {
// 直种仅 banner 相册(复用本文件资产直种模式)。
const album = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId: 'u-ui01-coverfb',
startAt: new Date('2026-10-03T02:00:00.000Z'),
endAt: new Date('2026-10-03T12:00:00.000Z'),
defaultLocale: 'zh-CN',
},
});
await prisma.album_translations.create({
data: { albumId: album.id, locale: 'zh-CN', title: '仅宣传图相册' },
});
const key = `photo-live/dev/albums/${album.id}/assets/u01assetbannerfb00000000.jpg`;
await putObject(key, Buffer.from([0x51, 0x52, 0x53]));
await prisma.album_assets.create({
data: {
id: 'u01assetbannerfb00000000',
albumId: album.id,
kind: 'banner',
locale: null,
objectKey: key,
state: 'effective',
expectedBytes: 3,
contentType: 'image/jpeg',
},
});
const res = await listAlbums(adminToken);
expect(res.status).toBe(200);
const item = (res.body.items as Array<{ id: string; cover: { url: string } | null }>).find(
(row) => row.id === album.id,
);
expect(item).toBeDefined();
expect(item?.cover).not.toBeNull();
const fetched = await fetch(item!.cover!.url);
expect(fetched.status).toBe(200);
expect(Buffer.from(await fetched.arrayBuffer()).length).toBe(3);
});
});