07_discussions.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. <?php
  2. use \WikipaliApi\Api\DiscussionsApi;
  3. use \WikipaliApi\Model\PatchApiV3DiscussionsDiscussionRequest;
  4. use \WikipaliApi\Model\PostApiV3DiscussionsBatchCountRequest;
  5. use \WikipaliApi\Model\PostApiV3DiscussionsBatchCountRequestTargetsInner;
  6. use \WikipaliApi\Model\PostApiV3DiscussionsDiscussionRepliesRequest;
  7. use \WikipaliApi\Model\PostApiV3DiscussionsRequest;
  8. /*
  9. * 讨论(评论线程):话题列表 / 统计 / 单条 / 回复 / 新建 / PATCH / 软删除。
  10. *
  11. * ⚠️ 写用例在所有服务器上真实执行。挂在 fixtures 的 discussion_sentence 上(测试账号
  12. * 自己的句子),带 notify=false 不发通知,最后删掉。删除是软删:行还在表里、标了
  13. * deleted_at,对外已不可见(再 GET 404)。
  14. * 权限矩阵(basic 角色、匿名可见性、谁能关闭)由 api-v13 的 DiscussionV3Test 覆盖。
  15. */
  16. function discussion_target(): string
  17. {
  18. return fixtures()['discussion_sentence'];
  19. }
  20. /* ---- 只读 ---- */
  21. test('GET /v3/discussions?target_type=sentence&target_id=… → 200', function () {
  22. $res = request(fn () => api(DiscussionsApi::class)->getApiV3DiscussionsWithHttpInfo('sentence', discussion_target()));
  23. assert_status(200, $res, 'discussions');
  24. assert_has_key('data', $res['body'], 'discussions');
  25. assert_has_key('meta', $res['body'], 'discussions');
  26. });
  27. test('GET /v3/discussions(缺 target_id)→ 422', function () {
  28. $res = raw('GET', '/v3/discussions', ['target_type' => 'sentence']);
  29. assert_status(422, $res, 'discussions 缺 target_id');
  30. assert_field_error('target_id', $res, 'discussions 缺 target_id');
  31. });
  32. test('GET /v3/discussions(非法 target_type)→ 422', function () {
  33. $res = raw('GET', '/v3/discussions', ['target_type' => 'channel', 'target_id' => discussion_target()]);
  34. assert_status(422, $res, 'discussions 非法 target_type');
  35. assert_field_error('target_type', $res, 'discussions 非法 target_type');
  36. });
  37. test('GET /v3/discussions/summary → 200,含 status_counts 与 creatable_types', function () {
  38. $res = request(fn () => api(DiscussionsApi::class)->getApiV3DiscussionsSummaryWithHttpInfo('sentence', discussion_target()));
  39. assert_status(200, $res, 'summary');
  40. $data = $res['body']['data'] ?? null;
  41. assert_has_key('status_counts', $data, 'summary.data');
  42. assert_has_key('creatable_types', $data, 'summary.data');
  43. // 匿名:不能发起任何话题
  44. assert_eq([], $data['creatable_types'], '匿名 creatable_types');
  45. });
  46. test('GET /v3/discussions/{不存在} → 404', function () {
  47. $res = raw('GET', '/v3/discussions/00000000-0000-4000-8000-000000000000');
  48. assert_status(404, $res, 'discussions 不存在');
  49. assert_problem($res, 'discussions 不存在');
  50. });
  51. test('PUT /v3/discussions/{id} → 405(只有 PATCH)', function () {
  52. $res = raw('PUT', '/v3/discussions/00000000-0000-4000-8000-000000000000', null, ['title' => 'x'], required_token());
  53. assert_status(405, $res, 'PUT');
  54. });
  55. test('POST /v3/discussions/batch-count → 200,按请求顺序、没有讨论的给 0', function () {
  56. $sentence = new PostApiV3DiscussionsBatchCountRequestTargetsInner();
  57. $sentence->setType('sentence');
  58. $sentence->setId(discussion_target());
  59. $wbw = new PostApiV3DiscussionsBatchCountRequestTargetsInner();
  60. $wbw->setType('wbw');
  61. $wbw->setId('00000000-0000-4000-8000-000000000000');
  62. $body = new PostApiV3DiscussionsBatchCountRequest();
  63. $body->setTargets([$sentence, $wbw]);
  64. $res = request(fn () => api(DiscussionsApi::class)->postApiV3DiscussionsBatchCountWithHttpInfo($body));
  65. assert_status(200, $res, 'batch-count');
  66. assert_eq(discussion_target(), $res['body']['data'][0]['target_id'] ?? null, 'batch-count 顺序');
  67. assert_eq(0, $res['body']['data'][1]['total'] ?? null, 'batch-count 没有讨论给 0');
  68. assert_eq(0, $res['body']['data'][0]['mine'] ?? null, '匿名 mine 恒为 0');
  69. });
  70. test('POST /v3/discussions/batch-count(空 / 非法类型)→ 422', function () {
  71. assert_status(422, raw('POST', '/v3/discussions/batch-count', null, ['targets' => []]), 'batch-count 空');
  72. $bad = raw('POST', '/v3/discussions/batch-count', null, ['targets' => [['type' => 'article', 'id' => discussion_target()]]]);
  73. assert_field_error('targets.0.type', $bad, 'batch-count 非法类型');
  74. });
  75. /* ---- 无 token → 401 ---- */
  76. test('POST /v3/discussions(无 token)→ 401', function () {
  77. $body = new PostApiV3DiscussionsRequest();
  78. $body->setTargetType('sentence');
  79. $body->setTargetId(discussion_target());
  80. $body->setType('discussion');
  81. $body->setTitle('api-test 无 token');
  82. $res = request(fn () => api(DiscussionsApi::class)->postApiV3DiscussionsWithHttpInfo($body));
  83. assert_status(401, $res, 'POST 无 token');
  84. assert_problem($res, 'POST 无 token');
  85. });
  86. /* ---- 带 token:一整轮 ---- */
  87. test('discussions 一轮:建话题 → 回复 → 关闭后回复 409 → 重开 → 改类型 → 无 token 删 401 → 删回复 → 删话题(软删)→ 再查 404', function () {
  88. $token = required_token();
  89. $client = api(DiscussionsApi::class, $token);
  90. $stamp = 'api-test-' . date('Ymd-His');
  91. // 建话题:201,带 abilities,作者能改能删
  92. $body = new PostApiV3DiscussionsRequest();
  93. $body->setTargetType('sentence');
  94. $body->setTargetId(discussion_target());
  95. $body->setType('issue');
  96. $body->setSeverity('warning');
  97. $body->setTitle($stamp);
  98. $body->setContent('由 api-test 建立,测完即删');
  99. $body->setNotify(false);
  100. $create = request(fn () => $client->postApiV3DiscussionsWithHttpInfo($body));
  101. assert_status(201, $create, '建话题');
  102. $topic = $create['body']['data'] ?? [];
  103. $id = $topic['id'] ?? null;
  104. if (! $id) {
  105. fail('建话题:应返回 id');
  106. }
  107. try {
  108. assert_eq('warning', $topic['severity'] ?? null, '建话题 severity');
  109. assert_eq(true, $topic['abilities']['update'] ?? null, '作者 abilities.update');
  110. assert_eq(true, $topic['abilities']['delete'] ?? null, '作者 abilities.delete');
  111. // severity 与类型不匹配 → 422
  112. $bad = raw('POST', '/v3/discussions', null, [
  113. 'target_type' => 'sentence', 'target_id' => discussion_target(),
  114. 'type' => 'discussion', 'severity' => 'error', 'title' => $stamp, 'notify' => false,
  115. ], $token);
  116. assert_status(422, $bad, 'discussion 带 severity');
  117. assert_field_error('severity', $bad, 'discussion 带 severity');
  118. // 角标:数到这个话题,而且是我发起的
  119. $badge = raw('POST', '/v3/discussions/batch-count', null, ['targets' => [['type' => 'sentence', 'id' => discussion_target()]]], $token);
  120. if (($badge['body']['data'][0]['mine'] ?? 0) < 1) {
  121. fail('batch-count:mine 应至少为 1,body=' . json_encode($badge['body']));
  122. }
  123. // 列表里查得到
  124. $list = request(fn () => $client->getApiV3DiscussionsWithHttpInfo('sentence', discussion_target(), 50, 'issue'));
  125. assert_status(200, $list, '列表');
  126. $ids = array_column($list['body']['data'] ?? [], 'id');
  127. if (! in_array($id, $ids, true)) {
  128. fail('列表:查不到刚建的话题');
  129. }
  130. // 回复:201,类型跟着话题
  131. $reply = new PostApiV3DiscussionsDiscussionRepliesRequest();
  132. $reply->setContent('api-test 回复');
  133. $reply->setNotify(false);
  134. $r = request(fn () => $client->postApiV3DiscussionsDiscussionRepliesWithHttpInfo($id, $reply));
  135. assert_status(201, $r, '回复');
  136. $replyId = $r['body']['data']['id'] ?? null;
  137. assert_eq($id, $r['body']['data']['parent_id'] ?? null, '回复 parent_id');
  138. assert_eq('issue', $r['body']['data']['type'] ?? null, '回复类型跟话题');
  139. // 回复上改话题专属字段(标题、状态、类型)→ 422
  140. $topicOnly = raw('PATCH', "/v3/discussions/{$replyId}", null, ['title' => 'x'], $token);
  141. assert_field_error('title', $topicOnly, 'PATCH 回复的 title');
  142. // 不能回复一条回复 → 422
  143. $nested = raw('POST', "/v3/discussions/{$replyId}/replies", null, ['content' => 'x', 'notify' => false], $token);
  144. assert_status(422, $nested, '回复回复');
  145. $replies = request(fn () => $client->getApiV3DiscussionsDiscussionRepliesWithHttpInfo($id));
  146. assert_status(200, $replies, '回复列表');
  147. assert_eq([$replyId], array_column($replies['body']['data'] ?? [], 'id'), '回复列表内容');
  148. // 关闭 → 回复 409 → 重新打开
  149. $close = new PatchApiV3DiscussionsDiscussionRequest();
  150. $close->setStatus('close');
  151. $closed = request(fn () => $client->patchApiV3DiscussionsDiscussionWithHttpInfo($id, $close));
  152. assert_status(200, $closed, '关闭');
  153. assert_eq(false, $closed['body']['data']['abilities']['reply'] ?? null, '关闭后 abilities.reply');
  154. $conflict = raw('POST', "/v3/discussions/{$id}/replies", null, ['content' => 'x', 'notify' => false], $token);
  155. assert_status(409, $conflict, '关闭后回复');
  156. assert_problem($conflict, '关闭后回复');
  157. $open = new PatchApiV3DiscussionsDiscussionRequest();
  158. $open->setStatus('active');
  159. assert_status(200, request(fn () => $client->patchApiV3DiscussionsDiscussionWithHttpInfo($id, $open)), '重新打开');
  160. // 改类型:issue → discussion,severity 被清掉,回复跟着改
  161. $convert = new PatchApiV3DiscussionsDiscussionRequest();
  162. $convert->setType('discussion');
  163. $converted = request(fn () => $client->patchApiV3DiscussionsDiscussionWithHttpInfo($id, $convert));
  164. assert_status(200, $converted, '改类型');
  165. assert_eq(null, $converted['body']['data']['severity'] ?? null, '改类型后 severity');
  166. $reread = request(fn () => $client->getApiV3DiscussionsDiscussionWithHttpInfo($replyId));
  167. assert_eq('discussion', $reread['body']['data']['type'] ?? null, '回复类型跟着改');
  168. // 无 token 删:路由绑定先于鉴权,所以要拿真实存在的 id 才测得到 401
  169. $noToken = raw('DELETE', "/v3/discussions/{$replyId}");
  170. assert_status(401, $noToken, '无 token DELETE');
  171. assert_problem($noToken, '无 token DELETE');
  172. // 删回复:200 + 带 deleted_at 的资源;话题回复数归零
  173. $delReply = request(fn () => $client->deleteApiV3DiscussionsDiscussionWithHttpInfo($replyId));
  174. assert_status(200, $delReply, '删回复');
  175. if (empty($delReply['body']['data']['deleted_at'] ?? null)) {
  176. fail('删回复:响应应带 deleted_at');
  177. }
  178. $after = request(fn () => $client->getApiV3DiscussionsDiscussionWithHttpInfo($id));
  179. assert_eq(0, $after['body']['data']['reply_count'] ?? null, '删回复后 reply_count');
  180. } finally {
  181. // 无论中途哪步失败都清干净:先删剩下的回复,否则话题会留成墓碑
  182. $left = request(fn () => $client->getApiV3DiscussionsDiscussionRepliesWithHttpInfo($id));
  183. foreach (array_column($left['body']['data'] ?? [], 'id') as $rid) {
  184. request(fn () => $client->deleteApiV3DiscussionsDiscussionWithHttpInfo($rid));
  185. }
  186. $del = request(fn () => $client->deleteApiV3DiscussionsDiscussionWithHttpInfo($id));
  187. }
  188. assert_status(200, $del, '删话题');
  189. // 没有回复的话题删掉后不留墓碑 → 404
  190. $gone = raw('GET', "/v3/discussions/{$id}", null, null, $token);
  191. assert_status(404, $gone, '删后再查');
  192. });