kanbanStore.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import { createPinia, setActivePinia } from 'pinia'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { nextTick } from 'vue'
  4. import { setApiCsrfToken } from '@/api/client'
  5. import { useAuthStore } from '@/stores/auth'
  6. import { useKanbanStore } from '@/stores/kanban'
  7. const boardPayload = () => ({
  8. columns: [
  9. { name: 'todo', tasks: [{ id: 'task-a', title: '移动端验收', body: 'iOS 与 Web', status: 'todo', assignee: 'yaoyao', tenant: 'mobile', priority: 2, created_at: 1 }] },
  10. { name: 'ready', tasks: [{ id: 'task-b', title: '服务端核对', status: 'ready', assignee: 'ops', tenant: 'backend', created_at: 2 }] },
  11. ],
  12. tenants: ['backend', 'mobile'],
  13. assignees: ['ops', 'yaoyao'],
  14. latest_event_id: 2,
  15. now: 3,
  16. })
  17. function deferred<T>() {
  18. let resolve!: (value: T) => void
  19. let reject!: (reason?: unknown) => void
  20. const promise = new Promise<T>((resolvePromise, rejectPromise) => {
  21. resolve = resolvePromise
  22. reject = rejectPromise
  23. })
  24. return { promise, resolve, reject }
  25. }
  26. beforeEach(() => {
  27. setActivePinia(createPinia())
  28. localStorage.clear()
  29. vi.useFakeTimers()
  30. setApiCsrfToken('csrf-kanban-store')
  31. })
  32. afterEach(() => {
  33. useKanbanStore().stopPolling()
  34. vi.useRealTimers()
  35. vi.unstubAllGlobals()
  36. setApiCsrfToken('')
  37. })
  38. describe('Kanban store', () => {
  39. it('loads and switches boards, filters locally, and authoritatively refreshes after a write', async () => {
  40. const calls: Array<{ path: string; method: string }> = []
  41. vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
  42. const method = init?.method ?? 'GET'
  43. calls.push({ path, method })
  44. if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
  45. if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', name: '默认', total: 2 }, { slug: 'mobile', name: '移动端', total: 2 }], current: 'default' })
  46. if (path.endsWith('/profiles')) return Response.json({ profiles: [{ name: 'yaoyao', is_default: true }, { name: 'ops', is_default: false }] })
  47. if (path.includes('/board?')) return Response.json(boardPayload())
  48. if (path.includes('/tasks?') && method === 'POST') return Response.json({ task: { id: 'task-new', title: '新任务', status: 'ready' } })
  49. return Response.json({ ok: true })
  50. }))
  51. const auth = useAuthStore()
  52. auth.status = 'authenticated'
  53. auth.user = { id: 'admin-a', username: 'admin', role: 'admin' }
  54. const store = useKanbanStore()
  55. await store.initialize()
  56. await store.selectBoard('mobile')
  57. expect(store.availability).toBe('available')
  58. expect(store.selectedBoardSlug).toBe('mobile')
  59. expect(store.allTasks.map(task => task.id)).toEqual(['task-a', 'task-b'])
  60. expect(calls.filter(call => call.path.includes('/board?')).at(-1)?.path).toContain('board=mobile')
  61. store.search = 'iOS'
  62. store.assignee = 'yaoyao'
  63. store.tenant = 'mobile'
  64. expect(store.filteredColumns.flatMap(column => column.tasks).map(task => task.id)).toEqual(['task-a'])
  65. const beforeRejectedTargets = calls.length
  66. await expect(store.createTask({ title: '非法状态' }, 'running')).rejects.toMatchObject({ code: 'invalid_kanban_status' })
  67. await expect(store.moveTask(store.allTasks[0]!, 'review')).rejects.toMatchObject({ code: 'invalid_kanban_status' })
  68. expect(calls).toHaveLength(beforeRejectedTargets)
  69. await store.createTask({ title: '新任务' })
  70. expect(calls.filter(call => call.path.includes('/tasks?') && call.method === 'POST')).toHaveLength(1)
  71. expect(calls.filter(call => call.path.includes('/board?')).length).toBeGreaterThanOrEqual(2)
  72. })
  73. it('allows regular users to read but rejects every mutation before making a request', async () => {
  74. const calls: Array<{ path: string; method: string }> = []
  75. vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
  76. calls.push({ path, method: init?.method ?? 'GET' })
  77. if (path.endsWith('/status')) return Response.json({ available: true })
  78. if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 2 }], current: 'default' })
  79. if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
  80. if (path.includes('/board?')) return Response.json(boardPayload())
  81. return Response.json({ ok: true })
  82. }))
  83. const auth = useAuthStore()
  84. auth.status = 'authenticated'
  85. auth.user = { id: 'reader', username: 'reader', role: 'user' }
  86. const store = useKanbanStore()
  87. await store.initialize()
  88. expect(store.canEdit).toBe(false)
  89. await expect(store.moveTask(store.allTasks[0]!, 'ready')).rejects.toMatchObject({ status: 403, code: 'admin_required' })
  90. expect(calls.some(call => call.method === 'PATCH')).toBe(false)
  91. })
  92. it('forces a post-mutation board read after an older poll and discards stale task detail responses', async () => {
  93. const staleBoard = deferred<Response>()
  94. const freshBoard = deferred<Response>()
  95. const staleTask = deferred<Response>()
  96. let boardReads = 0
  97. let taskReads = 0
  98. let writes = 0
  99. vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
  100. const method = init?.method ?? 'GET'
  101. if (path.endsWith('/status')) return Response.json({ available: true })
  102. if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 1 }], current: 'default' })
  103. if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
  104. if (path.includes('/board?')) {
  105. boardReads += 1
  106. if (boardReads === 2) return staleBoard.promise
  107. if (boardReads >= 3) return freshBoard.promise
  108. return Response.json({
  109. columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: '竞态任务', status: 'todo' }] }, { name: 'ready', tasks: [] }],
  110. tenants: [], assignees: [], latest_event_id: boardReads, now: boardReads,
  111. })
  112. }
  113. if (path.includes('/tasks/task-a?') && method === 'GET') {
  114. taskReads += 1
  115. if (taskReads === 1) return staleTask.promise
  116. return Response.json({ task: { id: 'task-a', title: '新详情', status: 'ready' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] })
  117. }
  118. if (path.includes('/tasks/task-a?') && method === 'PATCH') {
  119. writes += 1
  120. return Response.json({ task: { id: 'task-a', title: '竞态任务', status: 'ready' } })
  121. }
  122. return Response.json({ ok: true })
  123. }))
  124. const auth = useAuthStore()
  125. auth.status = 'authenticated'
  126. auth.user = { id: 'admin-race', username: 'admin', role: 'admin' }
  127. const store = useKanbanStore()
  128. await store.initialize()
  129. const oldPoll = store.refreshBoard(true)
  130. await vi.waitFor(() => expect(boardReads).toBe(2))
  131. const mutation = store.moveTask(store.allTasks[0]!, 'ready')
  132. await vi.waitFor(() => expect(writes).toBe(1))
  133. staleBoard.resolve(Response.json({
  134. columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: '旧轮询不应回写', status: 'todo' }] }, { name: 'ready', tasks: [] }],
  135. tenants: [], assignees: [], latest_event_id: 1, now: 1,
  136. }))
  137. await vi.waitFor(() => expect(boardReads).toBe(3))
  138. expect(store.allTasks[0]?.title).toBe('竞态任务')
  139. freshBoard.resolve(Response.json({
  140. columns: [{ name: 'todo', tasks: [] }, { name: 'ready', tasks: [{ id: 'task-a', title: '竞态任务', status: 'ready' }] }],
  141. tenants: [], assignees: [], latest_event_id: 3, now: 3,
  142. }))
  143. await Promise.all([oldPoll, mutation])
  144. expect(boardReads).toBe(3)
  145. expect(store.allTasks[0]?.status).toBe('ready')
  146. const oldDetail = store.selectTask('task-a')
  147. await vi.waitFor(() => expect(taskReads).toBe(1))
  148. const newDetail = store.refreshTask(true)
  149. await newDetail
  150. expect(store.selectedTask?.task.title).toBe('新详情')
  151. expect(store.isTaskLoading).toBe(false)
  152. staleTask.resolve(Response.json({ task: { id: 'task-a', title: '旧详情', status: 'todo' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] }))
  153. await oldDetail
  154. expect(store.selectedTask?.task.title).toBe('新详情')
  155. })
  156. it('invalidates pending work and clears account-scoped state when the authenticated user changes', async () => {
  157. const oldTask = deferred<Response>()
  158. let taskReads = 0
  159. vi.stubGlobal('fetch', vi.fn(async (path: string) => {
  160. if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
  161. if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 1 }], current: 'default' })
  162. if (path.endsWith('/profiles')) return Response.json({ profiles: [{ name: 'yaoyao', is_default: true }] })
  163. if (path.includes('/board?')) return Response.json({ columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: '账号 A', status: 'todo' }] }], tenants: ['a'], assignees: ['yaoyao'], latest_event_id: 1, now: 1 })
  164. if (path.includes('/tasks/task-a?')) {
  165. taskReads += 1
  166. return oldTask.promise
  167. }
  168. return Response.json({ ok: true })
  169. }))
  170. const auth = useAuthStore()
  171. auth.status = 'authenticated'
  172. auth.user = { id: 'user-a', username: 'a', role: 'admin' }
  173. const store = useKanbanStore()
  174. await store.initialize()
  175. const pending = store.selectTask('task-a')
  176. await vi.waitFor(() => expect(taskReads).toBe(1))
  177. auth.user = { id: 'user-b', username: 'b', role: 'admin' }
  178. await nextTick()
  179. oldTask.resolve(Response.json({ task: { id: 'task-a', title: '不应回写', status: 'todo' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] }))
  180. await pending
  181. expect(store.boards).toEqual([])
  182. expect(store.profiles).toEqual([])
  183. expect(store.selectedBoardSlug).toBe('')
  184. expect(store.selectedTask).toBeUndefined()
  185. expect(store.search).toBe('')
  186. expect(store.pluginVersion).toBe('')
  187. })
  188. it('does not let a completed create-board request repopulate state after reset', async () => {
  189. const createResponse = deferred<Response>()
  190. let createCalls = 0
  191. let boardReads = 0
  192. vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
  193. const method = init?.method ?? 'GET'
  194. if (path.endsWith('/status')) return Response.json({ available: true })
  195. if (path.endsWith('/boards') && method === 'GET') return Response.json({ boards: [{ slug: 'default', total: 0 }], current: 'default' })
  196. if (path.endsWith('/boards') && method === 'POST') {
  197. createCalls += 1
  198. return createResponse.promise
  199. }
  200. if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
  201. if (path.includes('/board?')) {
  202. boardReads += 1
  203. return Response.json({ columns: [], tenants: [], assignees: [], latest_event_id: 0, now: 1 })
  204. }
  205. return Response.json({ ok: true })
  206. }))
  207. const auth = useAuthStore()
  208. auth.status = 'authenticated'
  209. auth.user = { id: 'admin-create-race', username: 'admin', role: 'admin' }
  210. const store = useKanbanStore()
  211. await store.initialize()
  212. expect(boardReads).toBe(1)
  213. const pending = store.createBoard({ slug: 'stale-board', name: '旧账号看板' })
  214. await vi.waitFor(() => expect(createCalls).toBe(1))
  215. store.reset()
  216. createResponse.resolve(Response.json({ board: { slug: 'stale-board', name: '旧账号看板' }, current: 'default' }))
  217. await expect(pending).rejects.toMatchObject({ code: 'REQUEST_ABORTED' })
  218. expect(store.boards).toEqual([])
  219. expect(store.selectedBoardSlug).toBe('')
  220. expect(store.snapshot).toBeUndefined()
  221. expect(store.isMutating).toBe(false)
  222. expect(boardReads).toBe(1)
  223. })
  224. })