diff --git a/.env.production b/.env.production index 6a6a7bb4..0b91f07b 100644 --- a/.env.production +++ b/.env.production @@ -1,5 +1,6 @@ VITE_API_URL=https://physics-api-cn.turtlesim.com VITE_STATIC_URL=https://physics-lab.oss-cn-hongkong.aliyuncs.com +VITE_DEMOCRACY_WALL_MODE=off # build之后API请求发送的路径 # The path where API requests are sent after build diff --git a/docs/democracy-wall-backend-contract.md b/docs/democracy-wall-backend-contract.md new file mode 100644 index 00000000..937d75b2 --- /dev/null +++ b/docs/democracy-wall-backend-contract.md @@ -0,0 +1,271 @@ +# 民主墙服务端 v1 上线契约 + +前端生产入口由 `VITE_DEMOCRACY_WALL_MODE` 控制: + +- `off`:隐藏入口并阻止路由访问;生产默认值。 +- `read-only`:允许读取公开事务,关闭发布和管理操作。 +- `full`:启用专用 `/Democracy/*` 接口。 + +只有服务端完成本文档的隐私、权限与审计要求后,生产环境才能设置为 `full`。 + +## 数据模型 + +### AnonymousProfile + +| 字段 | 约束 | +| --------------- | -------------------------------------------- | +| UserID | 主键;真实账号 ID;永不进入公开响应 | +| Alias | 规范化后全局唯一;2–16 个 Unicode 字符 | +| AliasNormalized | 唯一索引;使用服务端统一大小写与兼容字符规则 | +| CanPublish | 服务端发布总开关 | +| ModifiedAt | UTC 时间 | +| Version | 乐观并发版本 | + +改名必须在事务中完成唯一性检查。旧事务公开响应应读取当前化名;审计记录保留当时化名。 + +### DemocracyMatter + +| 字段 | 约束 | +| --------------------- | ------------------------------------------- | +| MatterID | 主键 | +| CreatorUserID | 真实账号 ID;仅审计权限可读 | +| PublicAlias | 可选快照;公开展示优先使用当前 profile | +| Kind | `Public` 或 `Oversight` | +| Mode | `Formal` 或 `Suggestion` | +| Participation | `Consultation`(意见征集)或 `Vote`(投票) | +| Stage | 见状态机 | +| Subject | 4–80 字符 | +| Description | 10–2000 字符 | +| CreatedAt / UpdatedAt | UTC 时间 | +| Version | 乐观并发版本 | + +### DemocracyAuditLog + +记录发布、修改、改名、追溯、撤销/恢复发布权、阶段迁移、投票创建和决议归档。至少包含操作者、目标、理由、前后值、时间、请求 ID 与来源 IP 的安全摘要。审计日志只能追加。 + +### DemocracyVotePlan + +包含 `Options`、`Multiple`、`StartAt` 与 `FinishAt`。投票沿用所属事务的上下文,不再单独保存题目;`Options` 为 2–20 项,每项规范化后 1–80 字符且不得重复。发布者可以在质询阶段设置或修改计划;开始时间到达后,由服务端调度器原子创建现有匿名投票并将事务迁移到 `Voting`。投票开始后正文、选项和时间冻结。 + +### DemocracyContribution + +| 字段 | 约束 | +| ------------------------- | ---------------------------------------------------- | +| ContributionID / MatterID | 主键与所属事务 | +| AuthorUserID | 真实账号 ID;仅追溯与审计权限可读 | +| AuthorAlias | 当前民主墙虚拟名;公开展示 | +| AuthorPosition | 服务端根据当前身份与权限标签生成的公开职位;公开展示 | +| Content | 建议或质询正文;1–400 字符 | +| ClientRequestID | 用户范围内唯一,用于幂等 | +| CreatedAt | UTC 时间 | + +公开职位由服务端生成,客户端无权指定或覆盖。实名账号、社区昵称、头像及其他可关联身份的字段不得进入公开响应。 + +### DemocracyInvestigationInvite + +| 字段 | 约束 | +| ----------------------------- | ----------------------------------- | +| InviteID | 主键 | +| MatterID | 必须关联 `Oversight` 事务 | +| InviterUserID / InviteeUserID | 私有账号 ID,不进入公开事务响应 | +| InviteeNickname | 当前公开昵称快照 | +| Status | `Pending`、`Accepted` 或 `Declined` | +| CreatedAt / RespondedAt | UTC 时间 | + +重复发送未处理邀请应返回原邀请。受邀人接受前只显示“等待接受”,不得计入已加入成员。 + +### DemocracyReport + +| 字段 | 约束 | +| --------------------- | ---------------------------------------------------- | +| ReportID | 主键 | +| MatterID | 关联公开事务 | +| ReporterUserID | 仅审核权限可读 | +| Category | `Harassment`、`Privacy`、`Misinformation` 或 `Other` | +| Details | 10–500 字符 | +| Status | `Pending`、`Handled` 或 `Rejected` | +| ClientRequestID | 用户范围内唯一,用于幂等 | +| CreatedAt / HandledAt | UTC 时间 | + +举报需按账号和事务限流;重复举报返回原有结果。紧急隐藏只能改变公开可见性,原文和全部处理记录继续保留在受限存储中。 + +## 权限 + +- 已登录且状态正常的账号:可提交建议与质询。 +- `Oldtimer`:可匿名提议。 +- `Editor`、`Administrator`:可发起正式事务。 +- 独立的 `DemocracyDeveloperAdmin`:可追溯身份和撤销发布权。 +- `DemocracyDeveloperAdmin`:可为管理监察事务邀请调查团成员。 +- 独立的 `DemocracyClerk`:可迁移阶段、创建投票和归档。 +- 所有权限均由服务端根据当前账号状态判断。 +- 被全站封禁或 `CanPublish=false` 的账号无法通过任何民主墙写接口。 +- `DemocracyDeveloperAdmin` 的账号警告与封禁权限只取决于操作者能力,不受目标账号的 Oldtimer、认证编辑、管理员等身份标签影响;认证管理员同样没有处置豁免。 + +## 公开响应隐私约束 + +`QueryMatters`、`GetMatter`、`SubmitMatter` 和 `UpdateMatter` 的响应必须满足: + +- `Summary.User.ID` 为空。 +- `Summary.User.Nickname` 仅包含匿名化名。 +- 头像、签名、装饰、身份标签及可关联账号的字段清空。 +- 响应、CDN 缓存键、通知和客户端日志中不出现真实用户 ID。 +- `GetMatter` 对普通用户与未登录用户返回同一个公开 DTO。 + +身份追溯仅通过 `TracePublisher` 的授权审计流程进行。请求必须包含具体理由,每次读取都写入审计日志。 + +## API + +所有接口使用 POST、现有 token/authCode 认证和统一 `Result` 包装。 + +### `/Democracy/GetContext` + +返回 API 版本、匿名 profile 和五项细分权限:`CanContribute`、`CanSuggest`、`CanInitiate`、`CanModerate`、`CanTransition`。未登录用户返回 `Profile=null` 且全部写权限为 `false`;被禁发账号的全部写权限同样为 `false`。 + +### `/Democracy/QueryMatters` + +输入 `Scope`, `Skip`, `Take`。只返回公开 DTO。服务端固定排序并限制 `Take <= 48`。 + +### `/Democracy/GetMatter` + +输入 `MatterID`。只返回公开 DTO;已删除、待内部审核或无权公开的事务使用一致的不可见响应。 + +### `/Democracy/SubmitMatter` + +输入: + +- `ClientRequestID`:用户范围内唯一,服务端保存幂等结果。 +- `Alias`:与事务创建在同一事务中完成设置或改名。 +- `Kind`, `Mode`, `Participation`, `Subject`, `Description`,以及投票事务所需的 `VotePlan`。 + +服务端检查身份、角色、发布资格、速率限制、化名唯一性、投票时间和文本规则。客户端提交的用户 ID、标签和阶段一律忽略。匿名提议在公开模型中不区分公共事务与管理监察,传入的 `Kind` 统一按 `Public` 兼容处理,`Participation` 强制归一化为 `Consultation` 并拒绝 `VotePlan`;具备资格的 Oldtimer 与正式事务均进入 `Questions`。正式事务可选择 `Consultation` 或 `Vote`,选择 `Vote` 时必须提交有效投票计划。 + +### `/Democracy/UpdateMatter` + +输入 `MatterID`、`ExpectedRevision`、`Participation`、`Subject`、`Description` 与可选 `VotePlan`。服务端从认证信息判断当前账号是否为原发布者,禁止接收客户端发布者 ID。发布者在 `PendingReview` 或 `Questions` 且服务器时间早于原投票开始时间时可反复修改;到达开始时间即禁止修改,即使阶段迁移任务尚未运行也必须拒绝。仅 `CanDeveloperManage` 对应的特定开发权限可在投票开始后或历史阶段修订标题和正文,投票选项、时间、参与方式及已有票数保持不变。服务端须在同一事务内校验权限、原开始时间及 `ExpectedRevision`,防止并发覆盖或提交新时间绕过限制。匿名提议始终保持 `Consultation`;正式事务在投票开始前可调整参与方式。修改应写入审计日志,但公开响应继续隐藏真实账号。此规则仍需服务端实现并联调,前端预检只改善交互体验。 + +### `/Democracy/QueryContributions` + +输入 `MatterID`、`Skip`、`Take`。返回按创建时间稳定排序的建议与质询,单页限制 `Take <= 100`。每项公开响应仅包含 `ID`、`AuthorAlias`、`AuthorPosition`、`Content` 和 `CreatedAt`;历史事务同样可读,但不提供写入口。 + +### `/Democracy/SubmitContribution` + +输入 `MatterID`、`Content`、`ClientRequestID`。服务端核验事务可见性、当前阶段、账号状态、速率限制与幂等键,从认证账号关联的民主墙 profile 取得虚拟名,并根据当前权限与身份标签生成公开职位。客户端提交的虚拟名、职位、用户 ID 和时间字段一律忽略。响应返回与查询接口相同的公开 DTO。 + +### `/Democracy/ModeratePublisher` + +输入 `MatterID`, `Revoked`, `Reason`。服务端从 MatterID 解析真实用户,禁止客户端传入目标 UserID。调用者必须具备 `DemocracyDeveloperAdmin`。响应返回独立的 `Revoked` 与合并账号状态后的 `CanPublish`,避免账号封禁状态覆盖民主墙发布权设置。 + +### `/Democracy/ModerateAccount` + +输入 `MatterID`、`Action`(`Warn`、`Ban`、`Unban`)和 `Reason`。服务端先核验操作者的账号处置能力,再从事务私有字段解析目标实名账号。目标账号的认证标签与管理员身份不提供豁免。警告应生成实名通知;封禁与解封调用全站账号状态服务,并写入追加式审计记录。响应同时返回 `Banned` 与合并民主墙发布权设置后的 `CanPublish`。 + +### `/Democracy/DeleteMatter` + +输入 `MatterID` 与 `Reason`。执行公开下架并阻止详情、评论和投票继续写入,同时保留私有原文、审计记录、举报记录和申诉所需证据。公开查询统一返回不可见响应,避免泄露删除原因或内部状态。 + +### `/Democracy/TracePublisher` + +输入 `MatterID`, `Reason`。仅向 `DemocracyDeveloperAdmin` 返回真实 `UserID`、当前化名、主账号 `Banned`、民主墙独立状态 `PublishingRevoked` 和最终有效状态 `CanPublish`。禁止缓存响应;每次调用必须追加审计记录。 + +### `/Democracy/QueryAudit` + +输入 `MatterID`。仅 `DemocracyDeveloperAdmin` 可读取。返回公开操作名、操作者展示名、理由和时间;来源 IP、安全摘要及内部账号标识保留在更高权限日志中。 + +### `/Democracy/QueryInvestigationTeam` + +输入 `MatterID`。管理人员可读取待处理、已接受和已拒绝邀请;普通用户仅可读取已接受成员。响应包含 `InviteID`、公开昵称、状态与时间,不包含可关联账号的私有字段。管理界面的授权响应额外返回 `UserID`。 + +### `/Democracy/InviteInvestigator` + +输入 `MatterID`、`UserID` 与 `Reason`。调用者必须具备 `DemocracyDeveloperAdmin`,事务必须为 `Oversight`。服务端创建 `Pending` 邀请、写入 `InviteInvestigator` 审计记录并向受邀账号发送实名通知;相同事务与受邀人的未处理邀请保持幂等。受邀人接受后方可获得该事务的调查团权限。 + +### 邀请通知与受邀人响应 + +创建邀请和写入通知发送队列必须在同一数据库事务内提交。通知经现有社区消息系统发给受邀账号,Fields 包含 `DemocracyEvent: InvestigationInvite`、`DemocracyMatterID`、`DemocracyInviteID`。消息中不携带发布者实名信息。前端将其定位到 `/d/matter/:id?invitation=:inviteId`,在详情展示接受或拒绝操作。 + +- `/Democracy/GetInvestigationInvite`:输入 `MatterID`、`InviteID`,仅受邀账号可以读取;返回 `ID`、`Status`、`CreatedAt`。其他账号统一返回不可访问,避免通过邀请 ID 推断身份。 +- `/Democracy/RespondInvestigationInvite`:输入 `MatterID`、`InviteID`、`Decision`(`Accepted` 或 `Declined`)。账号身份从认证头取得,禁止客户端代指定。检查账号封禁、邀请有效性、事务可参与状态,并原子迁移 `Pending` 状态。 +- 相同决定重复提交返回当前状态;相反决定返回 409;失效或已删除事务返回 410。接受后授予该事务范围内的调查团权限,拒绝不授予权限。 +- 响应、成员权限写入、`RespondInvestigationInvite` 审计及给邀请人的结果通知必须原子提交;通知投递由可重试队列完成。结果通知用 `DemocracyEvent: InvestigationInviteResponded`、`DemocracyMatterID`,详情中管理人员可刷新成员状态。 +- 全局只读模式由服务端一并执行;前端开关用于同步交互。历史事务不得接受新邀请或继续质询。 + +当前仓库实现前端调用、通知定位与本地演示响应;上述服务端事务、消息投递与真实账号授权需在社区服务端实现并联调。 + +### `/Democracy/ReportMatter` + +输入 `MatterID`, `Category`, `Details`, `ClientRequestID`。登录用户可调用;服务端执行幂等、限流和可见性检查。举报人的账号信息不会进入事务公开响应或通知正文。 + +### `/Democracy/TransitionMatter` + +输入 `MatterID`, `ExpectedStage`, `TargetStage`, `Reason`。使用乐观并发,非法迁移返回冲突。 + +### `/Democracy/CreateVote` + +输入 `MatterID`, `ExpectedStage`, `Options`, `Multiple`, `FinishDate`, `Reason`。服务端先确认事务的 `Participation=Vote`,再从事务中取得上下文,在同一数据库事务中创建绑定投票并完成 `Questions -> Voting` 迁移,任一步失败都回滚。意见征集与匿名提议调用该接口时返回阶段或类型冲突。 + +该接口保留给 `DemocracyClerk` 手动处理特殊事务。发布者设置的 `VotePlan` 由服务端调度任务执行同等的原子创建与迁移;页面计时器仅负责及时刷新展示,不能承担状态迁移权威。 + +## 通知精确跳转 + +民主墙通知沿用现有消息系统,并在 `Message.Fields` 中提供: + +- `DemocracyMatterID`:24 位事务 ID。 +- `DemocracyStage`:通知生成时的事务阶段。 +- `DemocracyEvent`:`QuestionReply`、`VotingOpened`、`VotingClosing`、`InvestigationInvite`、`Resolved`、`Archived`、`ReportHandled`。 + +`Voting` 通知进入详情投票页;`Resolved` 和 `Archived` 通知进入历史只读详情。通知字段、正文和模板不得包含真实发布者 ID。 + +## 治理与限流 + +- 匿名提议:每账号每 24 小时最多 3 次;失败请求不占额度。 +- 建议收集与质询:沿用社区评论限流,并增加单事务突发限制。 +- 举报:同一账号对同一事务保留一个待处理举报,可修改补充说明。 +- 追溯身份:要求不少于 6 个字符的理由,禁止批量接口。 +- 隐藏内容:仅 `DemocracyDeveloperAdmin` 可执行;必须填写理由并通知事务发起人。 +- 所有写接口拒绝客户端传入操作者、发布者、阶段、审计时间或公开身份字段。 + +## 状态机 + +允许的迁移: + +- `PendingReview -> Questions | Rejected` +- 投票事务:`Questions -> Voting | Rejected` +- 意见征集:`Questions -> Resolved | Rejected` +- `Voting -> Resolved` +- `Resolved -> Archived` + +投票截止只关闭投票并公开票数或比例,不依据多数票、阈值或选项名称自动判定通过、否决或形成决议。`Voting -> Resolved` 必须由具备权限的人员确认并写入审计理由。 + +禁止跳级、回退和覆盖历史决议。条例修订应创建新事务,并引用受影响的旧决议。 + +## 上线验收 + +- 并发注册相同化名时只允许一个成功。 +- 重放相同 `ClientRequestID` 不生成重复事务。 +- 前端改角色、本地存储或请求体无法绕过权限。 +- 普通用户无法从列表、详情、评论、通知、缓存和错误日志获得真实身份。 +- 建议与质询只展示民主墙虚拟名和服务端生成的公开职位,伪造客户端身份字段不会改变响应。 +- 追溯、禁发、恢复、阶段迁移均生成审计记录。 +- 管理监察调查团邀请保持幂等,受邀人接受前不获得调查权限,邀请操作生成审计和实名通知。 +- 删除事务执行公开下架并保留证据;账号警告、封禁、解封均产生审计和实名通知,目标认证身份不影响处置结果。 +- 举报幂等、限流、处理通知和紧急隐藏流程通过验收。 +- 投票创建与 `Questions -> Voting` 阶段迁移具备事务原子性。 +- 原发布者可在质询阶段修改内容和投票计划;其他用户无法伪造发布者身份,投票开始后修改被拒绝。 +- 匿名提议只能进入意见征集,篡改请求为 `Vote` 或携带 `VotePlan` 均无法创建投票。 +- 发布者设置的 `StartAt` 到达后,即使没有用户打开页面,服务端也会自动创建并展示投票。 +- 通知能精确进入质询、投票或历史只读详情,且不泄露真实身份。 +- Chromium、Firefox、WebKit、移动端视口和 GitHub Pages 子目录部署全部通过。 +- 灰度期间可从 `full` 回退到 `read-only` 或 `off`,公开历史记录保持可读。 + +## 开发专项权限与质询处置(2026-09 更新) + +- 服务端通过 GetContext.Permissions.CanDeveloperManage 明确授权,默认 false。必须映射到单独授予的 DemocracyDeveloperAdmin 能力,普通管理员、认证编辑、开发者身份标签和 CanModerate/CanTransition 均不能自动代替该授权。禁用或封禁操作者应撤销此能力。 +- 管理与审计界面仅对该能力开放。移除手动事务阶段选择、决议确认及手动创建投票;内容处置保留直接删除(带确认)。发布者设定的自动投票计划继续由服务端执行,历史事务状态仍由服务端管理。 +- TracePublisher 和 TraceContribution 均带 Notify: false,默认不通知被追溯者。仅权限者获得真实账号,响应 Cache-Control: no-store;每次读取追加审计。禁用普通缓存、公共日志和通知中的实名泄露。 +- ModerateAccount 增加 BanDays:Ban 操作必须为正整数,可自行填写任意可表示的天数;零、负数、小数和超出时间表示范围的值返回 422。服务端按当前 UTC 时间计算 BannedUntil,到期恢复,返回 Banned、BannedUntil、CanPublish。目标根据 MatterID 查找,不要求客户端先追溯,不接受客户端指定 UserID。目标身份不提供处置豁免。这里封禁社区主账号。 +- TraceContribution 输入 MatterID、ContributionID、Reason、Notify: false,必须校验质询属于事务;只向权限者返回与 TracePublisher 相同的私有身份字段。 +- ModerateContributionAuthor 输入 MatterID、ContributionID、Action、Reason、BanDays,服务端从质询作者关联定位主账号,其余行为与 ModerateAccount 一致。公开昵称、职位和客户端提交的 UserID 均不能作为定位依据。 +- DiscloseContribution 是独立写操作,输入 MatterID、ContributionID、Reason,必须具备 CanDeveloperManage。界面先私密追溯,再二次确认披露;该接口也必须再次鉴权,不能将追溯自动升级为公开。服务器提交披露记录和审计后返回更新后的质询。 +- 公开质询仅在主动披露成功后附加 Disclosure: { UserID, Reason, DisclosedAt }。默认完全省略此字段。只公开账号 ID 和披露理由、时间,不公开邮箱、IP、令牌或其他私有资料。服务端记录披露者、关联质询及依据;一般访客始终无法调用私密追溯接口。 +- 前端不得通过自行调用社区消息接口给被追溯者发送通知;封禁相关通知按社区账号处置规则由服务端发送。 +- 演示管理员使用 demo=1&admin=1;专项开发权限演示额外使用 developer=1。该参数仅开发环境有效,生产环境始终以服务端能力字段为准。 diff --git a/scripts/tests/democracy-wall-demo.spec.ts b/scripts/tests/democracy-wall-demo.spec.ts new file mode 100644 index 00000000..b7c70a74 --- /dev/null +++ b/scripts/tests/democracy-wall-demo.spec.ts @@ -0,0 +1,342 @@ +import { test, expect } from './fixtures' +import { injectLoginStateWithoutNavigation, waitForPageReady } from './test-helpers' + +test.describe('民主墙离线演示', () => { + test.beforeEach(async ({ page }) => { + await injectLoginStateWithoutNavigation(page) + await page.goto('/#/d?demo=1') + await waitForPageReady(page) + }) + + test('浏览卷宗并打开本地详情', async ({ page }) => { + await expect(page.getByText('离线演示', { exact: true }).first()).toBeVisible() + await expect(page.getByText('关于公开指控处理流程的调查卷宗')).toBeVisible() + await expect(page.getByText('Editor')).toHaveCount(0) + + await page.getByText('关于公开指控处理流程的调查卷宗').click() + await expect(page).toHaveURL(/#\/d\/matter\/66d100000000000000000001\?demo=1$/) + await expect(page.getByRole('heading', { name: '事务详情' })).toBeVisible() + await expect(page.getByRole('heading', { name: '建议收集与质询' })).toBeVisible() + await expect(page.getByText('卷宗引用的条例版本是否在事件发生时已生效?')).toBeVisible() + await expect(page.getByText('星轨记录员', { exact: true })).toBeVisible() + await expect(page.getByText('认证编辑', { exact: true })).toBeVisible() + await expect(page.getByText('潮汐观察员', { exact: true })).toBeVisible() + await expect(page.getByText('Oldtimer', { exact: true })).toBeVisible() + + const contribution = '建议补充调查团回避记录的公开日期。' + await page.getByPlaceholder(/提出建议、事实质询/).fill(contribution) + await page.locator('.comment-composer__send').click() + await expect(page.getByText(contribution, { exact: true })).toBeVisible() + await page.reload() + await waitForPageReady(page) + await expect(page.getByText(contribution, { exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: '事务管理与审计' })).toHaveCount(0) + }) + + test('在当前事务和历史事务之间切换', async ({ page }) => { + await expect(page.getByRole('button', { name: /当前事务 3/ })).toBeVisible() + await page.getByRole('button', { name: /历史事务 4/ }).click() + + await expect(page.getByRole('heading', { name: '历史事务' })).toBeVisible() + await expect(page.getByText('公共事务 1', { exact: true })).toBeVisible() + await expect(page.getByText('管理监察 2', { exact: true })).toBeVisible() + await expect(page.getByText('提议回复 1', { exact: true })).toBeVisible() + await expect(page.getByText('已决议:条例修订必须保留旧版本')).toBeVisible() + + await page.getByText('管理监察 2', { exact: true }).click() + await expect(page.getByText('历史事务:志愿调查团回避办法试行记录')).toBeVisible() + + await page.getByText('提议回复 1', { exact: true }).click() + await expect(page.getByText('提议回复:公开调查团成员替补流程')).toBeVisible() + await expect(page.getByRole('link', { name: '参与质询' })).toHaveCount(0) + await page.getByRole('link', { name: '提议回复:公开调查团成员替补流程' }).click() + await expect(page.getByText('历史记录只读')).toBeVisible() + await expect(page.getByRole('button', { name: '提交建言' })).toHaveCount(0) + }) + + test('普通用户与管理视角严格隔离', async ({ page }) => { + await expect(page.getByRole('link', { name: '发起事务' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '进入管理视角' })).toBeVisible() + await page.getByRole('button', { name: '进入管理视角' }).click() + await expect(page).toHaveURL(/admin=1/) + await expect(page.getByRole('link', { name: '发起事务' })).toBeVisible() + await page.getByText('关于公开指控处理流程的调查卷宗').click() + await expect(page.getByRole('heading', { name: '事务管理与审计' })).toHaveCount(0) + }) + + test('提案详情整合建议收集、质询和投票阶段', async ({ page }) => { + await page.getByText('公共事务 2', { exact: true }).click() + await page.getByRole('link', { name: '提案:卷宗公开后设置 72 小时质询期' }).first().click() + await expect(page.getByText('1. 建议收集与质询', { exact: true })).toBeVisible() + await expect(page.getByText('2. 投票阶段', { exact: true })).toBeVisible() + await page.getByText('2. 投票阶段', { exact: true }).click() + await expect(page.getByRole('button', { name: /同意 72 小时/ })).toBeVisible() + + await page.goto('/#/d?demo=1&scope=history') + await page.getByRole('button', { name: /历史事务 4/ }).click() + await page.getByRole('link', { name: '已决议:条例修订必须保留旧版本' }).first().click() + await page.getByText('2. 投票阶段', { exact: true }).click() + await expect(page.getByRole('button', { name: /通过修订/ })).toBeDisabled() + }) + + test('匿名投票可刷新保留并重置', async ({ page }) => { + await page.getByText('公共事务 2', { exact: true }).click() + await page.getByRole('link', { name: '提案:卷宗公开后设置 72 小时质询期' }).first().click() + await page.getByText('2. 投票阶段', { exact: true }).click() + const choice = page.getByRole('button', { name: /同意 72 小时/ }) + + await expect(choice).toBeEnabled() + await choice.click() + await expect(choice).toBeDisabled() + + await page.reload() + await waitForPageReady(page) + await page.getByText('2. 投票阶段', { exact: true }).click() + await expect(page.getByRole('button', { name: /同意 72 小时/ })).toBeDisabled() + + await page.goto('/#/d?demo=1') + await page.getByRole('button', { name: '重置投票' }).click() + await page.getByText('公共事务 2', { exact: true }).click() + await page.getByRole('link', { name: '提案:卷宗公开后设置 72 小时质询期' }).first().click() + await page.getByText('2. 投票阶段', { exact: true }).click() + await expect(page.getByRole('button', { name: /同意 72 小时/ })).toBeEnabled() + }) + + test('开发权限可审计举报且没有事务阶段操作', async ({ page }) => { + await page.goto('/#/d/matter/66d100000000000000000001?demo=1&admin=1&developer=1') + await page.getByRole('button', { name: '举报事务' }).click() + await page + .getByPlaceholder(/说明具体内容、位置和可能造成的影响/) + .fill('该卷宗可能包含需要进一步核对的个人隐私信息。') + await page.getByRole('button', { name: '提交举报' }).click() + await expect(page.getByText('举报已提交')).toBeVisible() + await page + .locator('.management-panel__audit-heading') + .getByRole('button', { name: '刷新' }) + .click() + await expect(page.getByText('Report', { exact: true })).toBeVisible() + await expect(page.getByText('事务阶段', { exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '确认阶段变更' })).toHaveCount(0) + }) + + test('有处理权限时可警告并封禁认证管理员账号', async ({ page }) => { + await page.goto('/#/d/matter/66d100000000000000000004?demo=1&admin=1&developer=1&scope=history') + await expect(page.getByText('管理员·归档员')).toBeVisible() + await page + .locator('.management-panel').getByPlaceholder(/填写追溯/) + .fill('该认证管理员账号发布的内容需要执行正式账号处置') + await page.getByRole('button', { name: '授权追溯发布者' }).click() + await page.getByRole('button', { name: '警告实名账号' }).click() + await expect(page.getByText('WarnAccount')).toBeVisible() + await page.getByRole('button', { name: '封禁实名账号' }).click() + await expect(page.getByText('已封禁', { exact: true })).toBeVisible() + await expect(page.getByText('BanAccount')).toBeVisible() + await expect(page.getByRole('button', { name: '解除账号封禁' })).toBeVisible() + await expect(page.getByRole('button', { name: '撤销发布权' })).toBeVisible() + + await page.getByRole('button', { name: '解除账号封禁' }).click() + await expect(page.getByText('正常', { exact: true }).first()).toBeVisible() + await page.getByRole('button', { name: '撤销发布权' }).click() + await expect(page.getByRole('button', { name: '恢复发布权' })).toBeVisible() + await page.getByRole('button', { name: '封禁实名账号' }).click() + await page.getByRole('button', { name: '解除账号封禁' }).click() + await expect(page.getByRole('button', { name: '恢复发布权' })).toBeVisible() + await expect(page.getByText('已撤销', { exact: true })).toBeVisible() + }) + + test('管理监察可邀请调查团成员并保留审计记录', async ({ page }) => { + await page.goto('/#/d/matter/66d100000000000000000005?demo=1&admin=1&developer=1&scope=history') + await page + .locator('.management-panel').getByPlaceholder(/填写追溯/) + .fill('邀请该用户加入调查团协助核对公开记录和处理流程') + await page + .getByPlaceholder('输入 24 位用户 ID 或资料页链接') + .fill('https://dl.turtlesim.com/plc/?chinese-user-5ea1934c8116c49429d3e405?') + await page.getByRole('button', { name: '发送邀请' }).click() + + await expect(page.getByText('受邀用户·e405', { exact: true })).toBeVisible() + await expect(page.getByText('等待接受')).toBeVisible() + await expect(page.getByText('InviteInvestigator')).toBeVisible() + await page.reload() + await expect(page.getByText('受邀用户·e405', { exact: true })).toBeVisible() + }) + + test('删除事务后公开下架并返回列表', async ({ page }) => { + await page.goto('/#/d/matter/66d100000000000000000001?demo=1&admin=1&developer=1') + await page + .locator('.management-panel').getByPlaceholder(/填写追溯/) + .fill('该事务包含违规内容,需要从民主墙公开区域下架') + await page.getByRole('button', { name: '删除事务' }).click() + await page.getByRole('button', { name: '确认删除' }).click() + await expect(page).toHaveURL(/#\/d\?.*developer=1/) + await expect(page.getByText('关于公开指控处理流程的调查卷宗')).toHaveCount(0) + }) +}) + +test.describe('民主墙离线创建事务', () => { + test.beforeEach(async ({ page }) => { + await injectLoginStateWithoutNavigation(page) + await page.goto('/#/d?demo=1') + await waitForPageReady(page) + }) + + test('演示用户匿名提议并进入独立板块', async ({ page }) => { + await page.getByRole('link', { name: '匿名提议' }).click() + await expect(page.getByRole('heading', { name: '提交匿名提议' })).toBeVisible() + await expect(page.getByText('公共事务', { exact: true })).toHaveCount(0) + await expect(page.getByText('管理监察', { exact: true })).toHaveCount(0) + await expect(page.getByText(/匿名提议仅可进行意见征集/)).toBeVisible() + await expect(page.getByPlaceholder('选项 1')).toHaveCount(0) + await page.getByLabel('匿名化名').fill('蓝鲸记录员') + + await page + .getByRole('textbox', { name: '标题', exact: true }) + .fill('建议增加社区条例修订预告期') + await page + .locator('.democracy-editor .cm-content') + .fill('建议条例修订在投票前公开七天,让普通用户有时间阅读并提出修改意见。') + await page.getByRole('button', { name: '发布' }).click() + + await expect(page.getByRole('heading', { name: '事务详情' })).toBeVisible() + await expect(page.getByText('建议增加社区条例修订预告期')).toBeVisible() + await expect(page.getByText('蓝鲸记录员')).toBeVisible() + await expect(page.getByText('2. 投票阶段', { exact: true })).toHaveCount(0) + await expect(page.getByText('66d10000000000000000a001')).toHaveCount(0) + + await page.goto(`${page.url()}&admin=1&developer=1`) + await page + .locator('.management-panel').getByPlaceholder(/填写追溯/) + .fill('意见征集已经完成,可以整理意见并形成最终决议') + await expect(page.locator('.management-panel__group .n-base-selection')).toHaveCount(0) + await expect(page.getByRole('button', { name: '确认阶段变更' })).toHaveCount(0) + + await page.goto('/#/d?demo=1') + await page.getByText('匿名提议 1', { exact: true }).click() + await expect(page.getByText('建议增加社区条例修订预告期')).toBeVisible() + await expect(page.getByText('TestUser')).toHaveCount(0) + }) + + test('正式投票事务到点自动开放投票', async ({ page }) => { + await page.goto('/#/d?demo=1&admin=1&developer=1') + await page.getByRole('link', { name: '发起事务' }).click() + await page.getByLabel('匿名化名').fill('定时议事员') + await page.getByText('投票', { exact: true }).click() + await page.getByRole('textbox', { name: '标题', exact: true }).fill('定时开放的正式投票事务') + await page + .locator('.democracy-editor .cm-content') + .fill('该正式事务用于验证意见征集与投票分流,并在预定时间自动开放社区投票。') + await page.getByPlaceholder('选项 1').fill('通过') + await page.getByPlaceholder('选项 2').fill('退回修改') + + const schedule = await page.evaluate(() => { + const format = (date: Date) => { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) + return local.toISOString().slice(0, 19) + } + return { + start: format(new Date(Date.now() + 3_000)), + finish: format(new Date(Date.now() + 12_000)), + } + }) + const dateInputs = page.locator('input[type="datetime-local"]') + await dateInputs.nth(0).fill(schedule.start) + await dateInputs.nth(1).fill(schedule.finish) + await page.getByRole('button', { name: '发布' }).click() + + await expect(page.getByText('定时开放的正式投票事务')).toBeVisible() + await expect(page.getByText('2. 投票阶段', { exact: true })).toBeVisible({ timeout: 8_000 }) + const voteChoice = page.getByRole('button', { name: /通过/ }) + await expect(voteChoice).toBeVisible() + await expect(voteChoice).toBeEnabled() + + const waitUntilFinished = Math.max(0, new Date(schedule.finish).getTime() - Date.now() + 800) + await page.waitForTimeout(waitUntilFinished) + await expect(voteChoice).toBeDisabled() + await expect(voteChoice).toContainText('%') + await expect(page.getByText('投票已结束', { exact: true })).toBeVisible() + await expect(page.getByText('自动判定通过', { exact: true })).toHaveCount(0) + }) + + test('匿名化名唯一、可修改,管理人员可追溯并撤销资格', async ({ page }) => { + await page.getByRole('link', { name: '匿名提议' }).click() + await page.getByLabel('匿名化名').fill('社区观察鲸') + await page.getByRole('textbox', { name: '标题', exact: true }).fill('第一份匿名社区建议') + await page + .locator('.democracy-editor .cm-content') + .fill('这是用于验证匿名化名、后台追溯以及管理禁发能力的测试内容。') + await page.getByRole('button', { name: '发布' }).click() + + await expect(page.getByText('社区观察鲸')).toBeVisible() + await expect(page.getByText('66d10000000000000000a001')).toHaveCount(0) + await page.goto(`${page.url()}&admin=1&developer=1`) + await page + .locator('.management-panel').getByPlaceholder(/填写追溯/) + .fill('核查该账号连续发布重复提议的情况') + await page.getByRole('button', { name: '授权追溯发布者' }).click() + await expect(page.getByText('66d10000000000000000a001')).toBeVisible() + await page.getByRole('button', { name: '撤销发布权' }).click() + await expect(page.getByRole('button', { name: '恢复发布权' })).toBeVisible() + await expect(page.getByText('RevokePublisher')).toBeVisible() + + await page.goto('/#/d/new?demo=1&mode=anonymous') + await expect(page.getByText(/匿名发布权已被撤销/)).toBeVisible() + await expect(page.getByRole('button', { name: '发布' })).toBeDisabled() + + await page.goto('/#/d/new?demo=1&mode=formal') + await expect(page.getByRole('heading', { name: '发起社区事务' })).toBeVisible() + await expect(page.getByText(/匿名发布权已被撤销/)).toBeVisible() + await expect(page.getByRole('button', { name: '发布' })).toBeDisabled() + + await page.evaluate(() => { + const profiles = JSON.parse( + localStorage.getItem('plweb2.democracy.demoAnonymousProfiles') || '{}', + ) + profiles['66d10000000000000000a001'].revoked = false + profiles['another-demo-user'] = { + alias: '已被占用的名字', + revoked: false, + userId: 'another-demo-user', + } + localStorage.setItem('plweb2.democracy.demoAnonymousProfiles', JSON.stringify(profiles)) + }) + await page.goto('/#/d/new?demo=1&mode=anonymous') + await page.getByLabel('匿名化名').fill('已被占用的名字') + await page.getByRole('textbox', { name: '标题', exact: true }).fill('重复化名测试事务') + await page + .locator('.democracy-editor .cm-content') + .fill('这条内容用于验证另一个账号已经占用的匿名化名无法再次注册。') + await page.getByRole('button', { name: '发布' }).click() + await expect(page).toHaveURL(/#\/d\/new\?demo=1&mode=anonymous$/) + + await page.getByLabel('匿名化名').fill('改名后的观察鲸') + await page.getByRole('button', { name: '发布' }).click() + await expect(page.getByText('改名后的观察鲸')).toBeVisible() + + await page.goto('/#/d?demo=1') + await page.getByText('匿名提议 2', { exact: true }).click() + await expect(page.getByText('改名后的观察鲸')).toHaveCount(2) + await expect(page.getByText('社区观察鲸')).toHaveCount(0) + }) + + test('具备权限的用户可以发起正式事务', async ({ page }) => { + await page.goto('/#/d?demo=1&admin=1&developer=1') + await page.getByRole('link', { name: '发起事务' }).click() + await expect(page.getByRole('heading', { name: '发起社区事务' })).toBeVisible() + await page.getByLabel('匿名化名').fill('事务记录员') + await page.getByText('管理监察', { exact: true }).click() + await page.getByRole('textbox', { name: '标题', exact: true }).fill('调查团成员回避申明核查') + await page + .locator('.democracy-editor .cm-content') + .fill('请核查本次调查团成员已公开的关系申明,并在质询期内补充遗漏信息。') + await page.getByRole('button', { name: '发布' }).click() + + await expect(page.getByText('调查团成员回避申明核查')).toBeVisible() + await expect(page.getByText('事务记录员')).toBeVisible() + await expect(page.getByText('66d10000000000000000a001')).toHaveCount(0) + await expect(page.getByText('认证编辑·演示用户')).toHaveCount(0) + + await page.goto('/#/d?demo=1') + await page.getByText('管理监察 2', { exact: true }).click() + await expect(page.getByRole('link', { name: '调查团成员回避申明核查' }).last()).toBeVisible() + }) +}) diff --git a/scripts/tests/democracy-wall-edit-lock.spec.ts b/scripts/tests/democracy-wall-edit-lock.spec.ts new file mode 100644 index 00000000..facd886e --- /dev/null +++ b/scripts/tests/democracy-wall-edit-lock.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from './fixtures' +import { injectLoginStateWithoutNavigation } from './test-helpers' + +test.beforeEach(async ({ page }) => { + await injectLoginStateWithoutNavigation(page) + await page.goto('/#/d?demo=1&admin=1') +}) + +test('发布者投票前可重复编辑,跨过开始时间后服务层拒绝修改', async ({ page }) => { + const result = await page.evaluate(async () => { + const path = '/src/services/democracyWall.ts' + const service = await import(path) + const plan = { + StartAt: new Date(Date.now() + 60000).toISOString(), + FinishAt: new Date(Date.now() + 3600000).toISOString(), + Options: ['赞成', '反对'], + Multiple: false, + } + const matter = await service.submitDemocracyMatter({ + subject: '编辑时间边界测试', + description: '用于验证发起后到投票开始之间的编辑权限。', + anonymous: false, + kind: 'public', + participation: 'Vote', + votePlan: plan, + }) + const input = { + matterId: matter.ID, + expectedRevision: String(matter.UpdateDate), + subject: '投票开始前第一次修改', + description: '这里是第一次修改后的完整事务正文。', + participation: 'Vote', + votePlan: plan, + } + await service.updateDemocracyMatter(input) + await service.updateDemocracyMatter({ ...input, subject: '投票开始前第二次修改' }) + const clock = Date.now + Date.now = () => new Date(plan.StartAt).getTime() + let error = '' + try { + await service.updateDemocracyMatter({ ...input, subject: '投票后禁止保存内容' }) + } catch (e) { + error = (e as Error).message + } finally { + Date.now = clock + } + return { error, subject: (await service.fetchDemocracyMatter(matter.ID)).summary.Subject } + }) + expect(result).toEqual({ error: 'permission-denied', subject: '投票开始前第二次修改' }) +}) + +test('编辑页面跨过投票开始时间自动禁用,开发权限可修改正文', async ({ page }) => { + const id = await page.evaluate(async () => { + const path = '/src/services/democracyWall.ts' + const service = await import(path) + const matter = await service.submitDemocracyMatter({ + subject: '编辑页自动锁定测试', + description: '用于验证编辑页面跨越投票开始时间后自动锁定。', + anonymous: false, + kind: 'public', + participation: 'Vote', + votePlan: { + StartAt: new Date(Date.now() + 10000).toISOString(), + FinishAt: new Date(Date.now() + 3600000).toISOString(), + Options: ['同意', '不同意'], + Multiple: false, + }, + }) + return matter.ID + }) + await page.goto(`/#/d/new?edit=${id}&demo=1&admin=1`) + const title = page.getByRole('textbox', { name: '标题', exact: true }) + await expect(title).toBeEnabled() + await expect(title).toBeDisabled({ timeout: 15000 }) + await page.goto(`/#/d/new?edit=${id}&demo=1&admin=1&developer=1`) + await expect(title).toBeEnabled() + await title.fill('开发权限修订后的正文标题') + await expect(page.locator('input[type="datetime-local"]').first()).toBeDisabled() + await page.getByRole('button', { name: '发布', exact: true }).click() + await expect(page.getByRole('heading', { name: '开发权限修订后的正文标题' })).toBeVisible() +}) diff --git a/scripts/tests/democracy-wall-permissions.spec.ts b/scripts/tests/democracy-wall-permissions.spec.ts new file mode 100644 index 00000000..8585f5ea --- /dev/null +++ b/scripts/tests/democracy-wall-permissions.spec.ts @@ -0,0 +1,279 @@ +import type { Page } from '@playwright/test' +import { expect, test } from './fixtures' +import { injectLoginStateWithoutNavigation, waitForPageReady } from './test-helpers' + +interface PermissionCase { + alias?: string + canPublish: boolean + login: boolean + name: string + permissions: { + CanContribute: boolean + CanInitiate: boolean + CanModerate: boolean + CanSuggest: boolean + CanTransition: boolean + } + verification?: string +} + +const matterSummary = { + ID: '66a84559744ed757b46f8917', + Tags: ['民主墙', '公开卷宗'], + Type: 0, + User: { + ID: '', + Nickname: '匿名调查员', + Avatar: 0, + AvatarRegion: 0, + Signature: '', + Decoration: 0, + Verification: undefined, + }, + Image: 0, + ImageRegion: 0, + Price: 0, + Stars: 0, + Editor: null, + Visits: 0, + ModelID: null, + Remixes: 0, + Subject: '权限兼容性测试事务', + Version: 1, + Category: 'Discussion', + Comments: 0, + Language: 'Chinese', + Supports: 0, + Coauthors: [], + Popularity: 0, + UpdateDate: 0, + Visibility: 0, + SortingDate: 0, + CreationDate: 0, + Multilingual: false, + Description: ['用于验证不同账号权限下的详情页写入口。'], +} + +const permissionCases: PermissionCase[] = [ + { + canPublish: false, + login: false, + name: '游客', + permissions: { + CanContribute: false, + CanInitiate: false, + CanModerate: false, + CanSuggest: false, + CanTransition: false, + }, + }, + { + alias: '普通用户化名', + canPublish: true, + login: true, + name: '普通用户', + permissions: { + CanContribute: true, + CanInitiate: false, + CanModerate: false, + CanSuggest: false, + CanTransition: false, + }, + verification: 'user', + }, + { + alias: '资深用户化名', + canPublish: true, + login: true, + name: 'Oldtimer', + permissions: { + CanContribute: true, + CanInitiate: false, + CanModerate: false, + CanSuggest: true, + CanTransition: false, + }, + verification: 'Oldtimer', + }, + { + alias: '编辑化名', + canPublish: true, + login: true, + name: '认证编辑', + permissions: { + CanContribute: true, + CanInitiate: true, + CanModerate: false, + CanSuggest: false, + CanTransition: false, + }, + verification: 'Editor', + }, + { + alias: '管理化名', + canPublish: true, + login: true, + name: '管理员', + permissions: { + CanContribute: true, + CanInitiate: true, + CanModerate: true, + CanSuggest: true, + CanTransition: true, + }, + verification: 'Administrator', + }, + { + alias: '受限账号', + canPublish: false, + login: true, + name: '被禁发管理员', + permissions: { + CanContribute: true, + CanInitiate: true, + CanModerate: true, + CanSuggest: true, + CanTransition: true, + }, + verification: 'Administrator', + }, +] + +async function mockPermissionApis(page: Page, account: PermissionCase) { + await page.route('**/api/Democracy/GetContext', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + ApiVersion: 1, + Permissions: account.permissions, + Profile: account.login + ? { Alias: account.alias, CanPublish: account.canPublish, ModifiedAt: '' } + : null, + }, + }), + }), + ) + await page.route('**/api/Democracy/QueryMatters', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ Status: 200, Message: '', Data: { Entries: [] } }), + }), + ) + await page.route('**/api/Democracy/GetMatter', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + CanEdit: account.login, + Kind: 'Public', + Mode: 'Formal', + Participation: 'Consultation', + Revision: '1', + Stage: 'Questions', + Summary: matterSummary, + }, + }), + }), + ) + await page.route('**/api/Democracy/QueryContributions', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ Status: 200, Message: '', Data: { Entries: [] } }), + }), + ) + await page.route('**/api/Democracy/QueryAudit', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ Status: 200, Message: '', Data: { Entries: [] } }), + }), + ) + await page.route('**/api/Democracy/QueryInvestigationTeam', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ Status: 200, Message: '', Data: { Entries: [] } }), + }), + ) + await page.route('**/api/Users/Authenticate', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { Activities: [], Statistic: { Activities: [], ID: 'permission-statistic' } }, + }), + }), + ) +} + +async function injectGuestPreferences(page: Page) { + await page.addInitScript(() => { + const now = Date.now() + localStorage.setItem( + 'userConfig', + JSON.stringify({ + value: { language: 'zh', languageManuallySelected: true }, + time: now, + maxAgeMs: 30 * 24 * 60 * 60 * 1000, + }), + ) + localStorage.setItem( + 'cookieConsent', + JSON.stringify({ value: true, time: now, maxAgeMs: 365 * 24 * 60 * 60 * 1000 }), + ) + }) +} + +function expectedWriteCount(account: PermissionCase, permission = true) { + return account.login && account.canPublish && permission ? 1 : 0 +} + +async function prepareAccount(page: Page, account: PermissionCase) { + if (account.login) { + await injectLoginStateWithoutNavigation(page, { verification: account.verification }) + return + } + await injectGuestPreferences(page) +} + +async function assertWallPermissions(page: Page, account: PermissionCase) { + await expect(page.getByRole('link', { name: '匿名提议' })).toHaveCount( + expectedWriteCount(account, account.permissions.CanSuggest), + ) + await expect(page.getByRole('link', { name: '发起事务' })).toHaveCount( + expectedWriteCount(account, account.permissions.CanInitiate), + ) +} + +async function assertDetailPermissions(page: Page, account: PermissionCase) { + await expect(page.locator('.comment-composer')).toHaveCount( + expectedWriteCount(account, account.permissions.CanContribute), + ) + await expect(page.getByRole('button', { name: '举报事务' })).toHaveCount( + expectedWriteCount(account), + ) + await expect(page.getByRole('button', { name: '修改事务' })).toHaveCount( + expectedWriteCount(account), + ) + await expect(page.getByRole('heading', { name: '事务管理与审计' })).toHaveCount( + expectedWriteCount(account, false), + ) +} + +for (const account of permissionCases) { + test(`${account.name}的民主墙写入口与服务端权限一致`, async ({ page }) => { + await prepareAccount(page, account) + await mockPermissionApis(page, account) + await page.goto('/#/d') + await waitForPageReady(page) + await assertWallPermissions(page, account) + + await page.goto('/#/d/matter/66a84559744ed757b46f8917') + await expect(page.getByRole('heading', { name: '事务详情' })).toBeVisible() + await assertDetailPermissions(page, account) + }) +} diff --git a/scripts/tests/democracy-wall-readonly.spec.ts b/scripts/tests/democracy-wall-readonly.spec.ts new file mode 100644 index 00000000..3be753cf --- /dev/null +++ b/scripts/tests/democracy-wall-readonly.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from './fixtures' +import { injectLoginStateWithoutNavigation } from './test-helpers' + +test('只读部署阻止详情写入口和服务层管理调用', async ({ page }) => { + test.skip(process.env.DEMOCRACY_READONLY_TEST !== '1', 'Requires a read-only Vite server') + await injectLoginStateWithoutNavigation(page) + const id = '66a84559744ed757b46f8917' + let writes = 0 + await page.route('**/api/Democracy/**', (route) => { + const path = new URL(route.request().url()).pathname.split('/').pop() + if (!path?.startsWith('Get') && !path?.startsWith('Query')) writes++ + const data = + path === 'GetContext' + ? { + ApiVersion: 1, + Profile: { Alias: '只读测试', CanPublish: true }, + Permissions: { + CanContribute: true, + CanInitiate: true, + CanSuggest: true, + CanModerate: true, + CanTransition: true, + }, + } + : path === 'GetMatter' + ? { + Kind: 'Oversight', + Mode: 'Formal', + Participation: 'Consultation', + Stage: 'Questions', + CanEdit: true, + Revision: '1', + Summary: { + ID: id, + Tags: [], + Subject: '只读模式测试事务', + Description: ['验证生产只读配置的交互限制'], + User: { ID: '', Nickname: '公开化名' }, + }, + } + : { Entries: [] } + return route.fulfill({ json: { Status: 200, Data: data } }) + }) + await page.route('**/api/Users/Authenticate', (route) => + route.fulfill({ + json: { + Status: 200, + Data: { Activities: [], Statistic: { Activities: [] } }, + }, + }), + ) + await page.goto(`/#/d/matter/${id}`) + await expect(page.getByText('只读模式测试事务', { exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: '事务管理与审计' })).toHaveCount(0) + await expect(page.locator('.comment-composer')).toHaveCount(0) + await expect(page.getByRole('button', { name: '举报事务' })).toHaveCount(0) + const errors = await page.evaluate(async (matterId) => { + const path = '/src/services/democracyWall.ts' + const service = await import(path) + const results = await Promise.allSettled([ + service.deleteDemocracyMatter(matterId, '只读配置验证'), + service.moderateDemocracyAccount(matterId, 'Ban', '只读配置验证'), + service.submitDemocracyContribution(matterId, '只读配置验证'), + ]) + return results.map((result) => + result.status === 'rejected' ? result.reason.message : 'allowed', + ) + }, id) + expect(errors).toEqual(['feature-read-only', 'feature-read-only', 'feature-read-only']) + expect(writes).toBe(0) +}) diff --git a/scripts/tests/democracy-wall-regressions.spec.ts b/scripts/tests/democracy-wall-regressions.spec.ts new file mode 100644 index 00000000..61679cba --- /dev/null +++ b/scripts/tests/democracy-wall-regressions.spec.ts @@ -0,0 +1,238 @@ +import { test, expect } from './fixtures' +import { injectLoginStateWithoutNavigation } from './test-helpers' + +const matterId = '66a84559744ed757b46f8917' +const activityId = '7777ff550b5f97d6e49d12d7' + +async function prepare(page: import('@playwright/test').Page, stage = 'Voting') { + await injectLoginStateWithoutNavigation(page, { verification: 'Oldtimer' }) + const activity = { + ID: activityId, + InterfaceModel: 'Vote-Single', + InternalLink: '', + TargetLink: {}, + TargetText: {}, + StartDate: new Date(Date.now() - 60_000).toISOString(), + FinishDate: new Date(Date.now() + 3600_000).toISOString(), + Items: [ + { Description: '方案甲', Counter: 10 }, + { Description: '方案乙', Counter: 0 }, + ], + } + const statistic = { + ID: matterId, + Activities: [{ ActivityID: activityId, Finished: false, Gains: [] }], + } + const matter = { + Kind: 'Oversight', + Mode: 'Formal', + Participation: 'Vote', + Stage: stage, + CanEdit: false, + Revision: '1', + VoteActivity: activity, + Summary: { + ID: matterId, + Tags: [], + Subject: '回归测试事务', + Description: ['测试事务的公开说明'], + User: { ID: '', Nickname: '公开虚拟名' }, + Comments: 0, + Visits: 0, + }, + } + await page.route('**/api/Democracy/**', async (route) => { + const path = new URL(route.request().url()).pathname.split('/').pop() + const data = + path === 'GetMatter' + ? matter + : path === 'GetContext' + ? { + ApiVersion: 1, + Profile: { Alias: '测试化名', CanPublish: true }, + Permissions: { + CanContribute: true, + CanInitiate: false, + CanSuggest: true, + CanModerate: false, + CanTransition: false, + }, + } + : { Entries: [] } + await route.fulfill({ json: { Status: 200, Data: data } }) + }) + await page.route('**/api/Users/Authenticate', (route) => + route.fulfill({ + json: { Status: 200, Data: { Activities: [activity], Statistic: statistic } }, + }), + ) + return { activity, statistic, matter } +} + +test('投票使用实际票数且采用提交后的最新快照', async ({ page }) => { + const { activity, statistic } = await prepare(page) + await page.route('**/api/Users/ReceiveBonus', (route) => + route.fulfill({ + json: { + Status: 200, + Data: { + Activities: [ + { ...activity, Items: [activity.Items[0], { ...activity.Items[1], Counter: 10 }] }, + ], + Statistic: { + ...statistic, + Activities: [{ ActivityID: activityId, Finished: false, Gains: [1] }], + }, + }, + }, + }), + ) + await page.goto(`/#/d/matter/${matterId}`) + await expect(page.getByText('公开虚拟名', { exact: true })).toBeVisible() + await page.getByRole('button', { name: /方案乙/ }).click() + await expect(page.getByRole('button', { name: /方案甲/ })).toContainText('50%') + await expect(page.getByRole('button', { name: /方案乙/ })).toContainText('50%') + await expect(page.getByRole('button', { name: /方案乙/ })).toBeDisabled() +}) + +test('历史事务直达链接只读且10比0显示100比0', async ({ page }) => { + const { statistic } = await prepare(page, 'Archived') + await page.route('**/api/Users/Authenticate', (route) => + route.fulfill({ + json: { + Status: 200, + Data: { Statistic: { ...statistic, Activities: [] }, Activities: [] }, + }, + }), + ) + await page.goto(`/#/d/matter/${matterId}`) + await expect(page.locator('.comment-composer')).toHaveCount(0) + await page.getByText('2. 投票阶段', { exact: true }).click() + await expect(page.getByRole('button', { name: /方案甲/ })).toContainText('100%') + await expect(page.getByRole('button', { name: /方案乙/ })).toContainText('0%') + await expect(page.getByRole('button', { name: /方案甲/ })).toBeDisabled() +}) + +test('服务端阶段迁移延迟时限制轮询频率', async ({ page }) => { + const { matter } = await prepare(page, 'Questions') + let requests = 0 + await page.route('**/api/Democracy/GetMatter', (route) => { + requests++ + return route.fulfill({ + json: { + Status: 200, + Data: { + ...matter, + VotePlan: { + StartAt: new Date(Date.now() - 60_000).toISOString(), + FinishAt: new Date(Date.now() + 3600_000).toISOString(), + Options: ['甲', '乙'], + }, + }, + }, + }) + }) + await page.goto(`/#/d/matter/${matterId}`) + await expect(page.getByText('回归测试事务', { exact: true })).toBeVisible() + await page.waitForTimeout(6500) + expect(requests).toBeGreaterThanOrEqual(2) + expect(requests).toBeLessThanOrEqual(3) +}) + +for (const decision of ['Accepted', 'Declined'] as const) { + test(`通知定位邀请并${decision === 'Accepted' ? '接受' : '拒绝'},刷新保留结果`, async ({ + page, + }) => { + await prepare(page, 'Questions') + let status: string = 'Pending' + let writes = 0 + await page.route('**/api/Democracy/GetInvestigationInvite', (route) => + route.fulfill({ + json: { + Status: 200, + Data: { ID: 'invite-1', Status: status, CreatedAt: new Date().toISOString() }, + }, + }), + ) + await page.route('**/api/Democracy/RespondInvestigationInvite', (route) => { + expect(route.request().postDataJSON()).toMatchObject({ + MatterID: matterId, + InviteID: 'invite-1', + Decision: decision, + }) + writes++ + status = decision + return route.fulfill({ + json: { + Status: 200, + Data: { + ID: 'invite-1', + Status: status, + CreatedAt: new Date().toISOString(), + }, + }, + }) + }) + await page.route('**/api/Messages/GetMessages', (route) => + route.fulfill({ + json: { + Status: 200, + Data: { + Templates: [], + Messages: [ + { + ID: 'notice-1', + CategoryID: 1, + TemplateID: 'invitation', + Users: [], + UserNames: [], + Fields: { + Content: '你收到一封调查团邀请', + DemocracyMatterID: matterId, + DemocracyEvent: 'InvestigationInvite', + DemocracyInviteID: 'invite-1', + }, + }, + ], + }, + }, + }), + ) + await page.goto('/#/n') + await page + .locator('.notification_message') + .filter({ hasText: '你收到一封调查团邀请' }) + .first() + .click() + await expect(page).toHaveURL(new RegExp(`/d/matter/${matterId}\\?invitation=invite-1$`)) + await page + .getByRole('button', { name: decision === 'Accepted' ? '接受邀请' : '拒绝邀请' }) + .click() + const label = decision === 'Accepted' ? '已加入' : '已拒绝' + await expect(page.locator('.invitation').getByText(label, { exact: true })).toBeVisible() + await page.reload() + await expect(page.locator('.invitation').getByText(label, { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '接受邀请' })).toHaveCount(0) + expect(writes).toBe(1) + }) +} + +test('超过一页的质询全部可见', async ({ page }) => { + await prepare(page, 'Questions') + const offsets: number[] = [] + await page.route('**/api/Democracy/QueryContributions', (route) => { + const { Skip, Take } = route.request().postDataJSON() + offsets.push(Skip) + const entries = Array.from({ length: 51 }, (_, index) => ({ + ID: String(index), + AuthorAlias: '测试化名', + AuthorPosition: 'Oldtimer', + CreatedAt: new Date().toISOString(), + Content: `质询编号${index + 1}`, + })).slice(Skip, Skip + Take) + return route.fulfill({ json: { Status: 200, Data: { Entries: entries } } }) + }) + await page.goto(`/#/d/matter/${matterId}`) + await expect(page.getByText('质询编号51', { exact: true })).toBeAttached() + expect(offsets).toEqual([0, 50]) +}) diff --git a/scripts/tests/democracy-wall-sensitive.spec.ts b/scripts/tests/democracy-wall-sensitive.spec.ts new file mode 100644 index 00000000..03fcf327 --- /dev/null +++ b/scripts/tests/democracy-wall-sensitive.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from './fixtures' +import { injectLoginStateWithoutNavigation } from './test-helpers' + +const id = '66d100000000000000000001' +const developerUrl = `/#/d/matter/${id}?demo=1&admin=1&developer=1` +test('正式追溯请求明确不通知当事人', async ({ page }) => { + await injectLoginStateWithoutNavigation(page) + await page.route('**/api/Democracy/GetContext', (route) => route.fulfill({ json: { + Status: 200, Data: { ApiVersion: 1, Profile: null, Permissions: { CanDeveloperManage: true } }, + } })) + let payload: unknown + await page.route('**/api/Democracy/TracePublisher', (route) => { + payload = route.request().postDataJSON() + return route.fulfill({ json: { Status: 200, Data: { UserID: 'private-account', Alias: '测试化名', Banned: false, CanPublish: true, PublishingRevoked: false } } }) + }) + await page.goto('/#/d?demo=1') + await expect(page.getByRole('heading', { name: '民主墙', exact: true })).toBeVisible() + await page.evaluate(async (matterId) => { + history.replaceState(null, '', '/#/d') + const path = '/src/services/democracyWall.ts' + const { traceDemocracyPublisher } = await import(path) + await traceDemocracyPublisher(matterId, '正式追溯静默通知验证') + }, id) + expect(payload).toMatchObject({ MatterID: id, Notify: false, Reason: '正式追溯静默通知验证' }) +}) +test.beforeEach(async ({ page }) => { + await injectLoginStateWithoutNavigation(page) +}) + +test('普通管理员无管理入口且服务层拒绝敏感操作', async ({ page }) => { + await page.goto(`/#/d/matter/${id}?demo=1&admin=1`) + await expect(page.getByRole('heading', { name: '事务详情' })).toBeVisible() + await expect(page.locator('.management-panel')).toHaveCount(0) + await expect(page.getByText('质询人管理')).toHaveCount(0) + const result = await page.evaluate(async (matterId) => { + const path = '/src/services/democracyWall.ts' + const service = await import(path) + try { + await service.traceDemocracyPublisher(matterId, '无开发权限的追溯测试') + return 'allowed' + } catch (error) { + return (error as Error).message + } + }, id) + expect(result).toBe('permission-denied') +}) + +test('直接封禁发布者自定义天数,负数被拒绝', async ({ page }) => { + await page.goto(developerUrl) + const panel = page.locator('.management-panel') + await panel.getByPlaceholder(/填写追溯/).fill('测试直接按自定义天数封禁发布者') + await panel.getByLabel('封禁天数').fill('-1') + await expect(panel.getByRole('button', { name: '封禁实名账号' })).toBeDisabled() + await panel.getByLabel('封禁天数').fill('123') + await panel.getByRole('button', { name: '封禁实名账号' }).click() + await expect(panel.getByText('已封禁', { exact: true })).toBeVisible() + await expect(panel.locator('.account-controls__trace')).toHaveCount(0) + const duration = await page.evaluate(() => { + const state = JSON.parse(localStorage.getItem('plweb2.democracy.demoAdminState') || '{}') + return ( + (new Date(state.bannedUntil['66d10000000000000000a001']).getTime() - Date.now()) / 86400000 + ) + }) + expect(duration).toBeGreaterThan(122.99) + expect(duration).toBeLessThanOrEqual(123) +}) + +test('质询追溯默认私密,确认披露后访客可见', async ({ page }) => { + await page.goto(developerUrl) + const card = page.locator('.contribution-card').first() + await card.getByText('质询人管理', { exact: true }).click() + await card.getByPlaceholder(/填写追溯/).fill('经核实需要公开此次质询的账号归属') + await card.getByRole('button', { name: '授权追溯质询人' }).click() + await expect(card.locator('.account-controls__trace')).toContainText('66d10000000000000000a002') + await expect(card.locator('.contribution-card__disclosure')).toHaveCount(0) + await page.goto(`/#/d/matter/${id}?demo=1`) + await expect(page.getByRole('heading', { name: '事务详情' })).toBeVisible() + await expect(page.getByText('66d10000000000000000a002', { exact: true })).toHaveCount(0) + await page.goto(developerUrl) + await card.getByText('质询人管理', { exact: true }).click() + await card.getByPlaceholder(/填写追溯/).fill('经核实需要公开此次质询的账号归属') + await card.getByRole('button', { name: '授权追溯质询人' }).click() + await card.getByRole('button', { name: '公开披露真实账号' }).click() + await expect(page.getByText(/将向所有访客公开/)).toBeVisible() + await page.locator('.n-popconfirm__action').getByRole('button').last().click() + await expect(card.locator('.contribution-card__disclosure')).toContainText( + '66d10000000000000000a002', + ) + await page.goto(`/#/d/matter/${id}?demo=1`) + await expect(page.locator('.contribution-card__disclosure')).toContainText( + '66d10000000000000000a002', + ) +}) + +test('封禁质询人仅影响关联账号并到期恢复', async ({ page }) => { + await page.goto(developerUrl) + const card = page.locator('.contribution-card').first() + await card.getByText('质询人管理', { exact: true }).click() + await card.getByPlaceholder(/填写追溯/).fill('测试对质询人执行两天账号封禁') + await card.getByLabel('封禁天数').fill('2') + await card.getByRole('button', { name: '封禁质询人' }).click() + await expect(card.getByText('已封禁', { exact: true })).toBeVisible() + const result = await page.evaluate(async () => { + const path = '/src/services/democracyWallDemo.ts' + const demo = await import(path) + const target = '66d10000000000000000a002' + const before = demo.isDemocracyDemoAnonymousPostingRevoked(target) + const publisher = demo.isDemocracyDemoAnonymousPostingRevoked('66d10000000000000000a001') + const key = 'plweb2.democracy.demoAdminState' + const state = JSON.parse(localStorage.getItem(key) || '{}') + state.bannedUntil[target] = new Date(Date.now() - 1000).toISOString() + localStorage.setItem(key, JSON.stringify(state)) + return { before, publisher, after: demo.isDemocracyDemoAnonymousPostingRevoked(target) } + }) + expect(result).toEqual({ before: true, publisher: false, after: false }) +}) diff --git a/scripts/tests/democracy-wall.spec.ts b/scripts/tests/democracy-wall.spec.ts new file mode 100644 index 00000000..28a401c1 --- /dev/null +++ b/scripts/tests/democracy-wall.spec.ts @@ -0,0 +1,363 @@ +import { test, expect } from './fixtures' +import { injectLoginStateWithoutNavigation, waitForPageReady } from './test-helpers' + +const statistic = { + ID: '6666ff550b5f97d6e49d12d7', + Activities: [ + { + ActivityID: '7777ff550b5f97d6e49d12d7', + Avails: [0, 1], + Counters: [0, 0], + Expiration: '2026-09-30T00:00:00Z', + Finished: false, + Gains: [], + LastModified: '2026-08-29T00:00:00Z', + }, + ], +} + +const entriesResponse = { + Status: 200, + Message: '', + Data: { + $values: [ + { + $type: 'Summary', + ID: '66a84559744ed757b46f8917', + Tags: ['民主墙', '公开卷宗'], + Type: 0, + User: { + ID: '', + Nickname: '匿名调查员', + Avatar: 0, + AvatarRegion: 0, + Signature: '', + Decoration: 0, + Verification: undefined, + }, + Image: 1, + ImageRegion: 1, + Price: 0, + Stars: 12, + Editor: null, + Visits: 88, + ModelID: null, + Remixes: 0, + Subject: '关于公开指控处理流程的卷宗', + Version: 1, + Category: 'Discussion', + Comments: 7, + Language: 'Chinese', + Supports: 0, + Coauthors: [], + Popularity: 0, + UpdateDate: 0, + Visibility: 0, + SortingDate: 0, + CreationDate: 0, + Multilingual: false, + Description: ['公开事实、规则依据和处理记录。'], + }, + ], + }, +} + +const activitiesResponse = { + Status: 200, + Message: '', + Data: { + Statistic: statistic, + Activities: [ + { + Contents: [{ Chinese: '对条例模糊部分进行社区决议。' }], + FinishDate: '2026-09-30T00:00:00Z', + ID: '7777ff550b5f97d6e49d12d7', + InterfaceModel: 'Vote-Single', + InternalLink: '/p/Discussion/66a84559744ed757b46f8917', + IsAttendance: false, + IsDaily: false, + IsDevelopment: false, + IsTutorial: false, + Items: [ + { + Bonuses: {}, + Condition: '', + Counter: 20, + Counters: {}, + Description: '同意修订', + Local: false, + }, + { + Bonuses: {}, + Condition: '', + Counter: 10, + Counters: {}, + Description: '维持原文', + Local: false, + }, + ], + Languages: [], + Platforms: [], + Priority: 1, + StartDate: '2026-08-01T00:00:00Z', + Subject: { Chinese: '条例修订投票' }, + TargetLink: {}, + TargetText: {}, + Version: 1, + }, + ], + }, +} + +test.describe('民主墙', () => { + test.beforeEach(async ({ page }) => { + await injectLoginStateWithoutNavigation(page, { verification: 'Oldtimer' }) + await page.route('**/api/Democracy/GetContext', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + ApiVersion: 1, + Permissions: { + CanContribute: true, + CanInitiate: false, + CanModerate: false, + CanSuggest: true, + CanTransition: false, + }, + Profile: { Alias: 'Oldtimer测试化名', CanPublish: true, ModifiedAt: '' }, + }, + }), + }) + }) + await page.route('**/api/Democracy/QueryMatters', async (route) => { + const request = route.request().postDataJSON() + const summaries = request?.Scope === 'History' ? [] : entriesResponse.Data.$values + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + Entries: [ + ...summaries.map((Summary) => ({ + Kind: 'Oversight', + Mode: 'Formal', + Participation: 'Vote', + Stage: 'Questions', + Summary, + })), + ...(request?.Scope === 'Current' + ? [ + { + Kind: 'Public', + Mode: 'Suggestion', + Participation: 'Consultation', + Stage: 'Rejected', + Summary: { + ...entriesResponse.Data.$values[0], + ID: '66a84559744ed757b46f8000', + Subject: '不应公开的已拒绝事项', + }, + }, + ] + : []), + ], + }, + }), + }) + }) + await page.route('**/api/Users/Authenticate', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(activitiesResponse), + }) + }) + await page.route('**/api/Democracy/GetMatter', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + Kind: 'Oversight', + Mode: 'Formal', + Participation: 'Vote', + Stage: 'Questions', + Summary: entriesResponse.Data.$values[0], + VoteActivity: { + ...activitiesResponse.Data.Activities[0], + InternalLink: '', + }, + }, + }), + }) + }) + await page.route('**/api/Democracy/QueryContributions', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + Entries: [ + { + AuthorAlias: '晨星记录员', + AuthorPosition: 'Oldtimer', + Content: '请补充适用条例的生效日期。', + CreatedAt: '2026-08-29T10:20:00+08:00', + ID: '66a84559744ed757b46f8101', + }, + ], + }, + }), + }) + }) + }) + + test('展示卷宗、身份标签和现有匿名投票', async ({ page }) => { + await page.goto('/#/d') + await waitForPageReady(page) + + await expect(page.getByRole('heading', { name: '民主墙' })).toBeVisible() + await expect(page.getByText('精选决议', { exact: true })).toHaveCount(0) + await expect(page.getByText('关于公开指控处理流程的卷宗')).toBeVisible() + await expect(page.getByText('不应公开的已拒绝事项')).toHaveCount(0) + await expect(page.getByText('Editor')).toHaveCount(0) + await expect(page.getByRole('link', { name: '查看详情' }).first()).toHaveAttribute( + 'href', + '#/d/matter/66a84559744ed757b46f8917', + ) + await expect(page.getByRole('link', { name: '参与质询' })).toHaveCount(0) + + await page.getByRole('link', { name: '查看详情' }).first().click() + await expect(page.getByRole('heading', { name: '建议收集与质询' })).toBeVisible() + await expect(page.getByText('晨星记录员', { exact: true })).toBeVisible() + await expect(page.getByText('Oldtimer', { exact: true })).toBeVisible() + await expect(page.getByText('请补充适用条例的生效日期。', { exact: true })).toBeVisible() + await page.getByText('2. 投票阶段', { exact: true }).click() + await expect(page.getByRole('button', { name: /同意修订/ })).toBeVisible() + }) + + test('Oldtimer 通过社区接口提交匿名提议', async ({ page }) => { + let submission: Record | undefined + await page.route('**/api/Democracy/SubmitMatter', async (route) => { + submission = route.request().postDataJSON() + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + Kind: 'Public', + Mode: 'Suggestion', + Participation: 'Consultation', + Stage: 'PendingReview', + Summary: { + ...entriesResponse.Data.$values[0], + Anonymous: true, + ID: '66a84559744ed757b46f8999', + Subject: submission?.Subject, + Description: [submission?.Description], + Tags: ['民主墙', '匿名提议', '公共议案', '待审核'], + User: { + ...entriesResponse.Data.$values[0].User, + ID: '', + Nickname: submission?.Alias, + Verification: undefined, + }, + }, + }, + }), + }) + }) + await page.goto('/#/d') + await waitForPageReady(page) + + await expect(page.getByRole('link', { name: '发起事务' })).toHaveCount(0) + await page.getByRole('link', { name: '匿名提议' }).click() + await expect(page.getByLabel('匿名化名')).toHaveValue('Oldtimer测试化名') + await expect(page.getByText('公共事务', { exact: true })).toHaveCount(0) + await expect(page.getByText('管理监察', { exact: true })).toHaveCount(0) + await expect(page.getByText(/匿名提议仅可进行意见征集/)).toBeVisible() + await expect(page.getByPlaceholder('选项 1')).toHaveCount(0) + await page.getByRole('textbox', { name: '标题', exact: true }).fill('建议公开条例修订时间表') + await page + .locator('.democracy-editor .cm-content') + .fill('建议在民主墙公开修订节点、负责人和预计投票时间,便于社区持续跟进。') + await page.getByRole('button', { name: '发布' }).click() + + await expect.poll(() => submission?.Mode).toBe('Suggestion') + expect(submission?.Alias).toBe('Oldtimer测试化名') + expect(submission?.Kind).toBe('Public') + expect(submission?.Participation).toBe('Consultation') + expect(submission?.ClientRequestID).toEqual(expect.any(String)) + expect(JSON.stringify(submission)).not.toContain('6666ff550b5f97d6e49d12d7') + await expect(page).toHaveURL(/#\/d\/matter\/66a84559744ed757b46f8999$/) + }) + + test('撤销名单中的 Oldtimer 无法匿名提议', async ({ page }) => { + await page.unroute('**/api/Democracy/GetContext') + await page.route('**/api/Democracy/GetContext', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + Status: 200, + Message: '', + Data: { + ApiVersion: 1, + Permissions: { + CanContribute: false, + CanInitiate: false, + CanModerate: false, + CanSuggest: true, + CanTransition: false, + }, + Profile: { Alias: '受限用户', CanPublish: false, ModifiedAt: '' }, + }, + }), + }) + }) + await page.goto('/#/d/new?mode=anonymous') + await expect(page.getByText(/被撤销资格的账号无法提交/)).toBeVisible() + await expect(page.getByRole('button', { name: '发布' })).toBeDisabled() + }) + + test('权限接口失败后可以重试', async ({ page }) => { + await page.unroute('**/api/Democracy/GetContext') + let requests = 0 + await page.route('**/api/Democracy/GetContext', async (route) => { + requests += 1 + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify( + requests === 1 + ? { Status: 503, Message: 'temporarily unavailable', Data: null } + : { + Status: 200, + Message: '', + Data: { + ApiVersion: 1, + Permissions: { + CanContribute: true, + CanInitiate: false, + CanModerate: false, + CanSuggest: true, + CanTransition: false, + }, + Profile: { Alias: '恢复后的化名', CanPublish: true, ModifiedAt: '' }, + }, + }, + ), + }) + }) + + await page.goto('/#/d/new?mode=anonymous') + await expect(page.getByText('暂时无法确认发布权限,请稍后重试。')).toBeVisible() + await page.getByRole('button', { name: '重试' }).click() + await expect(page.getByLabel('匿名化名')).toHaveValue('恢复后的化名') + await expect(page.getByRole('button', { name: '发布' })).toBeDisabled() + }) +}) diff --git a/scripts/tests/navigation.spec.ts b/scripts/tests/navigation.spec.ts index 53f64f6d..2fcb4df1 100644 --- a/scripts/tests/navigation.spec.ts +++ b/scripts/tests/navigation.spec.ts @@ -25,6 +25,7 @@ test.describe('路由与导航', () => { { path: '/', name: '首页' }, { path: '/b', name: '黑洞' }, { path: '/n', name: '通知' }, + { path: '/d', name: '民主墙' }, { path: '/p/Discussion/66a84559744ed757b46f8917', name: '作品详情' }, { path: '/c/Discussion/66a84559744ed757b46f8917/test', name: '评论' }, { path: '/u/6666ff550b5f97d6e49d12d7', name: '用户资料' }, @@ -80,5 +81,4 @@ test.describe('路由与导航', () => { // 应回到首页 await assertNoWhiteScreen(page) }) - }) diff --git a/scripts/tests/notifications.spec.ts b/scripts/tests/notifications.spec.ts index 274c178d..49ab5fb8 100644 --- a/scripts/tests/notifications.spec.ts +++ b/scripts/tests/notifications.spec.ts @@ -78,4 +78,25 @@ test.describe('通知中心 (Notifications)', () => { await assertNoWhiteScreen(page) }) + + test('民主墙通知精确跳转到对应阶段', async ({ page }) => { + const matterId = '66d100000000000000000002' + await page.goto('/src/services/democracyNotification.ts') + const targets = await page.evaluate(async (id) => { + const modulePath = '/src/services/democracyNotification.ts' + const { getDemocracyNotificationTarget } = await import(modulePath) + return { + voting: getDemocracyNotificationTarget({ DemocracyMatterID: id, DemocracyStage: 'Voting' }), + archived: getDemocracyNotificationTarget({ + DemocracyMatterID: id, + DemocracyStage: 'Archived', + }), + invalid: getDemocracyNotificationTarget({ DemocracyMatterID: 'invalid' }), + } + }, matterId) + + expect(targets.voting).toBe(`/d/matter/${matterId}?stage=vote`) + expect(targets.archived).toBe(`/d/matter/${matterId}?scope=history`) + expect(targets.invalid).toBeNull() + }) }) diff --git a/scripts/tests/playwright.local.config.ts b/scripts/tests/playwright.local.config.ts new file mode 100644 index 00000000..8a7ee16f --- /dev/null +++ b/scripts/tests/playwright.local.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import baseConfig from './playwright.config' + +export default defineConfig({ + ...baseConfig, + outputDir: join(tmpdir(), 'plweb-democracy-playwright-results'), + reporter: [['list']], + use: { + ...baseConfig.use, + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? baseConfig.use?.baseURL, + }, + webServer: undefined, +}) diff --git a/scripts/tests/test-helpers.ts b/scripts/tests/test-helpers.ts index db86fc7e..09e2c674 100644 --- a/scripts/tests/test-helpers.ts +++ b/scripts/tests/test-helpers.ts @@ -32,13 +32,16 @@ export async function injectLoginState(page: Page) { await page.goto('/') } -export async function injectLoginStateWithoutNavigation(page: Page) { - await page.addInitScript(() => { +export async function injectLoginStateWithoutNavigation( + page: Page, + overrides: { userId?: string; verification?: string } = {}, +) { + await page.addInitScript((loginOverrides) => { const authInfo = { value: { token: 'pKexl3M9jB1iWX2tyCPh6udYmEOFUnRz', authCode: 'RaQwt53Jnr9CSuUTH1kjsxqB40cV2fFD', - userId: '6666ff550b5f97d6e49d12d7', + userId: loginOverrides.userId || '6666ff550b5f97d6e49d12d7', }, time: Date.now(), maxAgeMs: 30 * 24 * 60 * 60 * 1000, @@ -47,11 +50,11 @@ export async function injectLoginStateWithoutNavigation(page: Page) { const userInfo = { value: { - ID: '6666ff550b5f97d6e49d12d7', + ID: loginOverrides.userId || '6666ff550b5f97d6e49d12d7', Nickname: 'TestUser', Avatar: 1, AvatarRegion: 1, - Verification: 'user', + Verification: loginOverrides.verification || 'user', Gold: 100, Diamond: 50, Level: 5, @@ -84,7 +87,7 @@ export async function injectLoginStateWithoutNavigation(page: Page) { maxAgeMs: 365 * 24 * 60 * 60 * 1000, } localStorage.setItem('cookieConsent', JSON.stringify(cookieConsent)) - }) + }, overrides) } export async function waitForPageReady(page: Page, options?: { timeout?: number }) { diff --git a/src/components/democracy/AnonymousVoteCard.vue b/src/components/democracy/AnonymousVoteCard.vue new file mode 100644 index 00000000..4409749d --- /dev/null +++ b/src/components/democracy/AnonymousVoteCard.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/components/democracy/DemocracyAccountControls.vue b/src/components/democracy/DemocracyAccountControls.vue new file mode 100644 index 00000000..ed4b7fe9 --- /dev/null +++ b/src/components/democracy/DemocracyAccountControls.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/components/democracy/DemocracyContributionCard.vue b/src/components/democracy/DemocracyContributionCard.vue new file mode 100644 index 00000000..0aaf5312 --- /dev/null +++ b/src/components/democracy/DemocracyContributionCard.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/components/democracy/DemocracyEntryCard.vue b/src/components/democracy/DemocracyEntryCard.vue new file mode 100644 index 00000000..fb85389b --- /dev/null +++ b/src/components/democracy/DemocracyEntryCard.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/src/components/democracy/DemocracyInvitationCard.vue b/src/components/democracy/DemocracyInvitationCard.vue new file mode 100644 index 00000000..50577a0d --- /dev/null +++ b/src/components/democracy/DemocracyInvitationCard.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/components/democracy/DemocracyManagementPanel.vue b/src/components/democracy/DemocracyManagementPanel.vue new file mode 100644 index 00000000..02c48ee5 --- /dev/null +++ b/src/components/democracy/DemocracyManagementPanel.vue @@ -0,0 +1,238 @@ + + + + diff --git a/src/components/democracy/DemocracyReportDialog.vue b/src/components/democracy/DemocracyReportDialog.vue new file mode 100644 index 00000000..669ec82c --- /dev/null +++ b/src/components/democracy/DemocracyReportDialog.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/components/democracy/DemocracyVoteOptionEditor.vue b/src/components/democracy/DemocracyVoteOptionEditor.vue new file mode 100644 index 00000000..51fd15e7 --- /dev/null +++ b/src/components/democracy/DemocracyVoteOptionEditor.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/components/democracy/theme.ts b/src/components/democracy/theme.ts new file mode 100644 index 00000000..9bb66513 --- /dev/null +++ b/src/components/democracy/theme.ts @@ -0,0 +1,11 @@ +import type { GlobalThemeOverrides } from 'naive-ui' + +export const democracyTheme: GlobalThemeOverrides = { + common: { + primaryColor: '#0185c5', + primaryColorHover: '#007bff', + primaryColorPressed: '#006da3', + primaryColorSuppl: '#0185c5', + borderRadius: '4px', + }, +} diff --git a/src/components/messages/NotificationItem.vue b/src/components/messages/NotificationItem.vue index 7a1eb453..039eec05 100644 --- a/src/components/messages/NotificationItem.vue +++ b/src/components/messages/NotificationItem.vue @@ -38,12 +38,14 @@ diff --git a/src/components/utils/CommentComposer.vue b/src/components/utils/CommentComposer.vue index 4af9215f..8ad44ef6 100644 --- a/src/components/utils/CommentComposer.vue +++ b/src/components/utils/CommentComposer.vue @@ -105,8 +105,8 @@ async function handleFocus() { align-items: flex-end; gap: 8px; padding: 8px; - border-radius: calc(var(--card-radius) + 2px); - border: 1px solid var(--border-soft); + border: 1px solid var(--border-soft, #e5e7eb); + border-radius: calc(var(--card-radius, 8px) + 2px); background: rgba(255, 255, 255, 0.96); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); } diff --git a/src/components/utils/Footer.vue b/src/components/utils/Footer.vue index 92b82ece..b50c8eaa 100644 --- a/src/components/utils/Footer.vue +++ b/src/components/utils/Footer.vue @@ -33,6 +33,22 @@ {{ $t('footer.blackHole') }} + + + + + {{ $t('footer.democracy') }} +