@tuturuuu/ui 0.37.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -0,0 +1,404 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ useWorkspaceActor,
6
+ WorkspaceVisibilityProvider,
7
+ } from '../../../../../hooks/use-workspace-visibility';
8
+ import { useInvoiceCustomerSearch } from './use-invoice-customer-search';
9
+
10
+ function user(label: string, id = 'selected') {
11
+ return { id, display_name: label, full_name: label };
12
+ }
13
+ function reply(payload: unknown) {
14
+ return new Response(JSON.stringify(payload), { status: 200 });
15
+ }
16
+ function held() {
17
+ let resolve!: (response: Response) => void;
18
+ let reject!: (error: Error) => void;
19
+ const promise = new Promise<Response>((yes, no) => {
20
+ resolve = yes;
21
+ reject = no;
22
+ });
23
+ return { promise, resolve, reject };
24
+ }
25
+ const transport = vi.fn();
26
+ let owner = 'account-A';
27
+ let currentQuery: ReturnType<typeof useInvoiceCustomerSearch>;
28
+ let currentActor: ReturnType<typeof useWorkspaceActor>;
29
+ function Probe({
30
+ ws,
31
+ search,
32
+ selected,
33
+ }: {
34
+ ws: string;
35
+ search: string;
36
+ selected: string;
37
+ }) {
38
+ currentActor = useWorkspaceActor();
39
+ currentQuery = useInvoiceCustomerSearch(ws, search, selected);
40
+ return (
41
+ <>
42
+ <output aria-label="Synthetic customers">
43
+ {currentQuery.customers.map((item) => item.display_name).join(',')}
44
+ </output>
45
+ <output aria-label="Synthetic selected">
46
+ {currentQuery.selectedUser?.display_name ?? 'none'}
47
+ </output>
48
+ <output aria-label="Synthetic error">
49
+ {currentQuery.error ? 'failed' : 'none'}
50
+ </output>
51
+ <output aria-label="Synthetic loading">
52
+ {String(currentQuery.isLoading)}
53
+ </output>
54
+ </>
55
+ );
56
+ }
57
+ function mount(selected = '') {
58
+ const client = new QueryClient({
59
+ defaultOptions: { queries: { retry: false } },
60
+ });
61
+ const content = (actor: string, ws: string, search: string) => (
62
+ <QueryClientProvider client={client}>
63
+ <WorkspaceVisibilityProvider actorId={actor}>
64
+ <Probe ws={ws} search={search} selected={selected} />
65
+ </WorkspaceVisibilityProvider>
66
+ </QueryClientProvider>
67
+ );
68
+ const result = render(content('account-A', 'ws-1', ''));
69
+ return {
70
+ ...result,
71
+ client,
72
+ redraw: (actor = 'account-A', ws = 'ws-1', search = '') => {
73
+ owner = actor;
74
+ result.rerender(content(actor, ws, search));
75
+ },
76
+ };
77
+ }
78
+ function url(input: string) {
79
+ return new URL(input, window.location.origin);
80
+ }
81
+ function listCalls() {
82
+ return transport.mock.calls.filter(([input]) =>
83
+ url(input).pathname.endsWith('/users')
84
+ );
85
+ }
86
+ function detailCalls() {
87
+ return transport.mock.calls.filter(([input]) =>
88
+ url(input).pathname.endsWith('/users/selected')
89
+ );
90
+ }
91
+ async function customers(label: string) {
92
+ await waitFor(() =>
93
+ expect(screen.getByLabelText('Synthetic customers')).toHaveTextContent(
94
+ label
95
+ )
96
+ );
97
+ }
98
+ async function selected(label: string) {
99
+ await waitFor(() =>
100
+ expect(screen.getByLabelText('Synthetic selected')).toHaveTextContent(label)
101
+ );
102
+ }
103
+ beforeEach(() => {
104
+ owner = 'account-A';
105
+ transport.mockReset();
106
+ transport.mockImplementation((input: string) => {
107
+ const path = url(input).pathname;
108
+ if (path.endsWith('/users'))
109
+ return Promise.resolve(reply({ data: [user(owner)], count: 1 }));
110
+ if (path.endsWith('/users/selected'))
111
+ return Promise.resolve(reply(user(owner)));
112
+ throw new Error('Unexpected synthetic API route');
113
+ });
114
+ vi.stubGlobal('fetch', transport);
115
+ });
116
+ afterEach(() => {
117
+ cleanup();
118
+ vi.unstubAllGlobals();
119
+ });
120
+
121
+ describe('actual customer search account and workspace handoff', () => {
122
+ it('reauthorizes fresh list rows for the replacement account', async () => {
123
+ const view = mount();
124
+ await customers('account-A');
125
+ const expired = currentActor;
126
+ view.redraw('account-B');
127
+ expect(() => expired?.assertActive()).toThrow('Workspace account changed');
128
+ await waitFor(() => expect(listCalls()).toHaveLength(2));
129
+ await customers('account-B');
130
+ expect(screen.getByLabelText('Synthetic customers')).not.toHaveTextContent(
131
+ 'account-A'
132
+ );
133
+ });
134
+ it('reauthorizes cached selected detail even when the list stays empty', async () => {
135
+ transport.mockImplementation((input: string) =>
136
+ Promise.resolve(
137
+ url(input).pathname.endsWith('/users')
138
+ ? reply({ data: [], count: 0 })
139
+ : reply(user(owner))
140
+ )
141
+ );
142
+ const view = mount('selected');
143
+ await selected('account-A');
144
+ view.redraw('account-B');
145
+ await waitFor(() => expect(detailCalls()).toHaveLength(2));
146
+ await selected('account-B');
147
+ expect(screen.getByLabelText('Synthetic selected')).not.toHaveTextContent(
148
+ 'account-A'
149
+ );
150
+ });
151
+ it('does not use old workspace placeholder rows as current selected-customer authority', async () => {
152
+ const pending = held();
153
+ const view = mount('selected');
154
+ await selected('account-A');
155
+ transport.mockImplementation((input: string) => {
156
+ const parsed = url(input);
157
+ if (parsed.pathname === '/api/v1/workspaces/ws-2/users')
158
+ return pending.promise;
159
+ if (parsed.pathname === '/api/v1/workspaces/ws-2/users/selected')
160
+ return Promise.resolve(reply([]));
161
+ throw new Error('Unexpected synthetic new-workspace route');
162
+ });
163
+ view.redraw('account-A', 'ws-2');
164
+ expect(screen.getByLabelText('Synthetic customers')).not.toHaveTextContent(
165
+ 'account-A'
166
+ );
167
+ expect(screen.getByLabelText('Synthetic selected')).toHaveTextContent(
168
+ 'none'
169
+ );
170
+ await waitFor(() =>
171
+ expect(
172
+ detailCalls().some(([input]) => url(input).pathname.includes('/ws-2/'))
173
+ ).toBe(true)
174
+ );
175
+ await act(async () => {
176
+ pending.resolve(reply({ data: [], count: 0 }));
177
+ await pending.promise;
178
+ });
179
+ expect(screen.getByLabelText('Synthetic selected')).toHaveTextContent(
180
+ 'none'
181
+ );
182
+ });
183
+ for (const kind of ['list', 'detail'] as const) {
184
+ for (const boundary of ['replacement', 'ABA'] as const) {
185
+ it(`held old ${kind} cannot supply current customer after actor ${boundary}`, async () => {
186
+ const pending = held();
187
+ let admittedOld = false;
188
+ transport.mockImplementation((input: string) => {
189
+ const isList = url(input).pathname.endsWith('/users');
190
+ if ((kind === 'list' && isList) || (kind === 'detail' && !isList)) {
191
+ if (!admittedOld) {
192
+ admittedOld = true;
193
+ return pending.promise;
194
+ }
195
+ return Promise.resolve(
196
+ reply(
197
+ isList
198
+ ? { data: [user('new-lifetime')], count: 1 }
199
+ : user('new-lifetime')
200
+ )
201
+ );
202
+ }
203
+ return Promise.resolve(reply({ data: [], count: 0 }));
204
+ });
205
+ const view = mount(kind === 'detail' ? 'selected' : '');
206
+ await waitFor(() => expect(admittedOld).toBe(true));
207
+ const expired = currentActor;
208
+ view.redraw('account-B');
209
+ if (boundary === 'ABA') {
210
+ await act(async () => {
211
+ await Promise.resolve();
212
+ });
213
+ view.redraw('account-A');
214
+ }
215
+ expect(() => expired?.assertActive()).toThrow(
216
+ 'Workspace account changed'
217
+ );
218
+ await act(async () => {
219
+ pending.resolve(
220
+ reply(
221
+ kind === 'list'
222
+ ? { data: [user('expired-lifetime')], count: 1 }
223
+ : user('expired-lifetime')
224
+ )
225
+ );
226
+ await pending.promise;
227
+ });
228
+ if (kind === 'list') await customers('new-lifetime');
229
+ else await selected('new-lifetime');
230
+ expect(
231
+ screen.getByLabelText('Synthetic customers')
232
+ ).not.toHaveTextContent('expired-lifetime');
233
+ expect(
234
+ screen.getByLabelText('Synthetic selected')
235
+ ).not.toHaveTextContent('expired-lifetime');
236
+ });
237
+ }
238
+ }
239
+ it('reuses fresh rows within the same account and workspace', async () => {
240
+ const view = mount();
241
+ await customers('account-A');
242
+ view.redraw();
243
+ await customers('account-A');
244
+ expect(listCalls()).toHaveLength(1);
245
+ });
246
+ it('retains same-workspace search placeholders until the next search settles', async () => {
247
+ const pending = held();
248
+ const view = mount();
249
+ await customers('account-A');
250
+ transport.mockReturnValueOnce(pending.promise);
251
+ view.redraw('account-A', 'ws-1', ' next ');
252
+ expect(screen.getByLabelText('Synthetic customers')).toHaveTextContent(
253
+ 'account-A'
254
+ );
255
+ await waitFor(() => expect(listCalls()).toHaveLength(2));
256
+ expect(url(listCalls()[1]?.[0]).searchParams.get('q')).toBe('next');
257
+ await act(async () => {
258
+ pending.resolve(reply({ data: [user('next-search')], count: 1 }));
259
+ await pending.promise;
260
+ });
261
+ await customers('next-search');
262
+ });
263
+ it('retains normalized inclusive paging and selected fallback error recovery', async () => {
264
+ transport.mockImplementation((input: string) => {
265
+ if (url(input).pathname.endsWith('/users'))
266
+ return Promise.resolve(reply({ data: [], count: 0 }));
267
+ return Promise.reject(new Error('Synthetic selected failure'));
268
+ });
269
+ mount('selected');
270
+ await waitFor(() =>
271
+ expect(screen.getByLabelText('Synthetic error')).toHaveTextContent(
272
+ 'failed'
273
+ )
274
+ );
275
+ const parsed = url(listCalls()[0]?.[0]);
276
+ expect(Object.fromEntries(parsed.searchParams)).toMatchObject({
277
+ from: '0',
278
+ to: '24',
279
+ limit: '25',
280
+ });
281
+ expect(currentQuery.hasNextPage).toBe(false);
282
+ transport.mockImplementation((input: string) =>
283
+ Promise.resolve(
284
+ url(input).pathname.endsWith('/users')
285
+ ? reply({ data: [], count: 0 })
286
+ : reply(user('recovered'))
287
+ )
288
+ );
289
+ await act(async () => {
290
+ await currentQuery.refetch();
291
+ });
292
+ await selected('recovered');
293
+ expect(screen.getByLabelText('Synthetic error')).toHaveTextContent('none');
294
+ });
295
+ for (const kind of ['list', 'detail'] as const) {
296
+ it(`an expired ${kind} transport error cannot affect the new account lifetime`, async () => {
297
+ const pending = held();
298
+ let oldAdmitted = false;
299
+ transport.mockImplementation((input: string) => {
300
+ const isList = url(input).pathname.endsWith('/users');
301
+ if ((kind === 'list' && isList) || (kind === 'detail' && !isList)) {
302
+ if (!oldAdmitted) {
303
+ oldAdmitted = true;
304
+ return pending.promise;
305
+ }
306
+ return Promise.resolve(
307
+ reply(
308
+ isList
309
+ ? { data: [user('current-lifetime')], count: 1 }
310
+ : user('current-lifetime')
311
+ )
312
+ );
313
+ }
314
+ return Promise.resolve(reply({ data: [], count: 0 }));
315
+ });
316
+ const view = mount(kind === 'detail' ? 'selected' : '');
317
+ await waitFor(() => expect(oldAdmitted).toBe(true));
318
+ view.redraw('account-B');
319
+ if (kind === 'list') await customers('current-lifetime');
320
+ else await selected('current-lifetime');
321
+ view.redraw('account-A');
322
+ if (kind === 'list') await customers('current-lifetime');
323
+ else await selected('current-lifetime');
324
+ await act(async () => {
325
+ pending.reject(new Error('Synthetic expired transport failure'));
326
+ await pending.promise.catch(() => {});
327
+ });
328
+ expect(screen.getByLabelText('Synthetic error')).toHaveTextContent(
329
+ 'none'
330
+ );
331
+ expect(currentQuery.error).toBeNull();
332
+ await waitFor(() =>
333
+ expect(
334
+ view.client
335
+ .getQueryCache()
336
+ .getAll()
337
+ .find((entry) => entry.state.error)?.state.error
338
+ ).toEqual(new Error('Workspace account changed'))
339
+ );
340
+ });
341
+ }
342
+ it('does not admit automatic, manual or page reads without a verified actor', async () => {
343
+ const client = new QueryClient();
344
+ render(
345
+ <QueryClientProvider client={client}>
346
+ <Probe ws="ws-1" search="" selected="selected" />
347
+ </QueryClientProvider>
348
+ );
349
+ await act(async () => {
350
+ await Promise.resolve();
351
+ });
352
+ expect(transport).not.toHaveBeenCalled();
353
+ let manual!: Awaited<ReturnType<typeof currentQuery.refetch>>;
354
+ let page!: Awaited<ReturnType<typeof currentQuery.fetchNextPage>>;
355
+ await act(async () => {
356
+ manual = await currentQuery.refetch();
357
+ page = await currentQuery.fetchNextPage();
358
+ });
359
+ expect(manual[0]?.error).toEqual(
360
+ new Error('Workspace account unavailable')
361
+ );
362
+ expect(page.error).toEqual(new Error('Workspace account unavailable'));
363
+ expect(transport).not.toHaveBeenCalled();
364
+ expect(screen.getByLabelText('Synthetic selected')).toHaveTextContent(
365
+ 'none'
366
+ );
367
+ });
368
+ it('rejects a retained old account page callback before transport dispatch', async () => {
369
+ transport.mockImplementation(() =>
370
+ Promise.resolve(reply({ data: [user(owner)], count: 2 }))
371
+ );
372
+ const view = mount();
373
+ await customers('account-A');
374
+ const previous = currentQuery;
375
+ expect(previous.hasNextPage).toBe(true);
376
+ view.redraw('account-B');
377
+ await customers('account-B');
378
+ const admitted = transport.mock.calls.length;
379
+ let result!: Awaited<ReturnType<typeof previous.fetchNextPage>>;
380
+ await act(async () => {
381
+ result = await previous.fetchNextPage();
382
+ });
383
+ expect(result.error).toEqual(new Error('Workspace account changed'));
384
+ expect(transport).toHaveBeenCalledTimes(admitted);
385
+ await customers('account-B');
386
+ });
387
+ it('preserves current list transport error and explicit recovery', async () => {
388
+ const failure = new Error('Synthetic current list failure');
389
+ transport.mockRejectedValueOnce(failure);
390
+ mount();
391
+ await waitFor(() =>
392
+ expect(screen.getByLabelText('Synthetic error')).toHaveTextContent(
393
+ 'failed'
394
+ )
395
+ );
396
+ expect(currentQuery.error).toBe(failure);
397
+ expect(transport).toHaveBeenCalledOnce();
398
+ await act(async () => {
399
+ await currentQuery.refetch();
400
+ });
401
+ await customers('account-A');
402
+ expect(screen.getByLabelText('Synthetic error')).toHaveTextContent('none');
403
+ });
404
+ });
@@ -0,0 +1,11 @@
1
+ // The weak key follows the verified provider lifetime, including actor ABA.
2
+ const lifetimes = new WeakMap<object, number>();
3
+ let nextLifetime = 0;
4
+ export function invoiceActorLifetimeKey(lifetime: object) {
5
+ let key = lifetimes.get(lifetime);
6
+ if (key === undefined) {
7
+ key = ++nextLifetime;
8
+ lifetimes.set(lifetime, key);
9
+ }
10
+ return key;
11
+ }
@@ -0,0 +1,48 @@
1
+ import { useInfiniteQuery } from '@tanstack/react-query';
2
+ import { listFinanceInvoices } from '@tuturuuu/internal-api/finance';
3
+ import { useWorkspaceActor } from '../../../../../hooks/use-workspace-visibility';
4
+ import { invoiceActorLifetimeKey } from './invoice-actor-lifetime-key';
5
+
6
+ export function useInfiniteUserInvoices(
7
+ wsId: string,
8
+ userId: string,
9
+ pageSize = 10
10
+ ) {
11
+ const actor = useWorkspaceActor();
12
+ return useInfiniteQuery({
13
+ queryKey: [
14
+ 'infinite-user-invoices',
15
+ wsId,
16
+ userId,
17
+ actor?.actorId ?? null,
18
+ actor ? invoiceActorLifetimeKey(actor.lifetime) : null,
19
+ ],
20
+ queryFn: async ({ pageParam = 1 }: { pageParam: number }) => {
21
+ if (!actor) throw new Error('Workspace account unavailable');
22
+ actor.assertActive();
23
+ try {
24
+ const payload = await listFinanceInvoices(wsId, {
25
+ customerIds: [userId],
26
+ page: String(pageParam),
27
+ pageSize: String(pageSize),
28
+ });
29
+ actor.assertActive();
30
+ const data = payload.data || [];
31
+ const count = payload.count ?? 0;
32
+ const fetchedCount = pageParam * pageSize;
33
+ return {
34
+ data,
35
+ count,
36
+ nextPage: fetchedCount < count ? pageParam + 1 : null,
37
+ hasMore: fetchedCount < count,
38
+ };
39
+ } catch (error) {
40
+ actor.assertActive();
41
+ throw error;
42
+ }
43
+ },
44
+ initialPageParam: 1,
45
+ getNextPageParam: (lastPage) => lastPage.nextPage,
46
+ enabled: !!actor && !!userId,
47
+ });
48
+ }
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
2
  import { act, renderHook, waitFor } from '@testing-library/react';
