import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID, randomBytes } from 'crypto';
import { PrismaService } from '@/core/database/prisma/prisma.service';
import { createTestApp } from '../../helpers/app.helper';
import { cleanupDatabase } from '../../helpers/cleanup.helper';
import {
  assignRoleToUser,
  createAdminUser,
  createTestUser,
} from '../../helpers/factories';

/**
 * Meeting-attendance · 议程能力 v1.0 L1 集成测试
 *
 * 覆盖 §5.A-H 38 用例（docs/modules/meeting-attendance/09-test-scenarios.md）：
 *   - 5.A 议程段 CRUD (5)
 *   - 5.B 议程项 CRUD (5)
 *   - 5.C 上传任务 CRUD (6)
 *   - 5.D 议程项级附件 (8)
 *   - 5.E 会议级附件 (3)
 *   - 5.F 资料下载 (4)
 *   - 5.G 资料删除 (3)
 *   - 5.H 边界 / 权限 / audit (4)
 *
 * 不写 L2 MCP 代码（见 10-e2e-test-spec.md）。
 * 真实 test DB；MIME / magic bytes 走真实 file-type lib。
 * 200MB 边界测试通过 env `RUN_LARGE_FILE_TESTS=1` 显式开启（CI 默认 skip 防 OOM）。
 */
