feat(api): management photo list and account lookup
P1b Task 4: listAlbumPhotos (GET /albums/{albumId}/photos, tag
[photos]) + lookupUsersByAccount (GET /admin/users, tag [admin]).
- management list: all visibility states visible (unpublished/
published/hidden/deleted) for owner/admin; collaborator 403,
outsider/ghost album 404 (canView -> canManage ordering, same
anti-enumeration as members). Items = PhotoRecord + uploader
{userId, account, displayName} (flat-FK second query, redacted).
Keyset (uploadedAt,id) DESC via shared/cursor; visibility filter
validated against PhotoVisibility enum (400 otherwise), categoryId
must belong to the album (ghost/cross-album same-code 400).
pending_upload rows (uploadedAt NULL) excluded: the sort key is
both the contract-required field and the keyset predicate - NULL
can neither be shaped as PhotoRecord nor compared; in-flight
uploads are covered by the uploads history endpoint.
- account lookup (new src/admin module): prefix search over active
users only (credentialStatus=active, sourceStatus!=disabled,
!localLocked - same eligibility as SessionGuard/members.add),
account ASC, no cursor (small cap). $queryRaw LIKE prefix% with
explicit ESCAPE '!': !/%/_/\ escaped to literal matching (Prisma
startsWith escaping is not contractual); utf8mb4_0900_bin makes
the prefix match case-sensitive ('AL' misses 'alice'), documented
in contract + tests. Access policy per contract: any authenticated
staff (deviation from plan's admin/owner), unauthenticated 401.
- e2e: photos-admin (6) + users-lookup (6) cases; api e2e 136 -> 148.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4)。
|
||||||
|
// 装配 UsersLookupController/UsersLookupService;仅依赖 @Global 的 PrismaService
|
||||||
|
// (SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersLookupController } from './users-lookup.controller';
|
||||||
|
import { UsersLookupService } from './users-lookup.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersLookupController],
|
||||||
|
providers: [UsersLookupService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点
|
||||||
|
// (P1b Task 4)。方法名与契约 operationId 一一对应(lookupUsersByAccount)。
|
||||||
|
// 控制器级 SessionGuard:访问策略=任何已认证工作人员(契约描述的 P1b 决策,
|
||||||
|
// 见服务文件头),认证通过即可,无角色判定。控制器只做查询参数规整
|
||||||
|
// (accountPrefix 必填 2-64 字符;limit 缺省 10、1..20——本端点独立 bounds,
|
||||||
|
// 同公开照片列表在 public.controller 本地维护 parsePublicPhotoLimit 的先例,
|
||||||
|
// 勿混用 shared/pagination 的 1..100);语义在服务层。
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
import { SessionGuard } from '../auth/session.guard';
|
||||||
|
import { validationFailed } from '../albums/support';
|
||||||
|
import {
|
||||||
|
UsersLookupService,
|
||||||
|
type AdminUserLookupListResponse,
|
||||||
|
} from './users-lookup.service';
|
||||||
|
|
||||||
|
/** 契约 lookupUsersByAccount.limit:缺省 10,上下界 1..20。 */
|
||||||
|
export const DEFAULT_LOOKUP_LIMIT = 10;
|
||||||
|
export const MAX_LOOKUP_LIMIT = 20;
|
||||||
|
|
||||||
|
/** accountPrefix 查询参数:必填(缺失/空串 400),长度 2..64(契约 bounds)。 */
|
||||||
|
export function parseAccountPrefix(raw: string | undefined): string {
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
throw validationFailed('accountPrefix 必填(2-64 字符)');
|
||||||
|
}
|
||||||
|
if (raw.length < 2 || raw.length > 64) {
|
||||||
|
throw validationFailed('accountPrefix 须为 2-64 字符');
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** limit 查询参数:缺省 10,须为 1..20 的十进制整数(契约 bounds)。 */
|
||||||
|
export function parseLookupLimit(raw: string | undefined): number {
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
return DEFAULT_LOOKUP_LIMIT;
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(raw)) {
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.VALIDATION_FAILED,
|
||||||
|
'limit 必须是 1-20 的整数',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const limit = Number.parseInt(raw, 10);
|
||||||
|
if (limit < 1 || limit > MAX_LOOKUP_LIMIT) {
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.VALIDATION_FAILED,
|
||||||
|
'limit 必须是 1-20 的整数',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/users')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class UsersLookupController {
|
||||||
|
constructor(private readonly usersLookupService: UsersLookupService) {}
|
||||||
|
|
||||||
|
/** 账号前缀查询:激活用户脱敏列表(account 升序,无游标)。 */
|
||||||
|
@Get()
|
||||||
|
lookupUsersByAccount(
|
||||||
|
@Query('accountPrefix') accountPrefix: string | undefined,
|
||||||
|
@Query('limit') limit: string | undefined,
|
||||||
|
): Promise<AdminUserLookupListResponse> {
|
||||||
|
return this.usersLookupService.lookup(
|
||||||
|
parseAccountPrefix(accountPrefix),
|
||||||
|
parseLookupLimit(limit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// services/api/src/admin/users-lookup.service.ts —— 账号前缀查询(P1b Task 4,
|
||||||
|
// 契约 lookupUsersByAccount,tag [admin])。邀请协作者的前置查询:按 account
|
||||||
|
// 前缀搜索激活用户,返回 {id,account,displayName} 脱敏列表(不含邮箱/手机/
|
||||||
|
// 状态/凭据字段)。
|
||||||
|
// 访问策略(P1b 决策,契约描述):任何已认证工作人员可用——邀请发起方不限于
|
||||||
|
// 创建者/管理员,泄露面仅为激活用户的最小展示字段、接收方为已认证内部人员;
|
||||||
|
// 未认证一律 401(控制器级 SessionGuard 承担,服务层不再重复角色判定)。
|
||||||
|
// 实现要点:
|
||||||
|
// · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注
|
||||||
|
// 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。
|
||||||
|
// · LIKE 通配符显式转义:Prisma startsWith 对 %/_ 的转义行为无契约保证
|
||||||
|
// (引擎版本相关),此处 $queryRaw 显式 `ESCAPE '!'`,前缀中 !/%/_/\ 四
|
||||||
|
// 字符前置 ! 后按字面匹配——'esc_' 只命中字面下划线账号,不作单字符通配。
|
||||||
|
// · 资格过滤与 SessionGuard 同一谓词(credentialStatus=active 且
|
||||||
|
// sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被
|
||||||
|
// 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。
|
||||||
|
// · 无游标:前缀搜索 + 小上限(1..20 缺省 10)即取即用(契约决策),按
|
||||||
|
// account 升序返回确定性输出。
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { components } from '@photo/contracts';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type AdminUserLookupListResponse =
|
||||||
|
components['schemas']['AdminUserLookupList'];
|
||||||
|
|
||||||
|
/** $queryRaw 行形状(SELECT 列即脱敏字段全集——不选取任何凭据/状态列)。 */
|
||||||
|
interface LookupRow {
|
||||||
|
id: string;
|
||||||
|
account: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIKE 前缀转义(ESCAPE '!'):! 自身、% 与 _(MySQL 通配符)及 \(历史转义
|
||||||
|
* 字符,防御不同 SQL_MODE 下的语义漂移)前置 ! 后按字面匹配。
|
||||||
|
*/
|
||||||
|
export function escapeLikePrefix(prefix: string): string {
|
||||||
|
return prefix.replace(/[!%_\\]/g, (ch) => `!${ch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersLookupService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 前缀查询(契约 lookupUsersByAccount):激活用户、account 升序、limit 截断。 */
|
||||||
|
async lookup(
|
||||||
|
accountPrefix: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<AdminUserLookupListResponse> {
|
||||||
|
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
|
||||||
|
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
|
||||||
|
const rows = await this.prisma.$queryRaw<LookupRow[]>`
|
||||||
|
SELECT id, account, displayName
|
||||||
|
FROM users
|
||||||
|
WHERE account LIKE ${pattern} ESCAPE '!'
|
||||||
|
AND credentialStatus = 'active'
|
||||||
|
AND sourceStatus <> 'disabled'
|
||||||
|
AND localLocked = false
|
||||||
|
ORDER BY account ASC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
account: row.account,
|
||||||
|
displayName: row.displayName,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
|||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import { AlbumsModule } from './albums/albums.module';
|
import { AlbumsModule } from './albums/albums.module';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
@@ -38,8 +39,11 @@ import type { ServerRequestWithId } from './shared/request-with-id';
|
|||||||
UploadsModule,
|
UploadsModule,
|
||||||
// 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。
|
// 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。
|
||||||
PublicModule,
|
PublicModule,
|
||||||
// 照片管理域(Task 24:可见性生命周期 PATCH/DELETE)。
|
// 照片管理域(Task 24:可见性生命周期 PATCH/DELETE;P1b Task 4 增补
|
||||||
|
// /albums/{albumId}/photos 管理列表)。
|
||||||
PhotosModule,
|
PhotosModule,
|
||||||
|
// 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。
|
||||||
|
AdminModule,
|
||||||
LoggerModule.forRoot({
|
LoggerModule.forRoot({
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos
|
||||||
|
// 管理列表端点(P1b Task 4)。方法名与契约 operationId 一一对应
|
||||||
|
// (listAlbumPhotos,tag [photos]——路由挂在相册子路径下,域仍属照片管理)。
|
||||||
|
// 控制器级 SessionGuard:端点需认证。控制器只做认证用户透传 + 查询参数规整
|
||||||
|
// (visibility 本域 dto / categoryId 空串归一 / cursor+limit 走 shared/pagination,
|
||||||
|
// 同契约 bounds);权限与 HTTP 映射由 PhotosService 抛 ApiException
|
||||||
|
// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { userOf } from '../albums/albums.controller';
|
||||||
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
|
import { parseCursor, parseLimit } from '../shared/pagination';
|
||||||
|
import { parseVisibilityFilter } from './dto';
|
||||||
|
import { PhotosService, type AdminPhotoListResponse } from './photos.service';
|
||||||
|
|
||||||
|
@Controller('albums/:albumId/photos')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class AlbumPhotosController {
|
||||||
|
constructor(private readonly photosService: PhotosService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理照片列表:全部可见性状态可见(区别于公开列表仅 published),
|
||||||
|
* (uploadedAt,id) 稳定倒序键集游标;条目内嵌 uploader 脱敏信息。
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
listAlbumPhotos(
|
||||||
|
@Param('albumId') albumId: string,
|
||||||
|
@Query('visibility') visibility: string | undefined,
|
||||||
|
@Query('categoryId') categoryId: string | undefined,
|
||||||
|
@Query('cursor') cursor: string | undefined,
|
||||||
|
@Query('limit') limit: string | undefined,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<AdminPhotoListResponse> {
|
||||||
|
return this.photosService.listAlbumPhotos(userOf(req), albumId, {
|
||||||
|
visibility: parseVisibilityFilter(visibility),
|
||||||
|
// 空串视为缺省(同公开列表 category 规整惯例)。
|
||||||
|
categoryId: categoryId === '' ? undefined : categoryId,
|
||||||
|
cursor: parseCursor(cursor),
|
||||||
|
limit: parseLimit(limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
// services/api/src/photos/dto.ts —— 照片可见性端点入参 DTO(Task 24)。
|
// services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body;
|
||||||
// 校验约束对齐契约 PhotoVisibilityUpdateRequest:visibility 仅允许
|
// P1b Task 4 增补:管理列表 visibility 查询参数)。校验约束对齐契约:
|
||||||
// hidden/published(enum 之外任何值 → 400 VALIDATION_FAILED);deleted 只能经
|
// PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外
|
||||||
// DELETE 进入,不开放 PATCH 直达(单向终态,见 photos.service 状态机)。
|
// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH
|
||||||
|
// 直达(单向终态,见 photos.service 状态机)。
|
||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
import { IsIn } from 'class-validator';
|
import { IsIn } from 'class-validator';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
|
||||||
/** 契约 PhotoVisibilityUpdateRequest.visibility 取值(hidden↔published 间切换)。 */
|
/** 契约 PhotoVisibilityUpdateRequest.visibility 取值(hidden↔published 间切换)。 */
|
||||||
export const PHOTO_VISIBILITY_TARGETS = ['hidden', 'published'] as const;
|
export const PHOTO_VISIBILITY_TARGETS = ['hidden', 'published'] as const;
|
||||||
@@ -12,3 +16,33 @@ export class PhotoVisibilityUpdateDto {
|
|||||||
@IsIn(PHOTO_VISIBILITY_TARGETS)
|
@IsIn(PHOTO_VISIBILITY_TARGETS)
|
||||||
visibility!: PhotoVisibilityTarget;
|
visibility!: PhotoVisibilityTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 契约 PhotoVisibility 全集(管理列表 visibility 过滤参数的合法值域)。 */
|
||||||
|
export const PHOTO_VISIBILITIES = [
|
||||||
|
'unpublished',
|
||||||
|
'published',
|
||||||
|
'hidden',
|
||||||
|
'deleted',
|
||||||
|
] as const;
|
||||||
|
export type PhotoVisibility = (typeof PHOTO_VISIBILITIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理列表 visibility 查询参数(P1b Task 4):空串视为缺省(同 categoryId
|
||||||
|
* 的规整惯例,?visibility= 不构成显式过滤);非空须为契约 PhotoVisibility
|
||||||
|
* 枚举值,否则 400 VALIDATION_FAILED(契约 listAlbumPhotos 400 语义)。
|
||||||
|
*/
|
||||||
|
export function parseVisibilityFilter(
|
||||||
|
raw: string | undefined,
|
||||||
|
): PhotoVisibility | undefined {
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!(PHOTO_VISIBILITIES as readonly string[]).includes(raw)) {
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.VALIDATION_FAILED,
|
||||||
|
'visibility 取值非法',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return raw as PhotoVisibility;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24)。
|
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24;P1b Task 4
|
||||||
// 装配:PhotosController/PhotosService + PermissionsService(经 AlbumsModule
|
// 增补管理列表控制器)。装配:PhotosController/AlbumPhotosController/
|
||||||
// 导入复用 A02 逐次权限判定;同 uploads 模块的装配先例)。
|
// PhotosService + PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限
|
||||||
|
// 判定;同 uploads 模块的装配先例)。
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AlbumsModule } from '../albums/albums.module';
|
import { AlbumsModule } from '../albums/albums.module';
|
||||||
|
import { AlbumPhotosController } from './album-photos.controller';
|
||||||
import { PhotosController } from './photos.controller';
|
import { PhotosController } from './photos.controller';
|
||||||
import { PhotosService } from './photos.service';
|
import { PhotosService } from './photos.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AlbumsModule],
|
imports: [AlbumsModule],
|
||||||
controllers: [PhotosController],
|
controllers: [PhotosController, AlbumPhotosController],
|
||||||
providers: [PhotosService],
|
providers: [PhotosService],
|
||||||
})
|
})
|
||||||
export class PhotosModule {}
|
export class PhotosModule {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// services/api/src/photos/photos.service.ts —— 照片可见性生命周期(Task 24:
|
// services/api/src/photos/photos.service.ts —— 照片可见性生命周期(Task 24:
|
||||||
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)。契约权威:openapi.yaml tags
|
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)+ 管理照片列表(P1b Task 4:
|
||||||
// [photos]。状态机(visibility,docs §5.5「对外可见性」):
|
// listAlbumPhotos,全状态可见 + uploader 脱敏内嵌 + (uploadedAt,id) 键集游标)。
|
||||||
|
// 契约权威:openapi.yaml tags [photos]。状态机(visibility,docs §5.5「对外可见性」):
|
||||||
// unpublished ──(worker 发布事务, Task 21)──▶ published
|
// unpublished ──(worker 发布事务, Task 21)──▶ published
|
||||||
// published ◀──────(manager)──────▶ hidden 双向,管理者可来回
|
// published ◀──────(manager)──────▶ hidden 双向,管理者可来回
|
||||||
// unpublished/published/hidden ──(manager)──▶ deleted 单向终态(软删除)
|
// unpublished/published/hidden ──(manager)──▶ deleted 单向终态(软删除)
|
||||||
@@ -26,8 +27,18 @@ import type { Prisma } from '@photo/db-cn';
|
|||||||
import { ApiException } from '../api-exception';
|
import { ApiException } from '../api-exception';
|
||||||
import type { AuthUser } from '../auth/session.guard';
|
import type { AuthUser } from '../auth/session.guard';
|
||||||
import { PermissionsService } from '../albums/permissions.service';
|
import { PermissionsService } from '../albums/permissions.service';
|
||||||
|
import {
|
||||||
|
albumForbidden,
|
||||||
|
albumNotFound,
|
||||||
|
validationFailed,
|
||||||
|
} from '../albums/support';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { PhotoVisibilityTarget } from './dto';
|
import {
|
||||||
|
decodeKeysetCursor,
|
||||||
|
encodeKeysetCursor,
|
||||||
|
keysetWhereDesc,
|
||||||
|
} from '../shared/cursor';
|
||||||
|
import type { PhotoVisibility, PhotoVisibilityTarget } from './dto';
|
||||||
import {
|
import {
|
||||||
photoForbidden,
|
photoForbidden,
|
||||||
photoNotFound,
|
photoNotFound,
|
||||||
@@ -35,6 +46,7 @@ import {
|
|||||||
} from './support';
|
} from './support';
|
||||||
|
|
||||||
export type PhotoRecord = components['schemas']['PhotoRecord'];
|
export type PhotoRecord = components['schemas']['PhotoRecord'];
|
||||||
|
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
|
||||||
|
|
||||||
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||||
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||||
@@ -68,6 +80,126 @@ export class PhotosService {
|
|||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理照片列表(P1b Task 4,契约 listAlbumPhotos):区别于公开列表——全部
|
||||||
|
* 可见性状态可见(unpublished/published/hidden/deleted),条目内嵌 uploader
|
||||||
|
* 脱敏信息(account/displayName,无凭据字段)。权限顺序防枚举(同 members):
|
||||||
|
* canViewAlbum=false → 404 ALBUM_NOT_FOUND;可见但 canManageAlbum=false
|
||||||
|
* (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。
|
||||||
|
* 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。
|
||||||
|
*
|
||||||
|
* 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约
|
||||||
|
* 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的
|
||||||
|
* new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的
|
||||||
|
* 比较恒 UNKNOWN,跨页会被静默跳过)。进行中的会话由 uploads 历史端点表达。
|
||||||
|
*/
|
||||||
|
async listAlbumPhotos(
|
||||||
|
user: AuthUser,
|
||||||
|
albumId: string,
|
||||||
|
query: {
|
||||||
|
visibility?: PhotoVisibility;
|
||||||
|
categoryId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit: number;
|
||||||
|
},
|
||||||
|
): Promise<AdminPhotoListResponse> {
|
||||||
|
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||||
|
throw albumForbidden('仅创建者或管理员可访问管理列表');
|
||||||
|
}
|
||||||
|
// admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身
|
||||||
|
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
|
||||||
|
const albumExists = await this.prisma.albums.findUnique({
|
||||||
|
where: { id: albumId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (albumExists === null) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryId:须为本相册分类;幽灵/跨册同码 400(同公开列表语义,不区分
|
||||||
|
// 两种失败,防探测)。
|
||||||
|
if (query.categoryId !== undefined) {
|
||||||
|
const category = await this.prisma.categories.findUnique({
|
||||||
|
where: { id: query.categoryId },
|
||||||
|
select: { albumId: true },
|
||||||
|
});
|
||||||
|
if (category === null || category.albumId !== albumId) {
|
||||||
|
throw validationFailed('categoryId 不属于该相册');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||||
|
if (query.cursor !== undefined && query.cursor !== '') {
|
||||||
|
decoded = decodeKeysetCursor(query.cursor);
|
||||||
|
if (decoded === null) {
|
||||||
|
throw validationFailed('cursor 不合法');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.photosWhereInput = {
|
||||||
|
albumId,
|
||||||
|
uploadedAt: { not: null },
|
||||||
|
...(query.visibility !== undefined
|
||||||
|
? { visibility: query.visibility }
|
||||||
|
: {}),
|
||||||
|
...(query.categoryId !== undefined
|
||||||
|
? { categoryId: query.categoryId }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
if (decoded !== null) {
|
||||||
|
Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded));
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.photos.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ uploadedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit + 1, // 多取一条探测 hasMore(同相册/成员列表)
|
||||||
|
});
|
||||||
|
const hasMore = rows.length > query.limit;
|
||||||
|
const page = hasMore ? rows.slice(0, query.limit) : rows;
|
||||||
|
|
||||||
|
// users 无 relation(扁平外键):二次查询应用层组装 uploader 脱敏信息。
|
||||||
|
const users =
|
||||||
|
page.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.users.findMany({
|
||||||
|
where: { id: { in: page.map((p) => p.uploaderId) } },
|
||||||
|
select: { id: true, account: true, displayName: true },
|
||||||
|
});
|
||||||
|
const userById = new Map(users.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
const items = page.flatMap((row) => {
|
||||||
|
const uploader = userById.get(row.uploaderId);
|
||||||
|
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同
|
||||||
|
// 成员列表先例,单条脏数据不拖垮整页)。
|
||||||
|
return uploader === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
...toPhotoRecord(row),
|
||||||
|
uploader: {
|
||||||
|
userId: uploader.id,
|
||||||
|
account: uploader.account,
|
||||||
|
displayName: uploader.displayName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const last = page.at(-1);
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && last !== undefined
|
||||||
|
? encodeKeysetCursor({
|
||||||
|
// where 已排除 NULL:断言收窄供类型系统(键集取页末行 DB 位置)。
|
||||||
|
sort: (last.uploadedAt ?? new Date(0)).toISOString(),
|
||||||
|
id: last.id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 →
|
* 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 →
|
||||||
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。
|
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
// services/api/test/photos-admin.e2e-spec.ts —— P1b Task 4 管理照片列表 e2e
|
||||||
|
// (先写后实现)。真实 app + MySQL。覆盖:
|
||||||
|
// ① owner GET /albums/{albumId}/photos:全部可见性状态可见(unpublished/
|
||||||
|
// published/hidden/deleted,含 uploaded/processing/processing_failed 处理
|
||||||
|
// 态),(uploadedAt,id) DESC 稳定倒序(同 uploadedAt 以 id DESC 决胜),
|
||||||
|
// 条目 = PhotoRecord + uploader{userId,account,displayName} 脱敏内嵌
|
||||||
|
// (无邮箱/密码/状态字段;契约 PhotoRecord 不含 errorCode——管理侧对失败
|
||||||
|
// 的可见性经 processingState=processing_failed 表达);未完成上传
|
||||||
|
// (pending_upload、uploadedAt NULL)不入列表(排序键未就绪,见服务注释);
|
||||||
|
// 他册照片不入列表;
|
||||||
|
// ② visibility 过滤(四态逐一 + 非法值 400);categoryId 过滤(含与
|
||||||
|
// visibility 组合;幽灵/跨册分类 400);
|
||||||
|
// ③ 游标分页:limit=3 三页跨页无重复、尾页 hasMore=false;非法 cursor /
|
||||||
|
// limit 越界 → 400;
|
||||||
|
// ④ 权限:协作者(非 owner 成员)403 AUTH_FORBIDDEN;外人员工 404
|
||||||
|
// ALBUM_NOT_FOUND;公司 admin 200;幽灵相册 404;未认证 401。
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
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 { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 契约 PhotoRecord 必返字段 + 管理列表内嵌 uploader(形状精确断言用)。 */
|
||||||
|
const ADMIN_PHOTO_KEYS = [
|
||||||
|
'albumId',
|
||||||
|
'bytes',
|
||||||
|
'categoryId',
|
||||||
|
'height',
|
||||||
|
'likeCount',
|
||||||
|
'photoId',
|
||||||
|
'processingState',
|
||||||
|
'publishedAt',
|
||||||
|
'shotAt',
|
||||||
|
'uploadedAt',
|
||||||
|
'uploader',
|
||||||
|
'uploaderId',
|
||||||
|
'visibility',
|
||||||
|
'width',
|
||||||
|
].sort();
|
||||||
|
|
||||||
|
/** 固定时间基(分钟偏移构造确定性 uploadedAt 序列)。 */
|
||||||
|
const T0 = Date.UTC(2026, 8, 12, 8, 0, 0);
|
||||||
|
const atMinute = (m: number): Date => new Date(T0 + m * 60_000);
|
||||||
|
|
||||||
|
describe('管理照片列表 GET /albums/{albumId}/photos (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
let albumId: string;
|
||||||
|
let catAId: string;
|
||||||
|
let catBId: string;
|
||||||
|
let catOtherId: string;
|
||||||
|
|
||||||
|
let ownerId: string;
|
||||||
|
let collabId: string;
|
||||||
|
let ownerToken: string;
|
||||||
|
let collabToken: string;
|
||||||
|
let adminToken: string;
|
||||||
|
let outsiderToken: string;
|
||||||
|
|
||||||
|
// 种子照片(显式 ID + 显式 uploadedAt,期望序可静态推出)。
|
||||||
|
let p1: string; // published / collab / catA / t+50
|
||||||
|
let p2: string; // published / owner / 无分类 / t+40
|
||||||
|
let p3: string; // hidden / collab / catA / t+30
|
||||||
|
let p4: string; // deleted / owner / t+20
|
||||||
|
let p5: string; // unpublished processing_failed / collab / catB / t+10
|
||||||
|
let tieA: string; // unpublished processing / owner / t+0(与 tieB 同刻)
|
||||||
|
let tieB: string; // unpublished uploaded / collab / catB / t+0(同刻 id 决胜)
|
||||||
|
|
||||||
|
/** 全量无过滤期望序(uploadedAt DESC, id DESC;tie-b > tie-a)。 */
|
||||||
|
const FULL_ORDER = () => [p1, p2, p3, p4, p5, tieB, tieA];
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 账号:owner / collab(上传协作者)/ admin / outsider(外人员工)。
|
||||||
|
// displayName 与 account 不同值,验证 uploader 映射取的是资料字段。
|
||||||
|
const mkUser = (
|
||||||
|
account: string,
|
||||||
|
displayName: string,
|
||||||
|
role = 'staff',
|
||||||
|
) =>
|
||||||
|
prisma.users.create({
|
||||||
|
data: { account, role, passwordHash: 'x', displayName },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
ownerId = (await mkUser('pa-owner', '潘欧文')).id;
|
||||||
|
collabId = (await mkUser('pa-collab', '程澜')).id;
|
||||||
|
const adminId = (await mkUser('pa-admin', '艾管', 'admin')).id;
|
||||||
|
const outsiderId = (await mkUser('pa-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);
|
||||||
|
|
||||||
|
// 主相册:owner + collab 成员行;分类 catA/catB。
|
||||||
|
const album = await prisma.albums.create({
|
||||||
|
data: {
|
||||||
|
publicToken: makePublicToken(),
|
||||||
|
ownerId,
|
||||||
|
startAt: new Date(),
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
albumId = album.id;
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const catA = await prisma.categories.create({
|
||||||
|
data: { albumId, sortOrder: 1 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const catB = await prisma.categories.create({
|
||||||
|
data: { albumId, sortOrder: 2 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
catAId = catA.id;
|
||||||
|
catBId = catB.id;
|
||||||
|
await prisma.category_translations.createMany({
|
||||||
|
data: [
|
||||||
|
{ categoryId: catAId, locale: 'zh-CN', name: '晚宴' },
|
||||||
|
{ categoryId: catBId, locale: 'zh-CN', name: '后台' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 他册(跨册分类/照片排除用):owner 名下独立相册 + 分类 + 一张 published。
|
||||||
|
const otherAlbum = await prisma.albums.create({
|
||||||
|
data: {
|
||||||
|
publicToken: makePublicToken(),
|
||||||
|
ownerId,
|
||||||
|
startAt: new Date(),
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const catOther = await prisma.categories.create({
|
||||||
|
data: { albumId: otherAlbum.id, sortOrder: 1 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
catOtherId = catOther.id;
|
||||||
|
await seedPhoto({
|
||||||
|
id: 'pa-photo-other',
|
||||||
|
targetAlbumId: otherAlbum.id,
|
||||||
|
uploaderId: ownerId,
|
||||||
|
visibility: 'published',
|
||||||
|
processingState: 'ready',
|
||||||
|
uploadedAt: atMinute(45),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 主册种子:两上传人 × 全可见性状态 × 处理态变体;tie 对同 uploadedAt。
|
||||||
|
p1 = await seedPhoto({
|
||||||
|
id: 'pa-photo-p1',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: collabId,
|
||||||
|
visibility: 'published',
|
||||||
|
processingState: 'ready',
|
||||||
|
categoryId: catAId,
|
||||||
|
uploadedAt: atMinute(50),
|
||||||
|
});
|
||||||
|
p2 = await seedPhoto({
|
||||||
|
id: 'pa-photo-p2',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: ownerId,
|
||||||
|
visibility: 'published',
|
||||||
|
processingState: 'ready',
|
||||||
|
uploadedAt: atMinute(40),
|
||||||
|
});
|
||||||
|
p3 = await seedPhoto({
|
||||||
|
id: 'pa-photo-p3',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: collabId,
|
||||||
|
visibility: 'hidden',
|
||||||
|
processingState: 'ready',
|
||||||
|
categoryId: catAId,
|
||||||
|
uploadedAt: atMinute(30),
|
||||||
|
});
|
||||||
|
p4 = await seedPhoto({
|
||||||
|
id: 'pa-photo-p4',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: ownerId,
|
||||||
|
visibility: 'deleted',
|
||||||
|
processingState: 'ready',
|
||||||
|
uploadedAt: atMinute(20),
|
||||||
|
});
|
||||||
|
p5 = await seedPhoto({
|
||||||
|
id: 'pa-photo-p5',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: collabId,
|
||||||
|
visibility: 'unpublished',
|
||||||
|
processingState: 'processing_failed',
|
||||||
|
categoryId: catBId,
|
||||||
|
uploadedAt: atMinute(10),
|
||||||
|
});
|
||||||
|
tieA = await seedPhoto({
|
||||||
|
id: 'pa-photo-tie-a',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: ownerId,
|
||||||
|
visibility: 'unpublished',
|
||||||
|
processingState: 'processing',
|
||||||
|
uploadedAt: atMinute(0),
|
||||||
|
});
|
||||||
|
tieB = await seedPhoto({
|
||||||
|
id: 'pa-photo-tie-b',
|
||||||
|
targetAlbumId: albumId,
|
||||||
|
uploaderId: collabId,
|
||||||
|
visibility: 'unpublished',
|
||||||
|
processingState: 'uploaded',
|
||||||
|
categoryId: catBId,
|
||||||
|
uploadedAt: atMinute(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 未完成上传(init 后未 complete 的会话占位行):uploadedAt NULL → 不入列表。
|
||||||
|
await prisma.photos.create({
|
||||||
|
data: {
|
||||||
|
id: 'pa-photo-pending',
|
||||||
|
albumId,
|
||||||
|
uploaderId: collabId,
|
||||||
|
objectKey: `photo-live/dev/albums/${albumId}/originals/pa-photo-pending.jpg`,
|
||||||
|
visibility: 'unpublished',
|
||||||
|
processingState: 'pending_upload',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直种照片行(管理列表不触及对象存储/变体——契约 PhotoRecord 无 URL 字段,
|
||||||
|
* 故无需 MinIO 对象与 photo_variants 行)。
|
||||||
|
*/
|
||||||
|
async function seedPhoto(spec: {
|
||||||
|
id: string;
|
||||||
|
targetAlbumId: string;
|
||||||
|
uploaderId: string;
|
||||||
|
visibility: string;
|
||||||
|
processingState: string;
|
||||||
|
categoryId?: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
}): Promise<string> {
|
||||||
|
await prisma.photos.create({
|
||||||
|
data: {
|
||||||
|
id: spec.id,
|
||||||
|
albumId: spec.targetAlbumId,
|
||||||
|
uploaderId: spec.uploaderId,
|
||||||
|
categoryId: spec.categoryId ?? null,
|
||||||
|
objectKey: `photo-live/dev/albums/${spec.targetAlbumId}/originals/${spec.id}.jpg`,
|
||||||
|
bytes: 12345,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
uploadedAt: spec.uploadedAt,
|
||||||
|
publishedAt:
|
||||||
|
spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt),
|
||||||
|
processingState: spec.processingState,
|
||||||
|
visibility: spec.visibility,
|
||||||
|
entityVersion: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return spec.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMgmt(
|
||||||
|
token: string,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/${albumId}/photos`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.query(query ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idsOf(body: { items: { photoId: string }[] }): string[] {
|
||||||
|
return body.items.map((item) => item.photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('owner → 200:全状态可见、(uploadedAt,id) DESC、uploader 脱敏内嵌正确', async () => {
|
||||||
|
const res = await listMgmt(ownerToken);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual([
|
||||||
|
'hasMore',
|
||||||
|
'items',
|
||||||
|
'nextCursor',
|
||||||
|
]);
|
||||||
|
expect(res.body.hasMore).toBe(false);
|
||||||
|
expect(res.body.nextCursor).toBeNull();
|
||||||
|
|
||||||
|
// 全状态 + 处理态变体全部可见;未完成上传(NULL uploadedAt)与他册照片排除。
|
||||||
|
expect(idsOf(res.body)).toEqual(FULL_ORDER());
|
||||||
|
expect(idsOf(res.body)).not.toContain('pa-photo-pending');
|
||||||
|
expect(idsOf(res.body)).not.toContain('pa-photo-other');
|
||||||
|
|
||||||
|
// 条目形状 = PhotoRecord 全字段 + uploader(无任何凭据/状态冗余字段)。
|
||||||
|
expect(Object.keys(res.body.items[0]).sort()).toEqual(ADMIN_PHOTO_KEYS);
|
||||||
|
|
||||||
|
// uploader 内嵌:两上传人各自映射到 account/displayName(脱敏资料字段)。
|
||||||
|
const byId = new Map(
|
||||||
|
(res.body.items as {
|
||||||
|
photoId: string;
|
||||||
|
uploader: { userId: string; account: string; displayName: string };
|
||||||
|
processingState: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
}[]).map((item) => [item.photoId, item]),
|
||||||
|
);
|
||||||
|
for (const item of byId.values()) {
|
||||||
|
expect(item.uploader).toEqual(
|
||||||
|
item.uploader.userId === ownerId
|
||||||
|
? { userId: ownerId, account: 'pa-owner', displayName: '潘欧文' }
|
||||||
|
: { userId: collabId, account: 'pa-collab', displayName: '程澜' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 处理态可见性(管理侧失败洞察):processing_failed / processing / uploaded。
|
||||||
|
expect(byId.get(p5)?.processingState).toBe('processing_failed');
|
||||||
|
expect(byId.get(tieA)?.processingState).toBe('processing');
|
||||||
|
expect(byId.get(tieB)?.processingState).toBe('uploaded');
|
||||||
|
// 未发布 publishedAt=null(与 published 行对照);分类映射。
|
||||||
|
expect(byId.get(p5)?.publishedAt).toBeNull();
|
||||||
|
expect(byId.get(p1)?.publishedAt).not.toBeNull();
|
||||||
|
expect(byId.get(p1)?.categoryId).toBe(catAId);
|
||||||
|
expect(byId.get(p2)?.categoryId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visibility 过滤:published/unpublished/hidden/deleted 逐一命中;非法值 → 400', async () => {
|
||||||
|
const published = await listMgmt(ownerToken, { visibility: 'published' });
|
||||||
|
expect(published.status).toBe(200);
|
||||||
|
expect(idsOf(published.body)).toEqual([p1, p2]);
|
||||||
|
|
||||||
|
const unpublished = await listMgmt(ownerToken, {
|
||||||
|
visibility: 'unpublished',
|
||||||
|
});
|
||||||
|
expect(unpublished.status).toBe(200);
|
||||||
|
expect(idsOf(unpublished.body)).toEqual([p5, tieB, tieA]);
|
||||||
|
|
||||||
|
const hidden = await listMgmt(ownerToken, { visibility: 'hidden' });
|
||||||
|
expect(hidden.status).toBe(200);
|
||||||
|
expect(idsOf(hidden.body)).toEqual([p3]);
|
||||||
|
|
||||||
|
const deleted = await listMgmt(ownerToken, { visibility: 'deleted' });
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
expect(idsOf(deleted.body)).toEqual([p4]);
|
||||||
|
|
||||||
|
for (const garbage of ['public', 'PUBLISHED', '42']) {
|
||||||
|
const res = await listMgmt(ownerToken, { visibility: garbage });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('categoryId 过滤与组合;幽灵/跨册 categoryId → 400', async () => {
|
||||||
|
const catA = await listMgmt(ownerToken, { categoryId: catAId });
|
||||||
|
expect(catA.status).toBe(200);
|
||||||
|
expect(idsOf(catA.body)).toEqual([p1, p3]);
|
||||||
|
|
||||||
|
const catB = await listMgmt(ownerToken, { categoryId: catBId });
|
||||||
|
expect(catB.status).toBe(200);
|
||||||
|
expect(idsOf(catB.body)).toEqual([p5, tieB]);
|
||||||
|
|
||||||
|
const combined = await listMgmt(ownerToken, {
|
||||||
|
visibility: 'published',
|
||||||
|
categoryId: catAId,
|
||||||
|
});
|
||||||
|
expect(combined.status).toBe(200);
|
||||||
|
expect(idsOf(combined.body)).toEqual([p1]);
|
||||||
|
|
||||||
|
const ghost = await listMgmt(ownerToken, { categoryId: 'no-such-cat' });
|
||||||
|
expect(ghost.status).toBe(400);
|
||||||
|
expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// 跨册分类:真实存在但不属于本相册——同码 400(不区分两种失败,防探测)。
|
||||||
|
const cross = await listMgmt(ownerToken, { categoryId: catOtherId });
|
||||||
|
expect(cross.status).toBe(400);
|
||||||
|
expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('游标分页:limit=3 三页跨页无重复,尾页 hasMore=false / nextCursor=null', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let pages = 0;
|
||||||
|
for (;;) {
|
||||||
|
const res = await listMgmt(
|
||||||
|
ownerToken,
|
||||||
|
cursor === undefined ? { limit: 3 } : { limit: 3, cursor },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
pages += 1;
|
||||||
|
seen.push(...idsOf(res.body));
|
||||||
|
if (res.body.hasMore === false) {
|
||||||
|
expect(res.body.nextCursor).toBeNull();
|
||||||
|
expect(pages).toBe(3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
expect(res.body.nextCursor).toEqual(expect.any(String));
|
||||||
|
cursor = res.body.nextCursor as string;
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(FULL_ORDER());
|
||||||
|
expect(new Set(seen).size).toBe(seen.length); // 无重复
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法 cursor / limit 越界 → 400 VALIDATION_FAILED', async () => {
|
||||||
|
const badCursor = await listMgmt(ownerToken, { cursor: 'not-a-cursor!' });
|
||||||
|
expect(badCursor.status).toBe(400);
|
||||||
|
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
for (const limit of [0, 101, 'abc']) {
|
||||||
|
const res = await listMgmt(ownerToken, { limit });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
// 合法界内:1 与 100 均 200。
|
||||||
|
expect((await listMgmt(ownerToken, { limit: 1 })).status).toBe(200);
|
||||||
|
expect((await listMgmt(ownerToken, { limit: 100 })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限:collab 403 / outsider 404 / admin 200 / 幽灵相册 404 / 未认证 401', async () => {
|
||||||
|
const collabRes = await listMgmt(collabToken);
|
||||||
|
expect(collabRes.status).toBe(403);
|
||||||
|
expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const outsiderRes = await listMgmt(outsiderToken);
|
||||||
|
expect(outsiderRes.status).toBe(404);
|
||||||
|
expect(outsiderRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const adminRes = await listMgmt(adminToken, { visibility: 'deleted' });
|
||||||
|
expect(adminRes.status).toBe(200);
|
||||||
|
expect(idsOf(adminRes.body)).toEqual([p4]);
|
||||||
|
|
||||||
|
const ghost = await request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/no-such-album/photos`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`);
|
||||||
|
expect(ghost.status).toBe(404);
|
||||||
|
expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
const anon = await request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/albums/${albumId}/photos`);
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// services/api/test/users-lookup.e2e-spec.ts —— P1b Task 4 账号前缀查询 e2e
|
||||||
|
// (先写后实现)。真实 app + MySQL。覆盖:
|
||||||
|
// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目仅
|
||||||
|
// id/account/displayName(脱敏,无邮箱/手机/状态/凭据字段);响应仅 items
|
||||||
|
// (无游标——前缀搜索 + 小上限即取即用);
|
||||||
|
// ② 账号资格过滤:credentialStatus=pending / sourceStatus=disabled /
|
||||||
|
// localLocked=true 均排除(只留「激活」用户);
|
||||||
|
// ③ 大小写敏感(库级 utf8mb4_0900_bin 二进制校对,契约已注明):'AL' → 空、
|
||||||
|
// 'carol' → 空、'Carol' → 命中;
|
||||||
|
// ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/
|
||||||
|
// 任意串通配(esc_ 仅命中字面下划线账号;al% 空);
|
||||||
|
// ⑤ 参数边界:accountPrefix 缺失/1 字符/65 字符 → 400;2 与 64 字符合法;
|
||||||
|
// limit 缺省 10、1..20 界内、0/21/非整数 → 400;
|
||||||
|
// ⑥ 访问策略(P1b 决策,契约描述):未认证 401;任何已认证工作人员(含非
|
||||||
|
// admin 的 staff)200;admin 200。
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
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 { API_PREFIX } from '../src/shared/constants';
|
||||||
|
import { resetDb } from './reset';
|
||||||
|
|
||||||
|
jest.setTimeout(120_000);
|
||||||
|
|
||||||
|
describe('账号查询 GET /admin/users (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
let staffToken: string; // 非 admin 工作人员(访问策略断言用)
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
let aliceId: string;
|
||||||
|
let albertId: string;
|
||||||
|
let carolId: string;
|
||||||
|
let escUnderscoreId: string;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const mkUser = (
|
||||||
|
account: string,
|
||||||
|
displayName: string,
|
||||||
|
extra: {
|
||||||
|
role?: string;
|
||||||
|
sourceStatus?: string;
|
||||||
|
credentialStatus?: string;
|
||||||
|
localLocked?: boolean;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
prisma.users.create({
|
||||||
|
data: {
|
||||||
|
account,
|
||||||
|
passwordHash: 'x',
|
||||||
|
displayName,
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
aliceId = (await mkUser('alice', '爱丽')).id;
|
||||||
|
albertId = (await mkUser('albert', '艾伯')).id;
|
||||||
|
await mkUser('bob', '博伟');
|
||||||
|
carolId = (await mkUser('Carol', '卡萝')).id; // 大写 C——大小写敏感断言
|
||||||
|
// 资格排除组(前缀均 'al' 命中域,但不应出现在结果里)。
|
||||||
|
await mkUser('aldis', '停用者', { sourceStatus: 'disabled' });
|
||||||
|
await mkUser('alpend', '待激活', { credentialStatus: 'pending' });
|
||||||
|
await mkUser('allock', '锁定者', { localLocked: true });
|
||||||
|
// LIKE 通配符转义断言组:esc_ 前缀若未转义 _,会同时命中 escshape。
|
||||||
|
escUnderscoreId = (await mkUser('esc_ape', '下划线')).id;
|
||||||
|
await mkUser('escshape', '形状');
|
||||||
|
// 默认 limit=10 截断断言组:12 个 zz-look-* 账号(升序 01..12)。
|
||||||
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
await mkUser(`zz-look-${String(i).padStart(2, '0')}`, `批量${i}`);
|
||||||
|
}
|
||||||
|
// 请求方:staff(非 admin)与 admin。
|
||||||
|
const staffId = (await mkUser('ul-staff', '陆石')).id;
|
||||||
|
const adminId = (await mkUser('ul-admin', '路安', { role: 'admin' })).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;
|
||||||
|
};
|
||||||
|
staffToken = await mkToken(staffId);
|
||||||
|
adminToken = await mkToken(adminId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function lookup(
|
||||||
|
token: string | null,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
) {
|
||||||
|
const req = request(app
|
||||||
|
.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/admin/users`);
|
||||||
|
if (token !== null) {
|
||||||
|
req.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
return req.query(query ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状脱敏", async () => {
|
||||||
|
const res = await lookup(staffToken, { accountPrefix: 'al' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// 响应仅 items(契约 AdminUserLookupList:无游标元信息)。
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual(['items']);
|
||||||
|
const items = res.body.items as {
|
||||||
|
id: string;
|
||||||
|
account: string;
|
||||||
|
displayName: string;
|
||||||
|
}[];
|
||||||
|
// account 升序:albert < alice;恰好两条(排除组全不出现在结果)。
|
||||||
|
expect(items.map((item) => item.account)).toEqual(['albert', 'alice']);
|
||||||
|
expect(items.map((item) => item.id)).toEqual([albertId, aliceId]);
|
||||||
|
expect(items.map((item) => item.displayName)).toEqual(['艾伯', '爱丽']);
|
||||||
|
// 条目形状:仅三个脱敏字段(无密码哈希/状态/角色等)。
|
||||||
|
for (const item of items) {
|
||||||
|
expect(Object.keys(item).sort()).toEqual([
|
||||||
|
'account',
|
||||||
|
'displayName',
|
||||||
|
'id',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('大小写敏感(bin 校对):“AL”/“carol” → 空;“Carol” → 命中', async () => {
|
||||||
|
const upper = await lookup(staffToken, { accountPrefix: 'AL' });
|
||||||
|
expect(upper.status).toBe(200);
|
||||||
|
expect(upper.body.items).toEqual([]);
|
||||||
|
|
||||||
|
const lowerCarol = await lookup(staffToken, { accountPrefix: 'carol' });
|
||||||
|
expect(lowerCarol.status).toBe(200);
|
||||||
|
expect(lowerCarol.body.items).toEqual([]);
|
||||||
|
|
||||||
|
const exact = await lookup(staffToken, { accountPrefix: 'Carol' });
|
||||||
|
expect(exact.status).toBe(200);
|
||||||
|
expect(exact.body.items).toEqual([
|
||||||
|
{ id: carolId, account: 'Carol', displayName: '卡萝' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LIKE 通配符按字面匹配(ESCAPE):“esc_” 仅命中字面下划线账号;“al%” 空', async () => {
|
||||||
|
// 未转义时 _ 作单字符通配会同时命中 escshape——转义后只命中 esc_ape。
|
||||||
|
const underscore = await lookup(staffToken, { accountPrefix: 'esc_' });
|
||||||
|
expect(underscore.status).toBe(200);
|
||||||
|
expect(underscore.body.items).toEqual([
|
||||||
|
{ id: escUnderscoreId, account: 'esc_ape', displayName: '下划线' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 未转义时 al% 作任意串通配会命中 albert/alice 等——转义后按字面前缀匹配。
|
||||||
|
const percent = await lookup(staffToken, { accountPrefix: 'al%' });
|
||||||
|
expect(percent.status).toBe(200);
|
||||||
|
expect(percent.body.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accountPrefix 边界:缺失/空串/1 字符/65 字符 → 400;64 字符 → 200 空', async () => {
|
||||||
|
const missing = await lookup(staffToken, {});
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const empty = await lookup(staffToken, { accountPrefix: '' });
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const short = await lookup(staffToken, { accountPrefix: 'a' });
|
||||||
|
expect(short.status).toBe(400);
|
||||||
|
expect(short.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const long = await lookup(staffToken, { accountPrefix: 'a'.repeat(65) });
|
||||||
|
expect(long.status).toBe(400);
|
||||||
|
expect(long.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const max = await lookup(staffToken, { accountPrefix: 'a'.repeat(64) });
|
||||||
|
expect(max.status).toBe(200);
|
||||||
|
expect(max.body.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit:缺省 10 且升序截断;5 合法;0/21/非整数 → 400;20 合法', async () => {
|
||||||
|
// zz-look-* 共 12 个激活账号:缺省 limit=10 → 恰前 10 个(01..10 升序)。
|
||||||
|
const def = await lookup(staffToken, { accountPrefix: 'zz-look-' });
|
||||||
|
expect(def.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(def.body.items as { account: string }[]).map((item) => item.account),
|
||||||
|
).toEqual(Array.from({ length: 10 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`));
|
||||||
|
|
||||||
|
const five = await lookup(staffToken, {
|
||||||
|
accountPrefix: 'zz-look-',
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(five.status).toBe(200);
|
||||||
|
expect(five.body.items).toHaveLength(5);
|
||||||
|
|
||||||
|
const top = await lookup(staffToken, {
|
||||||
|
accountPrefix: 'zz-look-',
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
expect(top.status).toBe(200);
|
||||||
|
expect(top.body.items).toHaveLength(12); // 上限 20 > 存量 12,全量返回
|
||||||
|
|
||||||
|
for (const limit of [0, 21, 'abc']) {
|
||||||
|
const res = await lookup(staffToken, {
|
||||||
|
accountPrefix: 'zz-look-',
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('访问策略:未认证 401;已认证 staff(非 admin)200;admin 200', async () => {
|
||||||
|
const anon = await lookup(null, { accountPrefix: 'al' });
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
|
||||||
|
|
||||||
|
// P1b 决策(契约描述):邀请发起方不限于 admin/owner,任何已认证工作人员
|
||||||
|
// 可用——此处显式断言非 admin 的 staff 可 200。
|
||||||
|
const staff = await lookup(staffToken, { accountPrefix: 'al' });
|
||||||
|
expect(staff.status).toBe(200);
|
||||||
|
expect(staff.body.items).toHaveLength(2);
|
||||||
|
|
||||||
|
const admin = await lookup(adminToken, { accountPrefix: 'al' });
|
||||||
|
expect(admin.status).toBe(200);
|
||||||
|
expect(admin.body.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user