3
3
  import type { PropsWithChildren } from 'react';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { WorkspaceVisibilityProvider } from '../../../../../hooks/use-workspace-visibility';
5
6
  import { useInvoiceCustomerSearch } from './use-invoice-customer-search';
6
7
 
7
8
  const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn() }));
@@ -19,7 +20,11 @@ function renderSearch(selectedUserId = '') {
19
20
  const client = new QueryClient();
20
21
  function Wrapper({ children }: PropsWithChildren) {
21
22
  return (
22
- <QueryClientProvider client={client}>{children}</QueryClientProvider>
23
+ <QueryClientProvider client={client}>
24
+ <WorkspaceVisibilityProvider actorId="synthetic-account">
25
+ {children}
26
+ </WorkspaceVisibilityProvider>
27
+ </QueryClientProvider>
23
28
  );
24
29
  }
25
30
  return renderHook(
@@ -1,9 +1,11 @@
1
1
  import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
2
2
  import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
3
+ import { useWorkspaceActor } from '../../../../../hooks/use-workspace-visibility';
3
4
  import {
4
5
  getWorkspaceUserWithInternalApi,
5
6
  listWorkspaceUsersWithInternalApi,
6
7
  } from '../internal-api';
8
+ import { invoiceActorLifetimeKey } from './invoice-actor-lifetime-key';
7
9
 
8
10
  const INVOICE_CUSTOMER_PAGE_SIZE = 25;
9
11
 
@@ -39,19 +41,59 @@ async function fetchWorkspaceUserById(
39
41
  return getWorkspaceUserWithInternalApi(wsId, userId);
40
42
  }
41
43
 
44
+ async function readInActorScope<T>(
45
+ actor: ReturnType<typeof useWorkspaceActor>,
46
+ read: () => Promise<T>
47
+ ): Promise<T> {
48
+ if (!actor) throw new Error('Workspace account unavailable');
49
+ actor.assertActive();
50
+ try {
51
+ const result = await read();
52
+ actor.assertActive();
53
+ return result;
54
+ } catch (error) {
55
+ actor.assertActive();
56
+ throw error;
57
+ }
58
+ }
59
+
42
60
  export function useInvoiceCustomerSearch(
43
61
  wsId: string,
44
62
  searchQuery: string,
45
63
  selectedUserId: string
46
64
  ) {
65
+ const actor = useWorkspaceActor();
66
+ const lifetime = actor ? invoiceActorLifetimeKey(actor.lifetime) : null;
47
67
  const normalizedSearchQuery = searchQuery.trim();
48
68
 
49
69
  const usersQuery = useInfiniteQuery({
50
- queryKey: ['invoice-customer-search', wsId, normalizedSearchQuery],
70
+ queryKey: [
71
+ 'invoice-customer-search',
72
+ wsId,
73
+ normalizedSearchQuery,
74
+ actor?.actorId ?? null,
75
+ lifetime,
76
+ ],
51
77
  queryFn: async ({ pageParam = 0 }) =>
52
- fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam),
78
+ readInActorScope(actor, () =>
79
+ fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam)
80
+ ),
53
81
  initialPageParam: 0,
54
- placeholderData: (previousData) => previousData,
82
+ placeholderData: (previousData, previousQuery) => {
83
+ if (
84
+ !actor ||
85
+ previousQuery?.queryKey[1] !== wsId ||
86
+ previousQuery.queryKey[3] !== actor.actorId ||
87
+ previousQuery.queryKey[4] !== lifetime
88
+ )
89
+ return undefined;
90
+ try {
91
+ actor.assertActive();
92
+ return previousData;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ },
55
97
  getNextPageParam: (lastPage, allPages) => {
56
98
  const loadedCount = allPages.reduce(
57
99
  (total, page) => total + page.data.length,
@@ -61,7 +103,7 @@ export function useInvoiceCustomerSearch(
61
103
  ? loadedCount
62
104
  : undefined;
63
105
  },
64
- enabled: !!wsId,
106
+ enabled: !!actor && !!wsId,
65
107
  retry: false,
66
108
  staleTime: 30 * 1000,
67
109
  gcTime: 5 * 60 * 1000,
@@ -71,12 +113,22 @@ export function useInvoiceCustomerSearch(
71
113
  const loadedCustomers =
72
114
  usersQuery.data?.pages.flatMap((page) => page.data) ?? [];
73
115
  const needsSelectedUser =
116
+ !!actor &&
74
117
  !!wsId &&
75
118
  !!selectedUserId &&
76
119
  !loadedCustomers.some((user) => user.id === selectedUserId);
77
120
  const selectedUserQuery = useQuery({
78
- queryKey: ['invoice-customer', wsId, selectedUserId],
79
- queryFn: async () => fetchWorkspaceUserById(wsId, selectedUserId),
121
+ queryKey: [
122
+ 'invoice-customer',
123
+ wsId,
124
+ selectedUserId,
125
+ actor?.actorId ?? null,
126
+ lifetime,
127
+ ],
128
+ queryFn: async () =>
129
+ readInActorScope(actor, () =>
130
+ fetchWorkspaceUserById(wsId, selectedUserId)
131
+ ),
80
132
  enabled: needsSelectedUser,
81
133
  retry: false,
82
134
  staleTime: 30 * 1000,
@@ -1,10 +1,17 @@
1
1
  import { renderHook } from '@testing-library/react';
2
2
  import { useState } from 'react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
+ import { listMultiGroupProductsWithInternalApi } from '../internal-api';
4
5
  import type { Product, SelectedProductItem, UserGroupProducts } from '../types';
5
6
  import type { UserGroup } from '../utils';
6
7
  import { useSubscriptionAutoSelection } from './use-subscription-auto-selection';
7
8
 
9
+ const api = vi.hoisted(() => ({ json: vi.fn() }));
10
+ vi.mock('@tuturuuu/internal-api/client', () => ({
11
+ encodePathSegment: encodeURIComponent,
12
+ getInternalApiClient: () => ({ json: api.json }),
13
+ }));
14
+
8
15
  const translate = (key: string) => key;
9
16
  vi.mock('next-intl', () => ({ useTranslations: () => translate }));
10
17
  vi.mock('@tuturuuu/ui/sonner', () => ({ toast: vi.fn() }));
@@ -253,3 +260,30 @@ describe('prefilled subscription quantities', () => {
253
260
  expect(result.current.map((row) => row.quantity)).toEqual([2]);
254
261
  });
255
262
  });
263
+
264
+ describe('RPC-shaped linked inventory admission', () => {
265
+ it('does not crash or choose an arbitrary unit for a mapped null inventory unit', async () => {
266
+ // The scoped RPC uses a LEFT JOIN and explicitly returns null for a
267
+ // missing inventory unit. Exercise the real internal-api mapping.
268
+ api.json.mockResolvedValueOnce({
269
+ items: [{ ...groupProducts[0], inventory_units: null }],
270
+ });
271
+ const mapped = await listMultiGroupProductsWithInternalApi('ws', [
272
+ 'class-a',
273
+ ]);
274
+ expect(mapped[0]?.inventory_units).toBeNull();
275
+ const onSelectedProductsChange = vi.fn();
276
+ expect(() =>
277
+ renderHook(() =>
278
+ useSubscriptionAutoSelection({
279
+ ...baseProps,
280
+ groupProducts: mapped,
281
+ onSelectedProductsChange,
282
+ })
283
+ )
284
+ ).not.toThrow();
285
+ // A submission-blocking validation result is required; silently dropping
286
+ // this charge or selecting an unrelated unit is not a safe correction.
287
+ expect(onSelectedProductsChange).not.toHaveBeenCalled();
288
+ });
289
+ });