2
0

DiscussionV3Test.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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 collaboration topics from anonymous viewers', function () {
  66. $target = makeTargetSentence(makeStudio('owner'));
  67. makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
  68. 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}")
  71. ->assertOk()
  72. ->json('data');
  73. expect($data)->toHaveCount(1)
  74. ->and($data[0]['type'])->toBe('qa')
  75. ->and($data[0]['abilities']['reply'])->toBeFalse();
  76. });
  77. it('shows a basic user only their own collaboration topics on targets they cannot manage', function () {
  78. $target = makeTargetSentence(makeStudio('owner'));
  79. $basic = makeBasicUser('basic');
  80. makeDiscussion(['res_id' => $target, 'editor_uid' => $basic, 'title' => 'mine']);
  81. $others = makeDiscussion(['res_id' => $target, 'title' => 'others']);
  82. $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($basic))
  83. ->assertOk()
  84. ->json('data');
  85. expect($data)->toHaveCount(1)
  86. ->and($data[0]['title'])->toBe('mine');
  87. // 单条与回复列表守同一条规则(v2 只在列表上判断)
  88. $this->getJson("/api/v3/discussions/{$others->id}", authHeader($basic))->assertNotFound();
  89. $this->getJson("/api/v3/discussions/{$others->id}/replies", authHeader($basic))->assertNotFound();
  90. });
  91. it('lets a basic user see all topics on a target they can manage', function () {
  92. $basic = makeBasicUser('basic-owner');
  93. $target = makeTargetSentence($basic);
  94. makeDiscussion(['res_id' => $target]);
  95. makeDiscussion(['res_id' => $target]);
  96. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($basic))
  97. ->assertOk()
  98. ->assertJsonCount(2, 'data');
  99. });
  100. it('filters topics by type, status and severity', function () {
  101. $owner = makeStudio('owner');
  102. $target = makeTargetSentence($owner);
  103. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
  104. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'suggestion']);
  105. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'warning', 'status' => 'close']);
  106. makeDiscussion(['res_id' => $target]);
  107. $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
  108. $this->getJson("{$base}&type=issue", authHeader($owner))->assertJsonCount(3, 'data');
  109. $this->getJson("{$base}&type=issue&status=active", authHeader($owner))->assertJsonCount(2, 'data');
  110. $this->getJson("{$base}&severity=error,warning", authHeader($owner))->assertJsonCount(2, 'data');
  111. });
  112. it('rejects an invalid list query', function () {
  113. $target = (string) Str::uuid();
  114. $this->getJson('/api/v3/discussions?target_type=sentence')->assertStatus(422);
  115. $this->getJson("/api/v3/discussions?target_type=chapter&target_id={$target}")->assertStatus(422);
  116. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&type=feedback")->assertStatus(422);
  117. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&per_page=1000")->assertStatus(422);
  118. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&sort=title")->assertStatus(422);
  119. });
  120. it('keeps a deleted topic with replies as a tombstone and drops one without replies', function () {
  121. $owner = makeStudio('owner');
  122. $target = makeTargetSentence($owner);
  123. $withReplies = makeDiscussion(['res_id' => $target, 'title' => 'kept']);
  124. makeDiscussion(['res_id' => $target, 'parent' => $withReplies->id, 'title' => null]);
  125. $withReplies->delete();
  126. makeDiscussion(['res_id' => $target, 'title' => 'gone'])->delete();
  127. $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($owner))
  128. ->assertOk()
  129. ->json('data');
  130. expect($data)->toHaveCount(1)
  131. ->and($data[0]['id'])->toBe($withReplies->id)
  132. ->and($data[0]['deleted_at'])->not->toBeNull()
  133. ->and($data[0]['title'])->toBeNull()
  134. ->and($data[0]['content'])->toBeNull()
  135. ->and($data[0]['abilities'])->toBe(['update' => false, 'delete' => false, 'close' => false, 'reply' => false]);
  136. // 墓碑的回复照样可读
  137. $this->getJson("/api/v3/discussions/{$withReplies->id}/replies", authHeader($owner))
  138. ->assertOk()
  139. ->assertJsonCount(1, 'data');
  140. });
  141. it('loads a page of topics with a constant number of queries', function () {
  142. $owner = makeStudio('owner');
  143. $target = makeTargetSentence($owner);
  144. $countQueriesFor = function (int $rows) use ($owner, $target) {
  145. Discussion::where('res_id', $target)->forceDelete();
  146. foreach (range(1, $rows) as $i) {
  147. makeDiscussion(['res_id' => $target, 'editor_uid' => makeStudio("n1-{$rows}-{$i}")]);
  148. }
  149. $headers = authHeader($owner);
  150. DB::flushQueryLog();
  151. DB::enableQueryLog();
  152. $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", $headers)->assertOk();
  153. $count = count(DB::getQueryLog());
  154. DB::disableQueryLog();
  155. return $count;
  156. };
  157. // 先热身一次:UserService 是 singleton,查看者自己的摘要只在第一次请求时查库
  158. $countQueriesFor(1);
  159. // 正文渲染(MdRender)是逐行的,内容不带模板时不查库;这里守的是编辑者与权限不逐行查
  160. expect($countQueriesFor(12))->toBe($countQueriesFor(2));
  161. });
  162. /* ------------------------------------------------------------------ 概况 */
  163. it('summarises status counts and creatable types', function () {
  164. $owner = makeStudio('owner');
  165. $target = makeTargetSentence($owner);
  166. makeDiscussion(['res_id' => $target]);
  167. makeDiscussion(['res_id' => $target, 'status' => 'close']);
  168. makeDiscussion(['res_id' => $target, 'status' => 'close']);
  169. makeDiscussion(['res_id' => $target])->delete();
  170. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
  171. ->assertOk()
  172. ->assertJsonPath('data.status_counts', ['active' => 1, 'close' => 2])
  173. ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'note', 'commentary']); // 句子的管理者能加批注
  174. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}")
  175. ->assertOk()
  176. ->assertJsonPath('data.status_counts', ['active' => 0, 'close' => 0])
  177. ->assertJsonPath('data.creatable_types', []);
  178. });
  179. /* ------------------------------------------------------------------ 发起话题 */
  180. it('creates a topic', function () {
  181. $user = makeStudio('author');
  182. $target = makeTargetSentence(makeStudio('owner'));
  183. $this->postJson('/api/v3/discussions', [
  184. 'target_type' => 'sentence',
  185. 'target_id' => $target,
  186. 'type' => 'issue',
  187. 'title' => '译文漏了一句',
  188. 'content' => '第二句没有译出',
  189. 'severity' => 'error',
  190. 'notify' => false,
  191. ], authHeader($user))
  192. ->assertCreated()
  193. ->assertJsonPath('data.type', 'issue')
  194. ->assertJsonPath('data.severity', 'error')
  195. ->assertJsonPath('data.parent_id', null)
  196. ->assertJsonPath('data.reply_count', 0)
  197. ->assertJsonPath('data.content_type', 'markdown');
  198. expect(Discussion::where('editor_uid', $user)->value('res_id'))->toBe($target);
  199. });
  200. it('requires login to create a topic', function () {
  201. $this->postJson('/api/v3/discussions', [
  202. 'target_type' => 'sentence',
  203. 'target_id' => (string) Str::uuid(),
  204. 'title' => 't',
  205. ])->assertUnauthorized();
  206. });
  207. it('validates severity against the topic type', function () {
  208. $user = makeStudio('author');
  209. $target = makeTargetSentence(makeStudio('owner'));
  210. $payload = ['target_type' => 'sentence', 'target_id' => $target, 'title' => 't', 'notify' => false];
  211. $this->postJson('/api/v3/discussions', $payload + ['type' => 'discussion', 'severity' => 'error'], authHeader($user))
  212. ->assertStatus(422)->assertJsonValidationErrors('severity');
  213. $this->postJson('/api/v3/discussions', $payload + ['type' => 'issue', 'severity' => 'urgent'], authHeader($user))
  214. ->assertStatus(422)->assertJsonValidationErrors('severity');
  215. $this->postJson('/api/v3/discussions', $payload + ['type' => 'help', 'severity' => 'urgent'], authHeader($user))
  216. ->assertStatus(403); // help 只能发在文章上
  217. });
  218. it('rejects a topic on a target that does not exist', function () {
  219. $this->postJson('/api/v3/discussions', [
  220. 'target_type' => 'sentence',
  221. 'target_id' => (string) Str::uuid(),
  222. 'title' => 't',
  223. 'notify' => false,
  224. ], authHeader(makeStudio('author')))
  225. ->assertStatus(422)
  226. ->assertJsonValidationErrors('target_id');
  227. });
  228. /* ------------------------------------------------------------------ 回复 */
  229. it('replies to a topic, inheriting its type and target', function () {
  230. $topic = makeDiscussion(['res_id' => makeTargetSentence(makeStudio('owner')), 'type' => 'issue', 'severity' => 'warning']);
  231. $user = makeStudio('replier');
  232. $this->postJson("/api/v3/discussions/{$topic->id}/replies", ['content' => '已修正', 'notify' => false], authHeader($user))
  233. ->assertCreated()
  234. ->assertJsonPath('data.parent_id', $topic->id)
  235. ->assertJsonPath('data.type', 'issue')
  236. ->assertJsonPath('data.target_id', $topic->res_id)
  237. ->assertJsonMissingPath('data.reply_count');
  238. expect($topic->fresh()->children_count)->toBe(1);
  239. });
  240. it('lists replies oldest first with the topic type', function () {
  241. $topic = makeDiscussion(['type' => 'qa']);
  242. $first = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'discussion', 'created_at' => now()->subHour()]);
  243. makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'qa']);
  244. $data = $this->getJson("/api/v3/discussions/{$topic->id}/replies")
  245. ->assertOk()
  246. ->json('data');
  247. expect($data)->toHaveCount(2)
  248. ->and($data[0]['id'])->toBe($first->id)
  249. ->and(array_column($data, 'type'))->toBe(['qa', 'qa']); // 历史数据里回复的 type 可能与话题不一致
  250. });
  251. it('refuses to reply to a reply or to a closed topic', function () {
  252. $user = makeStudio('replier');
  253. $topic = makeDiscussion();
  254. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id]);
  255. $closed = makeDiscussion(['status' => 'close']);
  256. $this->postJson("/api/v3/discussions/{$reply->id}/replies", ['content' => 'x', 'notify' => false], authHeader($user))
  257. ->assertStatus(422);
  258. $this->postJson("/api/v3/discussions/{$closed->id}/replies", ['content' => 'x', 'notify' => false], authHeader($user))
  259. ->assertStatus(409)
  260. ->assertJsonPath('type', fn ($type) => str_contains($type, 'discussion-closed'));
  261. $this->postJson("/api/v3/discussions/{$topic->id}/replies", ['notify' => false], authHeader($user))
  262. ->assertStatus(422);
  263. });
  264. /* ------------------------------------------------------------------ 修改 */
  265. it('patches only the fields that were sent', function () {
  266. $author = makeStudio('author');
  267. $topic = makeDiscussion(['editor_uid' => $author, 'title' => 'keep me', 'status' => 'close']);
  268. $this->patchJson("/api/v3/discussions/{$topic->id}", ['content' => 'new body'], authHeader($author))
  269. ->assertOk()
  270. ->assertJsonPath('data.content', 'new body')
  271. ->assertJsonPath('data.title', 'keep me')
  272. ->assertJsonPath('data.status', 'close'); // v2 的 PUT 在这里会把话题重新打开
  273. });
  274. it('lets the channel editor update someone else\'s topic', function () {
  275. $owner = makeStudio('owner');
  276. $topic = makeDiscussion(['res_id' => makeTargetSentence($owner)]);
  277. $this->patchJson("/api/v3/discussions/{$topic->id}", ['status' => 'close'], authHeader($owner))
  278. ->assertOk()
  279. ->assertJsonPath('data.status', 'close');
  280. $this->patchJson("/api/v3/discussions/{$topic->id}", ['content' => 'x'], authHeader(makeStudio('stranger')))
  281. ->assertForbidden();
  282. });
  283. it('rejects topic-only fields on a reply', function () {
  284. $author = makeStudio('author');
  285. $topic = makeDiscussion();
  286. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'editor_uid' => $author]);
  287. $this->patchJson("/api/v3/discussions/{$reply->id}", ['title' => 't'], authHeader($author))
  288. ->assertStatus(422)
  289. ->assertJsonValidationErrors('title');
  290. $this->patchJson("/api/v3/discussions/{$reply->id}", ['content' => 'fixed'], authHeader($author))
  291. ->assertOk();
  292. });
  293. it('converts a topic type, cascading to replies and clearing a severity the new type does not take', function () {
  294. $author = makeStudio('author');
  295. $topic = makeDiscussion(['editor_uid' => $author, 'type' => 'issue', 'severity' => 'error']);
  296. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'issue']);
  297. $this->patchJson("/api/v3/discussions/{$topic->id}", ['type' => 'discussion'], authHeader($author))
  298. ->assertOk()
  299. ->assertJsonPath('data.type', 'discussion')
  300. ->assertJsonPath('data.severity', null);
  301. expect($reply->fresh()->type)->toBe('discussion');
  302. $this->patchJson("/api/v3/discussions/{$topic->id}", ['severity' => 'error'], authHeader($author))
  303. ->assertStatus(422)
  304. ->assertJsonValidationErrors('severity');
  305. });
  306. /* ------------------------------------------------------------------ 批量角标 */
  307. it('counts active threads per target in request order, with zeros', function () {
  308. $owner = makeStudio('owner');
  309. $a = makeTargetSentence($owner);
  310. $b = makeTargetSentence($owner);
  311. $empty = (string) Str::uuid();
  312. makeDiscussion(['res_id' => $a, 'type' => 'qa', 'editor_uid' => $owner]);
  313. makeDiscussion(['res_id' => $a, 'type' => 'help']);
  314. makeDiscussion(['res_id' => $a, 'type' => 'qa', 'status' => 'close']); // 关闭的不数
  315. makeDiscussion(['res_id' => $a, 'type' => 'note']); // 批注不数
  316. makeDiscussion(['res_id' => $a, 'type' => 'qa'])->delete(); // 删除的不数
  317. $topic = makeDiscussion(['res_id' => $b, 'type' => 'qa']);
  318. makeDiscussion(['res_id' => $b, 'type' => 'qa', 'parent' => $topic->id]); // 回复不数
  319. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [
  320. ['type' => 'sentence', 'id' => $b],
  321. ['type' => 'sentence', 'id' => $a],
  322. ['type' => 'wbw', 'id' => $empty],
  323. ]], authHeader($owner))
  324. ->assertOk()
  325. ->assertJsonCount(3, 'data')
  326. ->assertJsonPath('data.0.target_id', $b)
  327. ->assertJsonPath('data.0.total', 1)
  328. ->assertJsonPath('data.0.mine', 0)
  329. ->assertJsonPath('data.1.target_id', $a)
  330. ->assertJsonPath('data.1.total', 2)
  331. ->assertJsonPath('data.1.mine', 1)
  332. ->assertJsonPath('data.2.target_type', 'wbw')
  333. ->assertJsonPath('data.2.total', 0);
  334. });
  335. it('applies list visibility to batch counts', function () {
  336. $owner = makeStudio('owner');
  337. $target = makeTargetSentence($owner);
  338. $basic = makeBasicUser('basic');
  339. makeDiscussion(['res_id' => $target, 'type' => 'qa']);
  340. makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
  341. makeDiscussion(['res_id' => $target, 'type' => 'issue', 'editor_uid' => $basic]);
  342. $body = ['targets' => [['type' => 'sentence', 'id' => $target]]];
  343. $this->postJson('/api/v3/discussions/batch-count', $body)->assertJsonPath('data.0.total', 1); // 匿名:只数 qa
  344. $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($basic))->assertJsonPath('data.0.total', 2); // basic:qa + 自己的 issue
  345. $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($owner))->assertJsonPath('data.0.total', 3); // 管理者:全部
  346. });
  347. it('rejects an invalid batch count request', function () {
  348. $this->postJson('/api/v3/discussions/batch-count', ['targets' => []])->assertStatus(422);
  349. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [['type' => 'article', 'id' => (string) Str::uuid()]]])
  350. ->assertStatus(422)->assertJsonValidationErrors('targets.0.type');
  351. $tooMany = array_fill(0, 501, ['type' => 'sentence', 'id' => (string) Str::uuid()]);
  352. $this->postJson('/api/v3/discussions/batch-count', ['targets' => $tooMany])->assertStatus(422);
  353. });
  354. it('counts a batch in a fixed number of queries', function () {
  355. $owner = makeStudio('owner');
  356. $targets = collect(range(1, 20))->map(function () use ($owner) {
  357. $id = makeTargetSentence($owner);
  358. makeDiscussion(['res_id' => $id, 'type' => 'qa']);
  359. return ['type' => 'sentence', 'id' => $id];
  360. })->all();
  361. $this->postJson('/api/v3/discussions/batch-count', ['targets' => [$targets[0]]], authHeader($owner)); // 预热 UserService
  362. DB::enableQueryLog();
  363. $this->postJson('/api/v3/discussions/batch-count', ['targets' => $targets], authHeader($owner))->assertOk();
  364. expect(count(DB::getQueryLog()))->toBeLessThan(5);
  365. });
  366. /* ------------------------------------------------------------------ 批注 */
  367. it('lists annotations only when asked for by type, and counts only threads by default', function () {
  368. $owner = makeStudio('owner');
  369. $target = makeTargetSentence($owner);
  370. makeDiscussion(['res_id' => $target]);
  371. $note = makeDiscussion(['res_id' => $target, 'type' => 'commentary', 'title' => null,
  372. 'content' => '{{135-404-50-54}}', 'pos_start' => 3, 'pos_end' => 8, 'quote_exact' => 'dukkh']);
  373. $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
  374. $this->getJson($base, authHeader($owner))->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'discussion');
  375. $this->getJson("{$base}&type=commentary") // 批注对匿名也可见
  376. ->assertJsonCount(1, 'data')
  377. ->assertJsonPath('data.0.id', $note->id)
  378. ->assertJsonPath('data.0.pos_end', 8)
  379. ->assertJsonPath('data.0.quote_exact', 'dukkh');
  380. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
  381. ->assertJsonPath('data.status_counts.active', 1);
  382. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}&type=commentary")
  383. ->assertJsonPath('data.status_counts.active', 1);
  384. });
  385. it('shows an annotation to anyone, without close', function () {
  386. $owner = makeStudio('owner');
  387. $note = makeDiscussion(['res_id' => makeTargetSentence($owner), 'type' => 'note', 'editor_uid' => $owner]);
  388. $this->getJson("/api/v3/discussions/{$note->id}")->assertOk();
  389. $this->getJson("/api/v3/discussions/{$note->id}", authHeader($owner))
  390. ->assertJsonPath('data.abilities', ['update' => true, 'delete' => true, 'close' => false, 'reply' => true]);
  391. });
  392. it('lets only the sentence manager create annotations, without notifying', function () {
  393. $owner = makeStudio('owner');
  394. $target = makeTargetSentence($owner);
  395. $stranger = makeStudio('stranger');
  396. $payload = ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'commentary',
  397. 'content' => '{{135-404-50-54}} {{135-404-55-65}}', 'pos_start' => 0, 'pos_end' => 4, 'quote_exact' => 'sent'];
  398. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
  399. ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'note', 'commentary']);
  400. $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($stranger))
  401. ->assertJsonPath('data.creatable_types', ['discussion', 'issue']);
  402. $this->postJson('/api/v3/discussions', $payload, authHeader($stranger))->assertForbidden();
  403. Log::spy();
  404. $this->postJson('/api/v3/discussions', $payload, authHeader($owner)) // 没传 notify:批注本来就不发
  405. ->assertCreated()
  406. ->assertJsonPath('data.type', 'commentary')
  407. ->assertJsonPath('data.title', null)
  408. ->assertJsonPath('data.pos_end', 4);
  409. Log::shouldNotHaveReceived('debug', fn (string $message) => $message === 'mq publish');
  410. });
  411. it('validates annotation input', function () {
  412. $owner = makeStudio('owner');
  413. $target = makeTargetSentence($owner);
  414. $post = fn (array $data) => $this->postJson('/api/v3/discussions',
  415. ['target_type' => 'sentence', 'target_id' => $target, 'notify' => false, ...$data], authHeader($owner));
  416. $post(['type' => 'commentary', 'content' => '这不是句子模板'])->assertStatus(422)->assertJsonValidationErrors('content');
  417. $post(['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('content');
  418. $post(['type' => 'note', 'content' => 'x', 'target_type' => 'article'])->assertStatus(422)->assertJsonValidationErrors('target_type');
  419. $post(['type' => 'note', 'content' => 'x', 'pos_start' => 5, 'pos_end' => 2])->assertStatus(422)->assertJsonValidationErrors('pos_end');
  420. $post(['type' => 'discussion', 'title' => 't', 'pos_start' => 1])->assertStatus(422)->assertJsonValidationErrors('pos_start');
  421. });
  422. it('keeps annotations and threads apart on update', function () {
  423. $owner = makeStudio('owner');
  424. $target = makeTargetSentence($owner);
  425. $note = makeDiscussion(['res_id' => $target, 'type' => 'note', 'title' => null, 'content' => 'gloss']);
  426. $topic = makeDiscussion(['res_id' => $target]);
  427. $patch = fn (Discussion $d, array $data) => $this->patchJson("/api/v3/discussions/{$d->id}", $data, authHeader($owner));
  428. $patch($note, ['status' => 'close'])->assertStatus(422)->assertJsonValidationErrors('status');
  429. $patch($note, ['type' => 'discussion'])->assertStatus(422)->assertJsonValidationErrors('type');
  430. $patch($topic, ['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('type');
  431. $patch($topic, ['pos_end' => 3])->assertStatus(422)->assertJsonValidationErrors('pos_end');
  432. $patch($note, ['content' => ''])->assertStatus(422)->assertJsonValidationErrors('content');
  433. $patch($note, ['type' => 'commentary'])->assertStatus(422)->assertJsonValidationErrors('content'); // 正文不是句子模板
  434. $patch($note, ['type' => 'commentary', 'content' => '{{1-2-3-4}}', 'pos_end' => 6])
  435. ->assertOk()
  436. ->assertJsonPath('data.type', 'commentary')
  437. ->assertJsonPath('data.pos_end', 6);
  438. });
  439. it('lets other users reply to an annotation, without injecting the reply into the reading page', function () {
  440. $owner = makeStudio('owner');
  441. $target = makeTargetSentence($owner);
  442. $note = makeDiscussion(['res_id' => $target, 'type' => 'note', 'title' => null, 'content' => 'gloss', 'editor_uid' => $owner]);
  443. $reader = makeStudio('reader');
  444. $this->getJson("/api/v3/discussions/{$note->id}", authHeader($reader))->assertJsonPath('data.abilities.reply', true);
  445. $this->postJson("/api/v3/discussions/{$note->id}/replies", ['content' => '请教出处', 'notify' => false], authHeader($reader))
  446. ->assertCreated()
  447. ->assertJsonPath('data.type', 'note')
  448. ->assertJsonPath('data.parent_id', $note->id);
  449. // 阅读页只注入批注本身:查询口径与 PaliContentService::injectAnnotationNotes 相同
  450. expect(Discussion::where('res_type', 'sentence')->where('res_id', $target)
  451. ->whereIn('type', Discussion::INJECTED_TYPES)->whereNull('parent')->pluck('id')->all())
  452. ->toBe([$note->id]);
  453. });
  454. it('still hides records that are neither threads nor annotations', function () {
  455. $log = makeDiscussion(['res_type' => 'task', 'type' => 'task-status']);
  456. $this->getJson("/api/v3/discussions/{$log->id}")->assertNotFound();
  457. });
  458. /* ------------------------------------------------------------------ 删除 */
  459. it('soft deletes a topic for its author and returns the tombstone', function () {
  460. $author = makeStudio('author');
  461. $topic = makeDiscussion(['editor_uid' => $author]);
  462. $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($author))
  463. ->assertOk()
  464. ->assertJsonPath('data.id', $topic->id)
  465. ->assertJsonPath('data.content', null)
  466. ->assertJsonPath('data.deleted_at', fn ($value) => $value !== null);
  467. $this->assertSoftDeleted($topic);
  468. // 没有回复的墓碑不再可读
  469. $this->getJson("/api/v3/discussions/{$topic->id}")->assertNotFound();
  470. });
  471. it('decrements the topic reply count when a reply is deleted', function () {
  472. $author = makeStudio('author');
  473. $topic = makeDiscussion(['children_count' => 1]);
  474. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'editor_uid' => $author]);
  475. $this->deleteJson("/api/v3/discussions/{$reply->id}", [], authHeader($author))->assertOk();
  476. $this->assertSoftDeleted($reply);
  477. expect($topic->fresh()->children_count)->toBe(0);
  478. $this->getJson("/api/v3/discussions/{$topic->id}", authHeader($author))->assertJsonPath('data.reply_count', 0);
  479. });
  480. it('lets the target manager delete someone else\'s reply and topic', function () {
  481. $owner = makeStudio('owner');
  482. $topic = makeDiscussion(['res_id' => makeTargetSentence($owner)]);
  483. $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id]);
  484. $this->getJson("/api/v3/discussions/{$reply->id}", authHeader($owner))
  485. ->assertJsonPath('data.abilities.delete', true);
  486. $this->deleteJson("/api/v3/discussions/{$reply->id}", [], authHeader($owner))->assertOk();
  487. $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($owner))->assertOk();
  488. $this->assertSoftDeleted($reply);
  489. $this->assertSoftDeleted($topic);
  490. });
  491. it('forbids deleting for users who are neither author nor manager', function () {
  492. $topic = makeDiscussion(['res_id' => makeTargetSentence(makeStudio('owner'))]);
  493. $this->getJson("/api/v3/discussions/{$topic->id}", authHeader($stranger = makeStudio('stranger')))
  494. ->assertJsonPath('data.abilities.delete', false);
  495. $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($stranger))->assertForbidden();
  496. $this->deleteJson("/api/v3/discussions/{$topic->id}")->assertUnauthorized();
  497. });
  498. /* ------------------------------------------------------------------ v2 快照 */
  499. it('keeps the v2 delete response while soft deleting underneath', function () {
  500. $author = makeStudio('author');
  501. $topic = makeDiscussion(['editor_uid' => $author]);
  502. $this->deleteJson("/api/v2/discussion/{$topic->id}", [], authHeader($author))
  503. ->assertOk()
  504. ->assertJsonPath('ok', true);
  505. $this->assertSoftDeleted($topic);
  506. });