07_discussions.php 9.6 KB

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