DiscussionV3Test.php 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. <?php
  2. use App\Models\Discussion;
  3. use App\Models\UserInfo;
  4. use Illuminate\Foundation\Testing\RefreshDatabase;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Log;
  7. use Illuminate\Support\Str;
  8. uses(RefreshDatabase::class);
  9. /**
  10. * v3 discussions:评论线程(话题 + 一层回复),底层是 discussions 表。
  11. *
  12. * 这里断言的是形状而非契约(spectator 未安装,见 v3-resource skill 第 3 步)。
  13. *
  14. * 端点:
  15. * GET /api/v3/discussions?target_type=&target_id= 话题列表(可选登录)
  16. * GET /api/v3/discussions/summary?target_type=&target_id= 概况(可选登录)
  17. * POST /api/v3/discussions 发起话题(登录)
  18. * GET /api/v3/discussions/{d} 单条(可选登录,含墓碑)
  19. * PATCH /api/v3/discussions/{d} 局部更新(登录)
  20. * DELETE /api/v3/discussions/{d} 软删除(登录)
  21. * GET /api/v3/discussions/{d}/replies 回复列表(可选登录)
  22. * POST /api/v3/discussions/{d}/replies 回复(登录)
  23. */
  24. function makeDiscussion(array $attrs = []): Discussion
  25. {
  26. return Discussion::forceCreate(array_merge([
  27. 'res_type' => 'sentence',
  28. 'res_id' => (string) Str::uuid(),
  29. 'type' => 'discussion',
  30. 'title' => 'topic',
  31. 'content' => 'body',
  32. 'editor_uid' => makeStudio('d-'.Str::random(8)),
  33. ], $attrs));
  34. }
  35. /**
  36. * 一个句子目标:channel 归 $owner 所有,返回句子 uid。
  37. */
  38. function makeTargetSentence(string $owner): string
  39. {
  40. return makeSentence(makeChannel($owner), 1, 1, 1, 'sentence text')->uid;
  41. }
  42. function makeBasicUser(string $name): string
  43. {
  44. $uid = makeStudio($name);
  45. UserInfo::where('userid', $uid)->update(['role' => json_encode(['basic'])]);
  46. return $uid;
  47. }
  48. /* ------------------------------------------------------------------ 列表 */
  49. it('lists only topics of the target, with reply counts and abilities', function () {
  50. $owner = makeStudio('owner');
  51. $target = makeTargetSentence($owner);
  52. $topic = makeDiscussion(['res_id' => $target, 'editor_uid' => $owner]);
  53. makeDiscussion(['res_id' => $target, 'parent' => $topic->id, 'title' => null]);
  54. makeDiscussion(['res_id' => $target, 'type' => 'commentary']); // 不带 type 时不列批注
  55. makeDiscussion(); // 别的对象
  56. $json = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($owner))
  57. ->assertOk()
  58. ->json();
  59. expect($json['data'])->toHaveCount(1)
  60. ->and($json['data'][0])->toHaveKeys(['id', 'target_type', 'target_id', 'parent_id', 'type', 'severity', 'status', 'editor', 'abilities', 'deleted_at'])
  61. ->and($json['data'][0]['reply_count'])->toBe(1)
  62. ->and($json['data'][0]['abilities'])->toBe(['update' => true, 'delete' => true, 'close' => true, 'reply' => true])
  63. ->and($json['meta']['total'])->toBe(1);
  64. });
  65. it('hides private topics from anonymous viewers, but not issues', function () {
  66. $target = makeTargetSentence(makeStudio('owner'));
  67. makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
  68. $issue = makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
  69. makeDiscussion(['res_id' => $target, 'type' => 'qa']);
  70. $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&sort=created_at")
  71. ->assertOk()
  72. ->json('data');
  73. // issue 会注入公开的阅读页,所以对匿名也可见
  74. expect(array_column($data, 'type'))->toEqualCanonicalizing(['issue', 'qa'])
  75. ->and($data[0]['abilities']['reply'])->toBeFalse();
  76. $this->getJson("/api/v3/discussions/{$issue->id}")->assertOk();
  77. });
  78. it('shows a basic user only their own collaboration topics on targets they cannot manage', function () {
  79. $target = makeTargetSentence(makeStudio('owner'));
  80. $basic = makeBasicUser('basic');
  81. makeDiscussion(['res_id' => $target, 'editor_uid' => $basic, 'title' => 'mine']);
  82. $others = makeDiscussion(['res_id' => $target, 'title' => 'others']);
  83. $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($basic))
  84. ->assertOk()
  85. ->json('data');
  86. expect($data)->toHaveCount(1)
  87. ->and($data[0]['title'])->toBe('mine');
  88. // 单条与回复列表守同一条规则(v2 只在列表上判断)
  89. $this->getJson("/api/v3/discussions/{$others->id}", authHeader($basic))->assertNotFound();
  90. $this->getJson("/api/v3/discussions/{$others->id}/replies", authHeader($basic))->assertNotFound();
  91. });
  92. it('lets a basic user see all topics on a target they can manage', function () {
  93. $basic = makeBasicUser('basic-owner');
  94. $target = makeTargetSentence($basic);
  95. makeDiscussion(['res_id' => $target]);
  96. makeDiscussion(['res_id' => $target]);
  97. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($basic))
  98. ->assertOk()
  99. ->assertJsonCount(2, 'data');
  100. });
  101. it('filters topics by type, status and severity', function () {
  102. $owner = makeStudio('owner');
  103. $target = makeTargetSentence($owner);
  104. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
  105. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'suggestion']);
  106. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'warning', 'status' => 'close']);
  107. makeDiscussion(['res_id' => $target]);
  108. $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
  109. $this->getJson("{$base}&type=issue", authHeader($owner))->assertJsonCount(3, 'data');
  110. $this->getJson("{$base}&type=issue&status=active", authHeader($owner))->assertJsonCount(2, 'data');
  111. $this->getJson("{$base}&severity=error,warning", authHeader($owner))->assertJsonCount(2, 'data');
  112. });
  113. it('rejects an invalid list query', function () {
  114. $target = (string) Str::uuid();
  115. $this->getJson('/api/v3/discussions?target_type=sentence')->assertStatus(422);
  116. $this->getJson("/api/v3/discussions?target_type=chapter&target_id={$target}")->assertStatus(422);
  117. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&type=feedback")->assertStatus(422);
  118. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&per_page=1000")->assertStatus(422);
  119. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&sort=title")->assertStatus(422);
  120. });
  121. it('keeps a deleted topic with replies as a tombstone and drops one without replies', function () {
  122. $owner = makeStudio('owner');
  123. $target = makeTargetSentence($owner);
  124. $withReplies = makeDiscussion(['res_id' => $target, 'title' => 'kept']);
  125. makeDiscussion(['res_id' => $target, 'parent' => $withReplies->id, 'title' => null]);
  126. $withReplies->delete();
  127. makeDiscussion(['res_id' => $target, 'title' => 'gone'])->delete();
  128. $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($owner))
  129. ->assertOk()
  130. ->json('data');
  131. expect($data)->toHaveCount(1)
  132. ->and($data[0]['id'])->toBe($withReplies->id)
  133. ->and($data[0]['deleted_at'])->not->toBeNull()
  134. ->and($data[0]['title'])->toBeNull()
  135. ->and($data[0]['content'])->toBeNull()
  136. ->and($data[0]['abilities'])->toBe(['update' => false, 'delete' => false, 'close' => false, 'reply' => false]);
  137. // 墓碑的回复照样可读
  138. $this->getJson("/api/v3/discussions/{$withReplies->id}/replies", authHeader($owner))
  139. ->assertOk()
  140. ->assertJsonCount(1, 'data');
  141. });
  142. it('loads a page of topics with a constant number of queries', function () {
  143. $owner = makeStudio('owner');
  144. $target = makeTargetSentence($owner);
  145. $countQueriesFor = function (int $rows) use ($owner, $target) {
  146. Discussion::where('res_id', $target)->forceDelete();
  147. foreach (range(1, $rows) as $i) {
  148. makeDiscussion(['res_id' => $target, 'editor_uid' => makeStudio("n1-{$rows}-{$i}")]);
  149. }
  150. $headers = authHeader($owner);
  151. DB::flushQueryLog();
  152. DB::enableQueryLog();
  153. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", $headers)->assertOk();
  154. $count = count(DB::getQueryLog());
  155. DB::disableQueryLog();
  156. return $count;
  157. };
  158. // 先热身一次:UserService 是 singleton,查看者自己的摘要只在第一次请求时查库
  159. $countQueriesFor(1);
  160. // 正文渲染(MdRender)是逐行的,内容不带模板时不查库;这里守的是编辑者与权限不逐行查
  161. expect($countQueriesFor(12))->toBe($countQueriesFor(2));
  162. });
  163. /* ------------------------------------------------------------------ 概况 */
  164. it('summarises status counts and creatable types', function () {
  165. $owner = makeStudio('owner');
  166. $target = makeTargetSentence($owner);
  167. makeDiscussion(['res_id' => $target]);
  168. makeDiscussion(['res_id' => $target, 'status' => 'close']);
  169. makeDiscussion(['res_id' => $target, 'status' => 'close']);
  170. makeDiscussion(['res_id' => $target])->delete();
  171. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
  172. ->assertOk()
  173. ->assertJsonPath('data.status_counts', ['active' => 1, 'close' => 2])
  174. ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary']); // 句子的管理者能加批注
  175. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}")
  176. ->assertOk()
  177. ->assertJsonPath('data.status_counts', ['active' => 0, 'close' => 0])
  178. ->assertJsonPath('data.creatable_types', []);
  179. });
  180. /* ------------------------------------------------------------------ 发起话题 */
  181. it('creates a topic', function () {
  182. $user = makeStudio('author');
  183. $target = makeTargetSentence(makeStudio('owner'));
  184. $this->postJson('/api/v3/discussions', [
  185. 'target_type' => 'sentence',
  186. 'target_id' => $target,
  187. 'type' => 'issue',
  188. 'title' => '译文漏了一句',
  189. 'content' => '第二句没有译出',
  190. 'severity' => 'error',
  191. 'notify' => false,
  192. ], authHeader($user))
  193. ->assertCreated()
  194. ->assertJsonPath('data.type', 'issue')
  195. ->assertJsonPath('data.severity', 'error')
  196. ->assertJsonPath('data.parent_id', null)
  197. ->assertJsonPath('data.reply_count', 0)
  198. ->assertJsonPath('data.content_type', 'markdown');
  199. expect(Discussion::where('editor_uid', $user)->value('res_id'))->toBe($target);
  200. });
  201. it('requires login to create a topic', function () {
  202. $this->postJson('/api/v3/discussions', [
  203. 'target_type' => 'sentence',
  204. 'target_id' => (string) Str::uuid(),
  205. 'title' => 't',
  206. ])->assertUnauthorized();
  207. });
  208. it('validates severity against the topic type', function () {
  209. $user = makeStudio('author');
  210. $target = makeTargetSentence(makeStudio('owner'));
  211. $payload = ['target_type' => 'sentence', 'target_id' => $target, 'title' => 't', 'notify' => false];
  212. $this->postJson('/api/v3/discussions', $payload + ['type' => 'discussion', 'severity' => 'error'], authHeader($user))
  213. ->assertStatus(422)->assertJsonValidationErrors('severity');
  214. $this->postJson('/api/v3/discussions', $payload + ['type' => 'issue', 'severity' => 'urgent'], authHeader($user))
  215. ->assertStatus(422)->assertJsonValidationErrors('severity');
  216. $this->postJson('/api/v3/discussions', $payload + ['type' => 'help', 'severity' => 'urgent'], authHeader($user))
  217. ->assertStatus(403); // help 只能发在文章上
  218. });
  219. it('rejects a topic on a target that does not exist', function () {
  220. $this->postJson('/api/v3/discussions', [
  221. 'target_type' => 'sentence',
  222. 'target_id' => (string) Str::uuid(),
  223. 'title' => 't',
  224. 'notify' => false,
  225. ], authHeader(makeStudio('author')))
  226. ->assertStatus(422)
  227. ->assertJsonValidationErrors('target_id');
  228. });
  229. /* ------------------------------------------------------------------ 回复 */
  230. it('replies to a topic, inheriting its type and target', function () {
  231. $topic = makeDiscussion(['res_id' => makeTargetSentence(makeStudio('owner')), 'type' => 'issue', 'severity' => 'warning']);
  232. $user = makeStudio('replier');
  233. $this->postJson("/api/v3/discussions/{$topic->id}/replies", ['content' => '已修正', 'notify' => false], authHeader($user))
  234. ->assertCreated()
  235. ->assertJsonPath('data.parent_id', $topic->id)
  236. ->assertJsonPath('data.type', 'issue')
  237. ->assertJsonPath('data.target_id', $topic->res_id)
  238. ->assertJsonMissingPath('data.reply_count');
  239. expect($topic->fresh()->children_count)->toBe(1);
  240. });
  241. it('lists replies oldest first with the topic type', function () {
  242. $topic = makeDiscussion(['type' => 'qa']);
  243. $first = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'discussion', 'created_at' => now()->subHour()]);
  244. makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'qa']);
  245. $data = $this->getJson("/api/v3/discussions/{$topic->id}/replies")
  246. ->assertOk()
  247. ->json('data');
  248. expect($data)->toHaveCount(2)
  249. ->and($data[0]['id'])->toBe($first->id)
  250. ->and(array_column($data, 'type'))->toBe(['qa', 'qa']); // 历史数据里回复的 type 可能与话题不一致
  251. });
  252. it('refuses to reply to a reply or to a closed topic', function () {
  253. $user = makeStudio('replier');
  254. $topic = makeDiscussion();
  255. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id]);
  256. $closed = makeDiscussion(['status' => 'close']);
  257. $this->postJson("/api/v3/discussions/{$reply->id}/replies", ['content' => 'x', 'notify' => false], authHeader($user))
  258. ->assertStatus(422);
  259. $this->postJson("/api/v3/discussions/{$closed->id}/replies", ['content' => 'x', 'notify' => false], authHeader($user))
  260. ->assertStatus(409)
  261. ->assertJsonPath('type', fn ($type) => str_contains($type, 'discussion-closed'));
  262. $this->postJson("/api/v3/discussions/{$topic->id}/replies", ['notify' => false], authHeader($user))
  263. ->assertStatus(422);
  264. });
  265. /* ------------------------------------------------------------------ 修改 */
  266. it('patches only the fields that were sent', function () {
  267. $author = makeStudio('author');
  268. $topic = makeDiscussion(['editor_uid' => $author, 'title' => 'keep me', 'status' => 'close']);
  269. $this->patchJson("/api/v3/discussions/{$topic->id}", ['content' => 'new body'], authHeader($author))
  270. ->assertOk()
  271. ->assertJsonPath('data.content', 'new body')
  272. ->assertJsonPath('data.title', 'keep me')
  273. ->assertJsonPath('data.status', 'close'); // v2 的 PUT 在这里会把话题重新打开
  274. });
  275. it('lets the channel editor update someone else\'s topic', function () {
  276. $owner = makeStudio('owner');
  277. $topic = makeDiscussion(['res_id' => makeTargetSentence($owner)]);
  278. $this->patchJson("/api/v3/discussions/{$topic->id}", ['status' => 'close'], authHeader($owner))
  279. ->assertOk()
  280. ->assertJsonPath('data.status', 'close');
  281. $this->patchJson("/api/v3/discussions/{$topic->id}", ['content' => 'x'], authHeader(makeStudio('stranger')))
  282. ->assertForbidden();
  283. });
  284. it('rejects topic-only fields on a reply', function () {
  285. $author = makeStudio('author');
  286. $topic = makeDiscussion();
  287. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'editor_uid' => $author]);
  288. $this->patchJson("/api/v3/discussions/{$reply->id}", ['title' => 't'], authHeader($author))
  289. ->assertStatus(422)
  290. ->assertJsonValidationErrors('title');
  291. $this->patchJson("/api/v3/discussions/{$reply->id}", ['content' => 'fixed'], authHeader($author))
  292. ->assertOk();
  293. });
  294. it('converts a topic type, cascading to replies and clearing a severity the new type does not take', function () {
  295. $author = makeStudio('author');
  296. $topic = makeDiscussion(['editor_uid' => $author, 'type' => 'issue', 'severity' => 'error']);
  297. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'issue']);
  298. $this->patchJson("/api/v3/discussions/{$topic->id}", ['type' => 'discussion'], authHeader($author))
  299. ->assertOk()
  300. ->assertJsonPath('data.type', 'discussion')
  301. ->assertJsonPath('data.severity', null);
  302. expect($reply->fresh()->type)->toBe('discussion');
  303. $this->patchJson("/api/v3/discussions/{$topic->id}", ['severity' => 'error'], authHeader($author))
  304. ->assertStatus(422)
  305. ->assertJsonValidationErrors('severity');
  306. });
  307. /* ------------------------------------------------------------------ 批量角标 */
  308. it('counts active threads per target in request order, with zeros', function () {
  309. $owner = makeStudio('owner');
  310. $a = makeTargetSentence($owner);
  311. $b = makeTargetSentence($owner);
  312. $empty = (string) Str::uuid();
  313. makeDiscussion(['res_id' => $a, 'type' => 'qa', 'editor_uid' => $owner]);
  314. makeDiscussion(['res_id' => $a, 'type' => 'help']);
  315. makeDiscussion(['res_id' => $a, 'type' => 'qa', 'status' => 'close']); // 关闭的不数
  316. makeDiscussion(['res_id' => $a, 'type' => 'note']); // 批注不数
  317. makeDiscussion(['res_id' => $a, 'type' => 'qa'])->delete(); // 删除的不数
  318. $topic = makeDiscussion(['res_id' => $b, 'type' => 'qa']);
  319. makeDiscussion(['res_id' => $b, 'type' => 'qa', 'parent' => $topic->id]); // 回复不数
  320. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [
  321. ['type' => 'sentence', 'id' => $b],
  322. ['type' => 'sentence', 'id' => $a],
  323. ['type' => 'wbw', 'id' => $empty],
  324. ]], authHeader($owner))
  325. ->assertOk()
  326. ->assertJsonCount(3, 'data')
  327. ->assertJsonPath('data.0.target_id', $b)
  328. ->assertJsonPath('data.0.total', 1)
  329. ->assertJsonPath('data.0.mine', 0)
  330. ->assertJsonPath('data.1.target_id', $a)
  331. ->assertJsonPath('data.1.total', 2)
  332. ->assertJsonPath('data.1.mine', 1)
  333. ->assertJsonPath('data.2.target_type', 'wbw')
  334. ->assertJsonPath('data.2.total', 0);
  335. });
  336. it('applies list visibility to batch counts', function () {
  337. $owner = makeStudio('owner');
  338. $target = makeTargetSentence($owner);
  339. $basic = makeBasicUser('basic');
  340. makeDiscussion(['res_id' => $target, 'type' => 'qa']);
  341. makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
  342. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'editor_uid' => $basic]);
  343. $body = ['targets' => [['type' => 'sentence', 'id' => $target]]];
  344. makeDiscussion(['res_id' => $target, 'type' => 'discussion', 'editor_uid' => $basic]);
  345. $this->postJson('/api/v3/discussions/batch-count', $body)->assertJsonPath('data.0.total', 2); // 匿名:qa + issue(公开)
  346. $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($basic))->assertJsonPath('data.0.total', 3); // basic:再加自己的 discussion
  347. $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($owner))->assertJsonPath('data.0.total', 4); // 管理者:全部
  348. });
  349. it('rejects an invalid batch count request', function () {
  350. $this->postJson('/api/v3/discussions/batch-count', ['targets' => []])->assertStatus(422);
  351. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [['type' => 'article', 'id' => (string) Str::uuid()]]])
  352. ->assertStatus(422)->assertJsonValidationErrors('targets.0.type');
  353. $tooMany = array_fill(0, 501, ['type' => 'sentence', 'id' => (string) Str::uuid()]);
  354. $this->postJson('/api/v3/discussions/batch-count', ['targets' => $tooMany])->assertStatus(422);
  355. });
  356. it('counts a batch in a fixed number of queries', function () {
  357. $owner = makeStudio('owner');
  358. $targets = collect(range(1, 20))->map(function () use ($owner) {
  359. $id = makeTargetSentence($owner);
  360. makeDiscussion(['res_id' => $id, 'type' => 'qa']);
  361. return ['type' => 'sentence', 'id' => $id];
  362. })->all();
  363. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [$targets[0]]], authHeader($owner)); // 预热 UserService
  364. DB::enableQueryLog();
  365. $this->postJson('/api/v3/discussions/batch-count', ['targets' => $targets], authHeader($owner))->assertOk();
  366. expect(count(DB::getQueryLog()))->toBeLessThan(5);
  367. });
  368. /* ------------------------------------------------------------------ 批注 */
  369. it('lists annotations only when asked for by type, and counts only threads by default', function () {
  370. $owner = makeStudio('owner');
  371. $target = makeTargetSentence($owner);
  372. makeDiscussion(['res_id' => $target]);
  373. $note = makeDiscussion(['res_id' => $target, 'type' => 'commentary', 'title' => null,
  374. 'content' => '{{135-404-50-54}}', 'pos_start' => 3, 'pos_end' => 8, 'quote_exact' => 'dukkh']);
  375. $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
  376. $this->getJson($base, authHeader($owner))->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'discussion');
  377. $this->getJson("{$base}&type=commentary") // 批注对匿名也可见
  378. ->assertJsonCount(1, 'data')
  379. ->assertJsonPath('data.0.id', $note->id)
  380. ->assertJsonPath('data.0.pos_end', 8)
  381. ->assertJsonPath('data.0.quote_exact', 'dukkh');
  382. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
  383. ->assertJsonPath('data.status_counts.active', 1);
  384. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}&type=commentary")
  385. ->assertJsonPath('data.status_counts.active', 1);
  386. });
  387. it('shows an annotation to anyone, without close', function () {
  388. $owner = makeStudio('owner');
  389. $note = makeDiscussion(['res_id' => makeTargetSentence($owner), 'type' => 'note', 'editor_uid' => $owner]);
  390. $this->getJson("/api/v3/discussions/{$note->id}")->assertOk();
  391. $this->getJson("/api/v3/discussions/{$note->id}", authHeader($owner))
  392. ->assertJsonPath('data.abilities', ['update' => true, 'delete' => true, 'close' => false, 'reply' => true]);
  393. });
  394. it('lets only the sentence manager create annotations, without notifying', function () {
  395. $owner = makeStudio('owner');
  396. $target = makeTargetSentence($owner);
  397. $stranger = makeStudio('stranger');
  398. $payload = ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'commentary',
  399. 'content' => '{{135-404-50-54}} {{135-404-55-65}}', 'pos_start' => 0, 'pos_end' => 4, 'quote_exact' => 'sent'];
  400. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
  401. ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'glossary', 'note', 'commentary']);
  402. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($stranger))
  403. ->assertJsonPath('data.creatable_types', ['discussion', 'issue']);
  404. $this->postJson('/api/v3/discussions', $payload, authHeader($stranger))->assertForbidden();
  405. Log::spy();
  406. $this->postJson('/api/v3/discussions', $payload, authHeader($owner)) // 没传 notify:批注本来就不发
  407. ->assertCreated()
  408. ->assertJsonPath('data.type', 'commentary')
  409. ->assertJsonPath('data.title', null)
  410. ->assertJsonPath('data.pos_end', 4);
  411. Log::shouldNotHaveReceived('debug', fn (string $message) => $message === 'mq publish');
  412. });
  413. it('validates annotation input', function () {
  414. $owner = makeStudio('owner');
  415. $target = makeTargetSentence($owner);
  416. $post = fn (array $data) => $this->postJson('/api/v3/discussions',
  417. ['target_type' => 'sentence', 'target_id' => $target, 'notify' => false, ...$data], authHeader($owner));
  418. $post(['type' => 'commentary', 'content' => '这不是句子模板'])->assertStatus(422)->assertJsonValidationErrors('content');
  419. $post(['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('content');
  420. $post(['type' => 'note', 'content' => 'x', 'target_type' => 'article'])->assertStatus(422)->assertJsonValidationErrors('target_type');
  421. $post(['type' => 'note', 'content' => 'x', 'pos_start' => 5, 'pos_end' => 2])->assertStatus(422)->assertJsonValidationErrors('pos_end');
  422. $post(['type' => 'discussion', 'title' => 't', 'pos_start' => 1])->assertStatus(422)->assertJsonValidationErrors('pos_start');
  423. });
  424. it('keeps annotations and threads apart on update', function () {
  425. $owner = makeStudio('owner');
  426. $target = makeTargetSentence($owner);
  427. $note = makeDiscussion(['res_id' => $target, 'type' => 'note', 'title' => null, 'content' => 'gloss']);
  428. $topic = makeDiscussion(['res_id' => $target]);
  429. $patch = fn (Discussion $d, array $data) => $this->patchJson("/api/v3/discussions/{$d->id}", $data, authHeader($owner));
  430. $patch($note, ['status' => 'close'])->assertStatus(422)->assertJsonValidationErrors('status');
  431. $patch($note, ['type' => 'discussion'])->assertStatus(422)->assertJsonValidationErrors('type');
  432. $patch($topic, ['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('type');
  433. $patch($topic, ['pos_end' => 3])->assertStatus(422)->assertJsonValidationErrors('pos_end');
  434. $patch($note, ['content' => ''])->assertStatus(422)->assertJsonValidationErrors('content');
  435. $patch($note, ['type' => 'commentary'])->assertStatus(422)->assertJsonValidationErrors('content'); // 正文不是句子模板
  436. $patch($note, ['type' => 'commentary', 'content' => '{{1-2-3-4}}', 'pos_end' => 6])
  437. ->assertOk()
  438. ->assertJsonPath('data.type', 'commentary')
  439. ->assertJsonPath('data.pos_end', 6);
  440. });
  441. it('lets other users reply to an annotation, without injecting the reply into the reading page', function () {
  442. $owner = makeStudio('owner');
  443. $target = makeTargetSentence($owner);
  444. $note = makeDiscussion(['res_id' => $target, 'type' => 'note', 'title' => null, 'content' => 'gloss', 'editor_uid' => $owner]);
  445. $reader = makeStudio('reader');
  446. $this->getJson("/api/v3/discussions/{$note->id}", authHeader($reader))->assertJsonPath('data.abilities.reply', true);
  447. $this->postJson("/api/v3/discussions/{$note->id}/replies", ['content' => '请教出处', 'notify' => false], authHeader($reader))
  448. ->assertCreated()
  449. ->assertJsonPath('data.type', 'note')
  450. ->assertJsonPath('data.parent_id', $note->id);
  451. // 阅读页只注入批注本身:查询口径与 PaliContentService::injectAnnotationNotes 相同
  452. expect(Discussion::where('res_type', 'sentence')->where('res_id', $target)
  453. ->whereIn('type', Discussion::INJECTED_TYPES)->whereNull('parent')->pluck('id')->all())
  454. ->toBe([$note->id]);
  455. });
  456. it('still hides records that are neither threads nor annotations', function () {
  457. $log = makeDiscussion(['res_type' => 'task', 'type' => 'task-status']);
  458. $this->getJson("/api/v3/discussions/{$log->id}")->assertNotFound();
  459. });
  460. /* ------------------------------------------------------------------ 外挂术语与锚点 */
  461. it('lets only the sentence manager create a glossary topic, with the headword as title and an anchor', function () {
  462. $owner = makeStudio('owner');
  463. $target = makeTargetSentence($owner);
  464. $payload = ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'glossary', 'title' => 'dukkha',
  465. 'content' => '这里译作「苦」不妥', 'pos_start' => 0, 'pos_end' => 6, 'quote_exact' => 'dukkha', 'notify' => false];
  466. $this->postJson('/api/v3/discussions', $payload, authHeader(makeStudio('stranger')))->assertForbidden();
  467. $id = $this->postJson('/api/v3/discussions', $payload, authHeader($owner))
  468. ->assertCreated()
  469. ->assertJsonPath('data.type', 'glossary')
  470. ->assertJsonPath('data.title', 'dukkha')
  471. ->assertJsonPath('data.status', 'active')
  472. ->assertJsonPath('data.pos_end', 6)
  473. ->assertJsonPath('data.quote_exact', 'dukkha')
  474. ->json('data.id');
  475. // 公开可见,算作评论线程(不带 type 的列表与角标都数它)
  476. $this->getJson("/api/v3/discussions/{$id}")->assertOk();
  477. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}")
  478. ->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $id);
  479. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [['type' => 'sentence', 'id' => $target]]])
  480. ->assertJsonPath('data.0.total', 1);
  481. });
  482. it('validates glossary input', function () {
  483. $owner = makeStudio('owner');
  484. $target = makeTargetSentence($owner);
  485. $post = fn (array $data) => $this->postJson('/api/v3/discussions',
  486. ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'glossary', 'notify' => false, ...$data], authHeader($owner));
  487. $post(['content' => 'x'])->assertStatus(422)->assertJsonValidationErrors('title');
  488. $post(['title' => 'dukkha', 'target_type' => 'article'])->assertStatus(422)->assertJsonValidationErrors('target_type');
  489. $post(['title' => 'dukkha', 'severity' => 'error'])->assertStatus(422)->assertJsonValidationErrors('severity');
  490. });
  491. it('accepts an anchor on issue topics', function () {
  492. $target = makeTargetSentence(makeStudio('owner'));
  493. $author = makeStudio('author');
  494. $id = $this->postJson('/api/v3/discussions', ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'issue',
  495. 'title' => '漏译', 'pos_start' => 2, 'pos_end' => 5, 'quote_exact' => 'nte', 'notify' => false], authHeader($author))
  496. ->assertCreated()
  497. ->assertJsonPath('data.pos_start', 2)
  498. ->json('data.id');
  499. $this->patchJson("/api/v3/discussions/{$id}", ['pos_end' => 7], authHeader($author))
  500. ->assertOk()->assertJsonPath('data.pos_end', 7);
  501. });
  502. it('keeps the anchor across type changes, and guards conversion to glossary', function () {
  503. $owner = makeStudio('owner');
  504. $author = makeStudio('author');
  505. $target = makeTargetSentence($owner);
  506. $issue = makeDiscussion(['res_id' => $target, 'type' => 'issue', 'editor_uid' => $author, 'pos_start' => 1, 'pos_end' => 3]);
  507. $onWbw = makeDiscussion(['res_type' => 'wbw', 'editor_uid' => $owner]);
  508. $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'glossary'], authHeader($author))->assertForbidden();
  509. $this->patchJson("/api/v3/discussions/{$onWbw->id}", ['type' => 'glossary'], authHeader($owner))
  510. ->assertStatus(422)->assertJsonValidationErrors('type');
  511. $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'discussion', 'pos_end' => 4], authHeader($author))
  512. ->assertStatus(422)->assertJsonValidationErrors('pos_end');
  513. $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'glossary'], authHeader($owner))
  514. ->assertOk()->assertJsonPath('data.type', 'glossary')->assertJsonPath('data.pos_end', 3);
  515. // 改成不收锚点的类型:锚点留着(不显示、不影响校验),改回来位置还在
  516. $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'discussion'], authHeader($owner))
  517. ->assertOk()
  518. ->assertJsonPath('data.pos_end', 3);
  519. $this->patchJson("/api/v3/discussions/{$issue->id}", ['content' => '改正文'], authHeader($owner))->assertOk();
  520. $this->patchJson("/api/v3/discussions/{$issue->id}", ['type' => 'issue'], authHeader($owner))
  521. ->assertOk()
  522. ->assertJsonPath('data.pos_start', 1)
  523. ->assertJsonPath('data.pos_end', 3);
  524. });
  525. /* ------------------------------------------------------------------ 删除 */
  526. it('soft deletes a topic for its author and returns the tombstone', function () {
  527. $author = makeStudio('author');
  528. $topic = makeDiscussion(['editor_uid' => $author]);
  529. $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($author))
  530. ->assertOk()
  531. ->assertJsonPath('data.id', $topic->id)
  532. ->assertJsonPath('data.content', null)
  533. ->assertJsonPath('data.deleted_at', fn ($value) => $value !== null);
  534. $this->assertSoftDeleted($topic);
  535. // 没有回复的墓碑不再可读
  536. $this->getJson("/api/v3/discussions/{$topic->id}")->assertNotFound();
  537. });
  538. it('decrements the topic reply count when a reply is deleted', function () {
  539. $author = makeStudio('author');
  540. $topic = makeDiscussion(['children_count' => 1]);
  541. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'editor_uid' => $author]);
  542. $this->deleteJson("/api/v3/discussions/{$reply->id}", [], authHeader($author))->assertOk();
  543. $this->assertSoftDeleted($reply);
  544. expect($topic->fresh()->children_count)->toBe(0);
  545. $this->getJson("/api/v3/discussions/{$topic->id}", authHeader($author))->assertJsonPath('data.reply_count', 0);
  546. });
  547. it('lets the target manager delete someone else\'s reply and topic', function () {
  548. $owner = makeStudio('owner');
  549. $topic = makeDiscussion(['res_id' => makeTargetSentence($owner)]);
  550. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id]);
  551. $this->getJson("/api/v3/discussions/{$reply->id}", authHeader($owner))
  552. ->assertJsonPath('data.abilities.delete', true);
  553. $this->deleteJson("/api/v3/discussions/{$reply->id}", [], authHeader($owner))->assertOk();
  554. $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($owner))->assertOk();
  555. $this->assertSoftDeleted($reply);
  556. $this->assertSoftDeleted($topic);
  557. });
  558. it('forbids deleting for users who are neither author nor manager', function () {
  559. $topic = makeDiscussion(['res_id' => makeTargetSentence(makeStudio('owner'))]);
  560. $this->getJson("/api/v3/discussions/{$topic->id}", authHeader($stranger = makeStudio('stranger')))
  561. ->assertJsonPath('data.abilities.delete', false);
  562. $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($stranger))->assertForbidden();
  563. $this->deleteJson("/api/v3/discussions/{$topic->id}")->assertUnauthorized();
  564. });
  565. /* ------------------------------------------------------------------ v2 快照 */
  566. it('keeps the v2 delete response while soft deleting underneath', function () {
  567. $author = makeStudio('author');
  568. $topic = makeDiscussion(['editor_uid' => $author]);
  569. $this->deleteJson("/api/v2/discussion/{$topic->id}", [], authHeader($author))
  570. ->assertOk()
  571. ->assertJsonPath('ok', true);
  572. $this->assertSoftDeleted($topic);
  573. });