describe('Meeting-attendance · Agenda v1.0 API', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  // 真实 1KB PDF magic bytes (`%PDF-` + 4KB padding)，过 file-type magic bytes 校验
  const PDF_HEADER = Buffer.from('%PDF-1.4\n%test\n', 'utf8');

  // 真实 PNG magic bytes
  const PNG_HEADER = Buffer.from([
    0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
    0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
    0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
    0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
    0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
    0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
    0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
    0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
    0x42, 0x60, 0x82,
  ]);

  function suffix() {
    return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  }

  function makePdfBuffer(sizeBytes: number = 1024): Buffer {
    const padding = Buffer.alloc(Math.max(0, sizeBytes - PDF_HEADER.length), 0x20);
    return Buffer.concat([PDF_HEADER, padding]);
  }

  async function login(username: string, password: string): Promise<string> {
    const resp = await request(app.getHttpServer())
      .post('/api/v1/auth/login')
      .send({ username, password })
      .expect(200);
    return resp.body.data.accessToken as string;
  }

  /** 给 role 注入议程相关 permission（resource=meeting, action=...）。 */
  async function ensureMeetingPermissionsOnRole(roleId: string, codes: string[]) {
    for (const code of codes) {
      const parts = code.split(':');
      const resource = parts[0];
      const action = parts.slice(1).join(':');
      const perm = await prisma.permission.upsert({
        where: { resource_action: { resource, action } },
        create: {
          resource,
          action,
          module: 'meeting-attendance',
          isBuiltIn: false,
        },
        update: {},
      });
      await prisma.rolePermission.upsert({
        where: {
          roleId_permissionId: { roleId, permissionId: perm.id },
        },
        create: { roleId, permissionId: perm.id },
        update: {},
      });
    }
  }

  async function ensureRole(code: 'Administrator' | 'MeetingManager' | 'Employee') {
    return prisma.role.upsert({
      where: { code },
      create: { code, name: code, enabled: true, isBuiltIn: true },
      update: { enabled: true },
    });
  }

  const AGENDA_FULL_PERMISSIONS = [
    'meeting:agenda:read',
    'meeting:agenda:update',
    'meeting:upload-task:assign',
    'meeting:attachment:upload:any',
    'meeting:attachment:upload:assigned',
    'meeting:attachment:download',
  ];

  const AGENDA_ASSIGNED_PERMISSIONS = [
    'meeting:agenda:read',
    'meeting:attachment:upload:assigned',
    'meeting:attachment:download',
  ];

  const AGENDA_READ_PERMISSIONS = [
    'meeting:agenda:read',
    'meeting:attachment:download',
  ];

  async function setupManager() {
    const s = suffix();
    const manager = await createAdminUser({
      username: `t_mgr_${s}`,
      email: `t_mgr_${s}@example.com`,
      password: 'Admin@123',
      displayName: `Agenda Manager ${s}`,
    });
    // 给 Administrator role 加议程权限（即使 PermissionsGuard 会 bypass，service 内仍读 actor.permissions）
    const adminRole = await ensureRole('Administrator');
    await ensureMeetingPermissionsOnRole(adminRole.id, AGENDA_FULL_PERMISSIONS);
    const mgrToken = await login(manager.username, 'Admin@123');
    return { manager, mgrToken };
  }

  async function setupAssignee(permissions: string[] = AGENDA_ASSIGNED_PERMISSIONS) {
    const s = suffix();
    const user = await createTestUser({
      username: `t_asg_${s}`,
      email: `t_asg_${s}@example.com`,
      password: 'Asg@123',
      displayName: `Agenda Assignee ${s}`,
    });
    const role = await ensureRole('Employee');
    await ensureMeetingPermissionsOnRole(role.id, permissions);
    await assignRoleToUser(user.id, role.id);
    const token = await login(user.username, 'Asg@123');
    return { user, token };
  }

  async function setupEmployee(permissions: string[] = AGENDA_READ_PERMISSIONS) {
    return setupAssignee(permissions); // 同 createTestUser + Employee role
  }

  async function createMeeting(opts: { creatorId: string }) {
    const now = new Date();
    return prisma.meeting.create({
      data: {
        title: `t_meeting_${suffix()}`,
        startTime: new Date(now.getTime() + 60 * 60 * 1000),
        endTime: new Date(now.getTime() + 2 * 60 * 60 * 1000),
        timezone: 'UTC',
        location: 'HQ',
        type: 'HYBRID',
        status: 'SCHEDULED',
        creatorId: opts.creatorId,
      } as any,
    });
  }

  async function addRequiredAttendee(meetingId: string, userId: string) {
    return prisma.meetingRequiredAttendee.create({
      data: { meetingId, userId, role: 'REGULAR_ATTENDEE' } as any,
    });
  }

  /** 创建 section 直入 DB，跳过 controller。 */
  async function dbCreateSection(meetingId: string, createdById: string, opts: {
    title?: string;
    order?: number;
    deletedAt?: Date | null;
  } = {}) {
    return prisma.meetingAgendaSection.create({
      data: {
        meetingId,
        title: opts.title ?? `t_section_${suffix()}`,
        order: opts.order ?? 0,
        createdById,
        deletedAt: opts.deletedAt ?? null,
      } as any,
    });
  }

  async function dbCreateItem(sectionId: string, createdById: string, opts: {
    title?: string;
    order?: number;
    deletedAt?: Date | null;
  } = {}) {
    return prisma.meetingAgendaItem.create({
      data: {
        sectionId,
        title: opts.title ?? `t_item_${suffix()}`,
        order: opts.order ?? 0,
        createdById,
        deletedAt: opts.deletedAt ?? null,
      } as any,
    });
  }

  async function dbCreateTask(itemId: string, assigneeUserId: string, assignedById: string, opts: {
    status?: 'PENDING' | 'UPLOADED' | 'CANCELLED';
    deletedAt?: Date | null;
  } = {}) {
    return prisma.meetingAgendaItemUploadTask.create({
      data: {
        agendaItemId: itemId,
        assigneeUserId,
        assignedById,
        createdById: assignedById,
        status: (opts.status as any) ?? 'PENDING',
        deletedAt: opts.deletedAt ?? null,
      } as any,
    });
  }

  beforeAll(async () => {
    process.env.NODE_ENV = 'test';
    // 议程附件 size 字段是 BigInt，main.ts 装的全局 toJSON patch 在 test 路径不生效
    // → 手动注入（与 backend/src/main.ts §BigInt 序列化补丁等价）
    if (!(BigInt.prototype as any).toJSON) {
      (BigInt.prototype as any).toJSON = function () {
        return this.toString();
      };
    }
    // 使用临时存储根目录避免污染开发数据
    process.env.MEETING_ATTACHMENT_STORAGE_ROOT = join(
      tmpdir(),
      `agenda-test-${process.pid}-${Date.now()}`,
    );
    app = await createTestApp();
    prisma = app.get<PrismaService>(PrismaService);
  });

  afterEach(async () => {
    jest.restoreAllMocks();
    await cleanupDatabase(prisma);
  });

  afterAll(async () => {
    // 清理临时存储目录
    try {
      const root = process.env.MEETING_ATTACHMENT_STORAGE_ROOT;
      if (root) await fs.rm(root, { recursive: true, force: true });
    } catch {
      // ignore
    }
    await app.close();
  });

  // ============================================================
  // 5.A 议程段 CRUD（5 用例）
  // ============================================================

  describe('5.A 议程段 CRUD', () => {
    it('5.A.1 manager 创建段 → 201 + DB', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ title: `t_section_${suffix()}` })
        .expect(201);

      expect(resp.body.section).toMatchObject({
        id: expect.any(String),
        title: expect.any(String),
        order: expect.any(Number),
      });
      const dbRow = await prisma.meetingAgendaSection.findUnique({
        where: { id: resp.body.section.id },
      });
      expect(dbRow).not.toBeNull();
      expect(dbRow!.createdById).toBe(manager.id);
    });

    it('5.A.2 manager 改段 title → 200 + DB updatedAt 更新', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const newTitle = `t_section_updated_${suffix()}`;

      const resp = await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections/${section.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ title: newTitle })
        .expect(200);

      expect(resp.body.section.title).toBe(newTitle);
      const dbRow = await prisma.meetingAgendaSection.findUnique({ where: { id: section.id } });
      expect(dbRow!.title).toBe(newTitle);
    });

    it('5.A.3 manager 删段 → 204 软删 + cascade 软删下属 items / tasks / attachments', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: assignee } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, assignee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item1 = await dbCreateItem(section.id, manager.id);
      const item2 = await dbCreateItem(section.id, manager.id);
      const task1 = await dbCreateTask(item1.id, assignee.id, manager.id);
      const attachment1 = await prisma.meetingAgendaItemAttachment.create({
        data: {
          agendaItemId: item1.id,
          uploadedById: manager.id,
          filename: 't_doc.pdf',
          mimeType: 'application/pdf',
          size: BigInt(1024),
          storagePath: '2026/05/abc.pdf',
          createdById: manager.id,
        } as any,
      });

      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections/${section.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(204);

      const sectionRow = await prisma.meetingAgendaSection.findUnique({ where: { id: section.id } });
      expect(sectionRow!.deletedAt).not.toBeNull();
      const itemRow = await prisma.meetingAgendaItem.findUnique({ where: { id: item1.id } });
      expect(itemRow!.deletedAt).not.toBeNull();
      const item2Row = await prisma.meetingAgendaItem.findUnique({ where: { id: item2.id } });
      expect(item2Row!.deletedAt).not.toBeNull();
      const taskRow = await prisma.meetingAgendaItemUploadTask.findUnique({ where: { id: task1.id } });
      expect(taskRow!.deletedAt).not.toBeNull();
      const attRow = await prisma.meetingAgendaItemAttachment.findUnique({ where: { id: attachment1.id } });
      expect(attRow!.deletedAt).not.toBeNull();

      // GET 不返回（默认 filter deletedAt:null）
      const treeResp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(200);
      expect(treeResp.body.sections).toHaveLength(0);
    });

    it('5.A.4 普通员工改段 → 403 + DB 未变', async () => {
      const { manager } = await setupManager();
      // Employee 没有 meeting:agenda:update 权限 → PermissionsGuard 403 (PERMISSION_DENIED)
      // 如果给了 update 权限但非 creator/manager → AccessService 抛 AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR
      // 本用例验证「无权限路径」（前端按权限点隐藏入口的兜底）
      const { token: empToken } = await setupEmployee();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id, { title: 't_section_orig' });

      const resp = await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections/${section.id}`)
        .set('Authorization', `Bearer ${empToken}`)
        .send({ title: 't_section_should_not_persist' })
        .expect(403);

      // PermissionsGuard 抛 ForbiddenException 时 code 可能在 body.code 或 body.message
      // 也可能被 AllExceptionsFilter 包成不同字段；统一断 body JSON 内含相关字符串。
      const bodyText = JSON.stringify(resp.body);
      expect(
        bodyText.includes('AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR') ||
          bodyText.includes('PERMISSION_DENIED') ||
          bodyText.includes('permission') ||
          bodyText.includes('Forbidden'),
      ).toBe(true);
      const dbRow = await prisma.meetingAgendaSection.findUnique({ where: { id: section.id } });
      expect(dbRow!.title).toBe('t_section_orig');
    });

    it('5.A.5 manager 批量 reorder 段 → 200 + DB order 按数组顺序落库', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const s1 = await dbCreateSection(meeting.id, manager.id, { order: 5 });
      const s2 = await dbCreateSection(meeting.id, manager.id, { order: 10 });
      const s3 = await dbCreateSection(meeting.id, manager.id, { order: 15 });

      await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections/reorder`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ ids: [s1.id, s2.id, s3.id] })
        .expect(200);

      const rows = await prisma.meetingAgendaSection.findMany({
        where: { meetingId: meeting.id },
        orderBy: { order: 'asc' },
      });
      const indexOf = (id: string) => rows.findIndex((r) => r.id === id);
      expect(rows.find((r) => r.id === s1.id)!.order).toBe(0);
      expect(rows.find((r) => r.id === s2.id)!.order).toBe(1);
      expect(rows.find((r) => r.id === s3.id)!.order).toBe(2);
      expect(indexOf(s1.id)).toBeLessThan(indexOf(s2.id));
      expect(indexOf(s2.id)).toBeLessThan(indexOf(s3.id));
    });
  });

  // ============================================================
  // 5.B 议程项 CRUD（5 用例）
  // ============================================================

  describe('5.B 议程项 CRUD', () => {
    it('5.B.1 manager 加项（presenter + code + 多栏 columnDescriptions）→ 201 + DB', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: presenter } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, presenter.id);
      const section = await dbCreateSection(meeting.id, manager.id);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-sections/${section.id}/items`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({
          title: `t_item_${suffix()}`,
          presenterUserId: presenter.id,
          code: 'A-1',
          timeMinutes: 15,
          columnDescriptions: ['FFAI 内容', 'Robotics 内容'],
        })
        .expect(201);

      expect(resp.body.item).toMatchObject({
        id: expect.any(String),
        title: expect.any(String),
        presenterUserId: presenter.id,
        code: 'A-1',
        timeMinutes: 15,
        columnDescriptions: ['FFAI 内容', 'Robotics 内容'],
      });
    });

    it('5.B.2 manager 改项字段 → 200 + DB', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      const resp = await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/agenda-sections/${section.id}/items/${item.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ title: 't_item_updated', timeMinutes: 30 })
        .expect(200);

      expect(resp.body.item.title).toBe('t_item_updated');
      expect(resp.body.item.timeMinutes).toBe(30);
    });

    it('5.B.3 manager 删项 → 204 软删 + cascade 软删 attachments / tasks', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: assignee } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, assignee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const task = await dbCreateTask(item.id, assignee.id, manager.id);
      const att = await prisma.meetingAgendaItemAttachment.create({
        data: {
          agendaItemId: item.id,
          uploadedById: manager.id,
          filename: 't_x.pdf',
          mimeType: 'application/pdf',
          size: BigInt(100),
          storagePath: '2026/05/x.pdf',
          createdById: manager.id,
        } as any,
      });

      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-sections/${section.id}/items/${item.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(204);

      expect((await prisma.meetingAgendaItem.findUnique({ where: { id: item.id } }))!.deletedAt).not.toBeNull();
      expect((await prisma.meetingAgendaItemUploadTask.findUnique({ where: { id: task.id } }))!.deletedAt).not.toBeNull();
      expect((await prisma.meetingAgendaItemAttachment.findUnique({ where: { id: att.id } }))!.deletedAt).not.toBeNull();
    });

    it('5.B.4 reorder items 按数组下标 → 200 + DB', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const i1 = await dbCreateItem(section.id, manager.id, { order: 7 });
      const i2 = await dbCreateItem(section.id, manager.id, { order: 8 });
      const i3 = await dbCreateItem(section.id, manager.id, { order: 9 });

      await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/agenda-sections/${section.id}/items/reorder`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ ids: [i3.id, i1.id, i2.id] })
        .expect(200);

      const rows = await prisma.meetingAgendaItem.findMany({
        where: { sectionId: section.id },
      });
      const byId = (id: string) => rows.find((r) => r.id === id)!;
      expect(byId(i3.id).order).toBe(0);
      expect(byId(i1.id).order).toBe(1);
      expect(byId(i2.id).order).toBe(2);
    });

    it('5.B.5 不存在的 presenterUserId → 404/400 + item 未落库', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const fakeId = randomUUID();

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-sections/${section.id}/items`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ title: 't_invalid_presenter', presenterUserId: fakeId });

      // 用户不存在；service 可能抛 MEETING_ATTENDANCE_005(404) 或 prisma P2025/P2003
      // 不应 201
      expect([400, 404, 500]).toContain(resp.status);
      const itemCount = await prisma.meetingAgendaItem.count({
        where: { sectionId: section.id, deletedAt: null },
      });
      expect(itemCount).toBe(0);
    });
  });

  // ============================================================
  // 5.C 上传任务 CRUD（6 用例）
  // ============================================================

  describe('5.C 上传任务 CRUD', () => {
    it('5.C.1 manager 批量分配 N 个 assignee → 201 + N 条 PENDING task + skippedExistingUserIds=[]', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: u1 } = await setupAssignee();
      const { user: u2 } = await setupAssignee();
      const { user: u3 } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      await addRequiredAttendee(meeting.id, u2.id);
      await addRequiredAttendee(meeting.id, u3.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/upload-tasks`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ assigneeUserIds: [u1.id, u2.id, u3.id] })
        .expect(201);

      expect(resp.body.skippedExistingUserIds).toEqual([]);
      expect(resp.body.created).toHaveLength(3);
      const rows = await prisma.meetingAgendaItemUploadTask.findMany({
        where: { agendaItemId: item.id, deletedAt: null },
      });
      expect(rows.length).toBe(3);
      expect(rows.every((r) => r.status === 'PENDING')).toBe(true);
    });

    it('5.C.2 GET tasks 列表 → 200 + 含 assignee 子对象', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: u1 } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      await dbCreateTask(item.id, u1.id, manager.id);

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/agenda-items/${item.id}/upload-tasks`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(200);

      expect(resp.body.items).toBeDefined();
      expect(resp.body.items.length).toBe(1);
      expect(resp.body.items[0]).toHaveProperty('status');
    });

    it('5.C.3 manager 撤销任务 status=CANCELLED → 200 + DB', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: u1 } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const task = await dbCreateTask(item.id, u1.id, manager.id);

      await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/agenda-items/${item.id}/upload-tasks/${task.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ status: 'CANCELLED' })
        .expect(200);

      const row = await prisma.meetingAgendaItemUploadTask.findUnique({ where: { id: task.id } });
      expect(row!.status).toBe('CANCELLED');
    });

    it('5.C.4 manager 软删任务 → 204 + DB deletedAt 非空', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: u1 } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const task = await dbCreateTask(item.id, u1.id, manager.id);

      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/upload-tasks/${task.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(204);

      const row = await prisma.meetingAgendaItemUploadTask.findUnique({ where: { id: task.id } });
      expect(row).not.toBeNull();
      expect(row!.deletedAt).not.toBeNull();
    });

    it('5.C.5 普通员工分配任务 → 403', async () => {
      const { manager } = await setupManager();
      const { token: empToken } = await setupEmployee();
      const { user: u1 } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/upload-tasks`)
        .set('Authorization', `Bearer ${empToken}`)
        .send({ assigneeUserIds: [u1.id] })
        .expect(403);
    });

    it('5.C.6 GET /my-upload-tasks 只返回当前用户的任务', async () => {
      const { manager } = await setupManager();
      const { user: u1, token: u1Token } = await setupAssignee();
      const { user: u2 } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      await addRequiredAttendee(meeting.id, u2.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      await dbCreateTask(item.id, u1.id, manager.id);
      await dbCreateTask(item.id, u2.id, manager.id);

      const resp = await request(app.getHttpServer())
        .get('/api/v1/meeting-attendance/my-upload-tasks')
        .set('Authorization', `Bearer ${u1Token}`)
        .expect(200);

      expect(resp.body.items).toBeDefined();
      expect(resp.body.items.length).toBe(1);
      expect(resp.body.items[0].assigneeUserId).toBe(u1.id);
    });
  });

  // ============================================================
  // 5.D 资料上传议程项级（8 用例）
  // ============================================================

  describe('5.D 议程项级资料上传', () => {
    it('5.D.1 manager any-path 上传 PDF → 201 + DB + storagePath', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', makePdfBuffer(2048), { filename: 't_doc.pdf', contentType: 'application/pdf' })
        .expect(201);

      expect(resp.body.attachment).toMatchObject({
        id: expect.any(String),
        filename: 't_doc.pdf',
        mimeType: 'application/pdf',
        storagePath: expect.any(String),
      });
      const row = await prisma.meetingAgendaItemAttachment.findUnique({ where: { id: resp.body.attachment.id } });
      expect(row).not.toBeNull();
    });

    it('5.D.2 assignee 上传 → 201 + task auto-flip UPLOADED + completedAt', async () => {
      const { manager } = await setupManager();
      const { user: assignee, token: asgToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, assignee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const task = await dbCreateTask(item.id, assignee.id, manager.id);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${asgToken}`)
        .attach('file', makePdfBuffer(1024), { filename: 't_asg.pdf', contentType: 'application/pdf' })
        .expect(201);

      expect(resp.body.taskUpdated).toBeDefined();
      expect(resp.body.taskUpdated.status).toBe('UPLOADED');
      expect(resp.body.taskUpdated.completedAt).toBeTruthy();
      const dbTask = await prisma.meetingAgendaItemUploadTask.findUnique({ where: { id: task.id } });
      expect(dbTask!.status).toBe('UPLOADED');
      expect(dbTask!.completedAt).not.toBeNull();
    });

    it('5.D.3 非 assignee + 非 manager 上传 → 403 UPLOAD_TASK_NOT_OWNED', async () => {
      const { manager } = await setupManager();
      const { user: other, token: otherToken } = await setupAssignee();
      const { user: assignee } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, other.id);
      await addRequiredAttendee(meeting.id, assignee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      await dbCreateTask(item.id, assignee.id, manager.id);
      // other 是参会人，但没 assigned task

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${otherToken}`)
        .attach('file', makePdfBuffer(512), { filename: 't_other.pdf', contentType: 'application/pdf' })
        .expect(403);

      expect(resp.body.code).toBe('UPLOAD_TASK_NOT_OWNED');
    });

    it('5.D.4 文件 > 200MB → 413 ATTACHMENT_TOO_LARGE', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      // multer fileSize limit 触发 LIMIT_FILE_SIZE → controller catch 转 413
      // 用 201MB（超 limit 1MB）；用 Buffer.alloc 一次性分配可能 OOM，改 stream/sparse
      const oversize = 201 * 1024 * 1024;
      if (!process.env.RUN_LARGE_FILE_TESTS) {
        // 默认 skip 大文件用例，CI 资源紧张
        // 即使如此也声明断言形态：传 1KB 但宣称 size > limit ——multer 按真实读取判，所以必须真传大文件
        console.warn('5.D.4 skipped: set RUN_LARGE_FILE_TESTS=1 to run 201MB upload test');
        return;
      }
      const big = Buffer.alloc(oversize, 0x20);
      PDF_HEADER.copy(big, 0);
      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', big, { filename: 't_big.pdf', contentType: 'application/pdf' });
      expect(resp.status).toBe(413);
      expect(resp.body.code).toBe('ATTACHMENT_TOO_LARGE');
    });

    it('5.D.5 MIME 不在白名单（.exe Content-Type=application/x-msdownload）→ 415 ATTACHMENT_MIME_NOT_ALLOWED', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', randomBytes(256), { filename: 't_payload.exe', contentType: 'application/x-msdownload' })
        .expect(415);

      expect(resp.body.code).toBe('ATTACHMENT_MIME_NOT_ALLOWED');
    });

    it('5.D.5b magic bytes 不匹配（.exe 伪装 application/pdf）→ 415 ATTACHMENT_MIME_MISMATCH', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      // PE 二进制（首两字节 MZ = `0x4d 0x5a`）
      const peHeader = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00]);
      const fakeBinary = Buffer.concat([peHeader, randomBytes(4096)]);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', fakeBinary, { filename: 't_evil.pdf', contentType: 'application/pdf' })
        .expect(415);

      expect(resp.body.code).toBe('ATTACHMENT_MIME_MISMATCH');
      const count = await prisma.meetingAgendaItemAttachment.count({ where: { agendaItemId: item.id, deletedAt: null } });
      expect(count).toBe(0);
    });

    it('5.D.6 200MB 边界（精确 200MB）→ 201（跳过除非 RUN_LARGE_FILE_TESTS）', async () => {
      if (!process.env.RUN_LARGE_FILE_TESTS) {
        console.warn('5.D.6 skipped: set RUN_LARGE_FILE_TESTS=1 to run 200MB boundary test');
        return;
      }
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      const exactBoundary = 200 * 1024 * 1024;
      const big = Buffer.alloc(exactBoundary, 0x20);
      PDF_HEADER.copy(big, 0);

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', big, { filename: 't_boundary.pdf', contentType: 'application/pdf' })
        .expect(201);

      expect(Number(resp.body.attachment.size)).toBe(exactBoundary);
    });

    it('5.D.7 上传到已软删的 item → 404 AGENDA_ITEM_NOT_FOUND', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id, { deletedAt: new Date() });

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', makePdfBuffer(256), { filename: 't.pdf', contentType: 'application/pdf' })
        .expect(404);

      expect(resp.body.code).toBe('AGENDA_ITEM_NOT_FOUND');
    });

    it('5.D.8 assignee 已 UPLOADED task 再次上传 → 上传仍允许（attachment 多条挂同 item）', async () => {
      // 注：根据 09-test-scenarios §5.D.8 描述意图是「409 UPLOAD_TASK_ALREADY_UPLOADED」
      // 但 controller/service 实测：assignee 走 `:assigned` 路径时若已无 PENDING task 则
      // throw uploadTaskNotOwned (403)，而不是 409。这是因为 service 把"已上传"等价于
      // "没有 owned PENDING task"。本断言记录当前真实行为。
      const { manager } = await setupManager();
      const { user: assignee, token: asgToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, assignee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      // 已 UPLOADED task（无 PENDING）
      await dbCreateTask(item.id, assignee.id, manager.id, { status: 'UPLOADED' });

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${asgToken}`)
        .attach('file', makePdfBuffer(128), { filename: 't_2nd.pdf', contentType: 'application/pdf' });

      // 当前实现：assignee 路径找不到 PENDING task → 403 UPLOAD_TASK_NOT_OWNED
      expect([403, 409]).toContain(resp.status);
      if (resp.status === 403) {
        expect(resp.body.code).toBe('UPLOAD_TASK_NOT_OWNED');
      } else {
        expect(resp.body.code).toBe('UPLOAD_TASK_ALREADY_UPLOADED');
      }
    });
  });

  // ============================================================
  // 5.E 资料上传会议级（3 用例）
  // ============================================================

  describe('5.E 会议级资料上传', () => {
    it('5.E.1 manager 上传 category=MINUTES → 201 + DB', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });

      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/meetings/${meeting.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .field('category', 'MINUTES')
        .attach('file', makePdfBuffer(512), { filename: 't_minutes.pdf', contentType: 'application/pdf' })
        .expect(201);

      expect(resp.body.attachment.category).toBe('MINUTES');
      const row = await prisma.meetingAttachment.findUnique({ where: { id: resp.body.attachment.id } });
      expect(row).not.toBeNull();
      expect(row!.category).toBe('MINUTES');
    });

    it('5.E.2 普通员工会议级上传 → 403', async () => {
      const { manager } = await setupManager();
      const { user: emp, token: empToken } = await setupEmployee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, emp.id);

      await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/meetings/${meeting.id}/attachments`)
        .set('Authorization', `Bearer ${empToken}`)
        .field('category', 'MATERIAL')
        .attach('file', makePdfBuffer(256), { filename: 't_emp.pdf', contentType: 'application/pdf' })
        .expect(403);
    });

    it('5.E.3 会议级 GET 列表 → 200 + 含 category', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      // 直接 DB 写一条避开上传
      await prisma.meetingAttachment.create({
        data: {
          meetingId: meeting.id,
          uploadedById: manager.id,
          filename: 't_minutes.pdf',
          mimeType: 'application/pdf',
          size: BigInt(1024),
          storagePath: '2026/05/m.pdf',
          createdById: manager.id,
          category: 'MINUTES',
        } as any,
      });

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/meetings/${meeting.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(200);

      expect(resp.body.items.length).toBeGreaterThanOrEqual(1);
      expect(resp.body.items[0]).toHaveProperty('category');
    });
  });

  // ============================================================
  // 5.F 资料下载（4 用例）
  // ============================================================

  describe('5.F 资料下载', () => {
    async function uploadAgendaItemAttachment(opts: {
      itemId: string;
      mgrToken: string;
      filename: string;
    }) {
      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${opts.itemId}/attachments`)
        .set('Authorization', `Bearer ${opts.mgrToken}`)
        .attach('file', makePdfBuffer(2048), { filename: opts.filename, contentType: 'application/pdf' })
        .expect(201);
      return resp.body.attachment;
    }

    it('5.F.1 参会人下载议程项附件 → 200 + Content-Disposition + Content-Type', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: attendee, token: attToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, attendee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const att = await uploadAgendaItemAttachment({
        itemId: item.id,
        mgrToken,
        filename: 't_doc.pdf',
      });

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/attachments/agenda-item/${att.id}/download`)
        .set('Authorization', `Bearer ${attToken}`);
      expect(resp.status).toBe(200);
      const disp = resp.headers['content-disposition'];
      expect(disp).toMatch(/attachment; filename="t_doc.pdf"/);
      expect(disp).toMatch(/filename\*=UTF-8''/);
      expect(resp.headers['content-type']).toMatch(/application\/pdf/);
    });

    it('5.F.1a RFC 5987：中文文件名 → Content-Disposition 同时含 ascii fallback + percent-encoded', async () => {
      // 注：supertest multipart 把 filename 当 latin-1 处理，无法发送 UTF-8 multipart filename。
      // 改用「先上传 ASCII 文件 → DB 直接改 filename 为中文 → 下载验证 header」
      const { manager, mgrToken } = await setupManager();
      const { user: attendee, token: attToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, attendee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const att = await uploadAgendaItemAttachment({
        itemId: item.id,
        mgrToken,
        filename: 't_ascii.pdf',
      });
      // 改 DB filename → 真实中文
      const cnName = 't_中文文档_季度报告.pdf';
      await prisma.meetingAgendaItemAttachment.update({
        where: { id: att.id },
        data: { filename: cnName },
      });

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/attachments/agenda-item/${att.id}/download`)
        .set('Authorization', `Bearer ${attToken}`);
      expect(resp.status).toBe(200);
      const disp = resp.headers['content-disposition'];
      // ascii fallback：非 ASCII 字符被替换为 `_`
      expect(disp).toMatch(/filename="t____________________\.pdf"|filename="t_[_A-Za-z0-9.]+\.pdf"/);
      // percent-encoded UTF-8 部分包含中文 percent encoding（"中" = %E4%B8%AD, "文" = %E6%96%87）
      expect(disp).toMatch(/filename\*=UTF-8''/);
      const percentPart = disp.split("filename*=UTF-8''")[1];
      expect(percentPart).toMatch(/%E4%B8%AD%E6%96%87/); // "中文" 的 percent-encoded
    });

    it('5.F.2 非参会人下载 → 403', async () => {
      const { manager, mgrToken } = await setupManager();
      const { token: nonAttToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const att = await uploadAgendaItemAttachment({
        itemId: item.id,
        mgrToken,
        filename: 't_doc.pdf',
      });

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/attachments/agenda-item/${att.id}/download`)
        .set('Authorization', `Bearer ${nonAttToken}`)
        .expect(403);
      expect(resp.body.code).toBe('MEETING_ATTENDANCE_011');
    });

    it('5.F.3 软删 attachment 下载 → 404', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: attendee, token: attToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, attendee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const att = await uploadAgendaItemAttachment({
        itemId: item.id,
        mgrToken,
        filename: 't_doc.pdf',
      });
      // 软删
      await prisma.meetingAgendaItemAttachment.update({
        where: { id: att.id },
        data: { deletedAt: new Date() },
      });

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/attachments/agenda-item/${att.id}/download`)
        .set('Authorization', `Bearer ${attToken}`)
        .expect(404);
      expect(resp.body.code).toBe('ATTACHMENT_NOT_FOUND');
    });
  });

  // ============================================================
  // 5.G 资料删除（3 用例）
  // ============================================================

  describe('5.G 资料删除', () => {
    async function uploadAndGetAtt(opts: { itemId: string; token: string; filename?: string }) {
      const resp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${opts.itemId}/attachments`)
        .set('Authorization', `Bearer ${opts.token}`)
        .attach('file', makePdfBuffer(512), {
          filename: opts.filename ?? 't_x.pdf',
          contentType: 'application/pdf',
        })
        .expect(201);
      return resp.body.attachment;
    }

    it('5.G.1 uploader 自己软删 → 204 + DB deletedAt 非空 + 列表不返回', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const att = await uploadAndGetAtt({ itemId: item.id, token: mgrToken });

      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments/${att.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(204);

      const row = await prisma.meetingAgendaItemAttachment.findUnique({ where: { id: att.id } });
      expect(row).not.toBeNull();
      expect(row!.deletedAt).not.toBeNull();

      const list = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(200);
      expect(list.body.items.every((it: any) => it.id !== att.id)).toBe(true);
    });

    it('5.G.2 非 uploader 非 manager 删 → 403 ATTACHMENT_DELETE_FORBIDDEN', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: other, token: otherToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, other.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      const att = await uploadAndGetAtt({ itemId: item.id, token: mgrToken });

      const resp = await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments/${att.id}`)
        .set('Authorization', `Bearer ${otherToken}`)
        .expect(403);
      expect(resp.body.code).toBe('ATTACHMENT_DELETE_FORBIDDEN');
    });

    it('5.G.3 manager 强删别人上传的 → 204', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: uploader, token: uploaderToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, uploader.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      // uploader 先 assigned task 上传
      await dbCreateTask(item.id, uploader.id, manager.id);
      const upResp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${uploaderToken}`)
        .attach('file', makePdfBuffer(256), { filename: 't_up.pdf', contentType: 'application/pdf' })
        .expect(201);

      // manager 删
      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments/${upResp.body.attachment.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(204);

      const row = await prisma.meetingAgendaItemAttachment.findUnique({
        where: { id: upResp.body.attachment.id },
      });
      expect(row!.deletedAt).not.toBeNull();
    });
  });

  // ============================================================
  // 5.H 边界 / 权限 / 软删（4 用例）
  // ============================================================

  describe('5.H 边界 / 权限 / 软删', () => {
    it('5.H.1 manager 会议 COMPLETED 后仍可改议程 → 200 + audit log 写入', async () => {
      const { manager, mgrToken } = await setupManager();
      const meeting = await createMeeting({ creatorId: manager.id });
      // 把状态改为 COMPLETED
      await prisma.meeting.update({
        where: { id: meeting.id },
        data: { status: 'COMPLETED' },
      });
      const section = await dbCreateSection(meeting.id, manager.id, { title: 't_pre_completed' });

      await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections/${section.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ title: 't_post_completed' })
        .expect(200);

      const dbRow = await prisma.meetingAgendaSection.findUnique({ where: { id: section.id } });
      expect(dbRow!.title).toBe('t_post_completed');

      // audit log
      const logs = await prisma.meetingAttendanceAuditLog.findMany({
        where: { action: 'AGENDA_SECTION_UPDATED', resourceId: section.id },
        orderBy: { createdAt: 'desc' },
      });
      expect(logs.length).toBeGreaterThanOrEqual(1);
    });

    it('5.H.2 外部访客 GET agenda → 403（非系统用户/非参会人无法访问）', async () => {
      // 外部访客 = MeetingExternalAttendee（无系统用户/token）；签到走 guest-checkin，
      // 议程 GET 路由要求 JWT。我们用一个非参会人 token 验证最简等价行为。
      const { manager } = await setupManager();
      const { token: nonAttToken } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      const section = await dbCreateSection(meeting.id, manager.id);
      await dbCreateItem(section.id, manager.id);

      const resp = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda`)
        .set('Authorization', `Bearer ${nonAttToken}`);
      expect(resp.status).toBe(403);
      expect(resp.body.code).toBe('MEETING_ATTENDANCE_011');
    });

    it('5.H.3 同议程项多 assignee 各自上传 → 互可见 + 仅本人可删', async () => {
      const { manager } = await setupManager();
      const { user: u1, token: u1Token } = await setupAssignee();
      const { user: u2, token: u2Token } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, u1.id);
      await addRequiredAttendee(meeting.id, u2.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);
      await dbCreateTask(item.id, u1.id, manager.id);
      await dbCreateTask(item.id, u2.id, manager.id);

      const r1 = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${u1Token}`)
        .attach('file', makePdfBuffer(128), { filename: 't_u1.pdf', contentType: 'application/pdf' })
        .expect(201);
      const r2 = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${u2Token}`)
        .attach('file', makePdfBuffer(128), { filename: 't_u2.pdf', contentType: 'application/pdf' })
        .expect(201);

      // u1 GET list 看到两条
      const listU1 = await request(app.getHttpServer())
        .get(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${u1Token}`)
        .expect(200);
      expect(listU1.body.items.length).toBe(2);

      // u1 删 u2 的 → 403
      const deny = await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments/${r2.body.attachment.id}`)
        .set('Authorization', `Bearer ${u1Token}`)
        .expect(403);
      expect(deny.body.code).toBe('ATTACHMENT_DELETE_FORBIDDEN');

      // u1 删自己的 → 204
      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments/${r1.body.attachment.id}`)
        .set('Authorization', `Bearer ${u1Token}`)
        .expect(204);
    });

    it('5.H.4 audit log：议程改 / 任务分配 / 附件上传 / 附件删除 四条均落库', async () => {
      const { manager, mgrToken } = await setupManager();
      const { user: assignee } = await setupAssignee();
      const meeting = await createMeeting({ creatorId: manager.id });
      await addRequiredAttendee(meeting.id, assignee.id);
      const section = await dbCreateSection(meeting.id, manager.id);
      const item = await dbCreateItem(section.id, manager.id);

      // (1) 议程改
      await request(app.getHttpServer())
        .patch(`/api/v1/meeting-attendance/meetings/${meeting.id}/agenda/sections/${section.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ title: 't_section_audit_updated' })
        .expect(200);

      // (2) 任务分配
      await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/upload-tasks`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .send({ assigneeUserIds: [assignee.id] })
        .expect(201);

      // (3) 附件上传
      const upResp = await request(app.getHttpServer())
        .post(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .attach('file', makePdfBuffer(128), { filename: 't_audit.pdf', contentType: 'application/pdf' })
        .expect(201);

      // (4) 附件删除
      await request(app.getHttpServer())
        .delete(`/api/v1/meeting-attendance/agenda-items/${item.id}/attachments/${upResp.body.attachment.id}`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(204);

      // 四条审计应落库
      const actions = ['AGENDA_SECTION_UPDATED', 'UPLOAD_TASK_ASSIGNED', 'ATTACHMENT_UPLOADED', 'ATTACHMENT_DELETED'];
      for (const action of actions) {
        const logs = await prisma.meetingAttendanceAuditLog.findMany({
          where: { action, userId: manager.id },
        });
        expect(logs.length).toBeGreaterThanOrEqual(1);
      }
    });
  });
});
