@tuturuuu/ui 0.38.0 → 0.39.1
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.
- package/CHANGELOG.md +34 -0
- package/package.json +8 -8
- package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
- package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +489 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/public-link-button.test.tsx +116 -0
- package/src/components/ui/public-link-button.tsx +81 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from '@testing-library/react';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
useWorkspaceActor,
|
|
13
|
+
WorkspaceVisibilityProvider,
|
|
14
|
+
} from '../../../../../hooks/use-workspace-visibility';
|
|
15
|
+
import { FinanceRouteProvider } from '../../finance-route-context';
|
|
16
|
+
import { useInfiniteUserInvoices } from '../hooks';
|
|
17
|
+
import { invalidateInvoiceMutationQueries } from '../query-invalidation';
|
|
18
|
+
import { InvoiceUserHistoryAccordion } from './invoice-user-history-accordion';
|
|
19
|
+
|
|
20
|
+
vi.mock('next-intl', () => ({
|
|
21
|
+
useLocale: () => 'en-US',
|
|
22
|
+
useTranslations: () => (key: string) => key,
|
|
23
|
+
}));
|
|
24
|
+
const transport = vi.fn();
|
|
25
|
+
let owner = 'account-A';
|
|
26
|
+
let history: ReturnType<typeof useInfiniteUserInvoices>;
|
|
27
|
+
function Probe({ ws, customer }: { ws: string; customer: string }) {
|
|
28
|
+
history = useInfiniteUserInvoices(ws, customer);
|
|
29
|
+
return <span data-testid="history-error">{history.error?.message}</span>;
|
|
30
|
+
}
|
|
31
|
+
let actorLease: ReturnType<typeof useWorkspaceActor>;
|
|
32
|
+
function Witness() {
|
|
33
|
+
actorLease = useWorkspaceActor();
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function invoice(label: string, id = 'synthetic-invoice') {
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
notice: label,
|
|
40
|
+
price: 100,
|
|
41
|
+
total_diff: 0,
|
|
42
|
+
created_at: '2026-09-01T12:00:00.000Z',
|
|
43
|
+
note: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function reply(label: string, count = 1, id?: string) {
|
|
47
|
+
return new Response(JSON.stringify({ data: [invoice(label, id)], count }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function held() {
|
|
52
|
+
let resolve!: (value: Response) => void;
|
|
53
|
+
let reject!: (error: Error) => void;
|
|
54
|
+
const promise = new Promise<Response>((yes, no) => {
|
|
55
|
+
resolve = yes;
|
|
56
|
+
reject = no;
|
|
57
|
+
});
|
|
58
|
+
return { promise, resolve, reject };
|
|
59
|
+
}
|
|
60
|
+
function parsed(input: string) {
|
|
61
|
+
return new URL(input, window.location.origin);
|
|
62
|
+
}
|
|
63
|
+
function mount(initialActor: string | null = 'account-A') {
|
|
64
|
+
// Same persistent QueryClient as the satellite provider across actor remounts.
|
|
65
|
+
const client = new QueryClient({
|
|
66
|
+
defaultOptions: {
|
|
67
|
+
queries: {
|
|
68
|
+
retry: false,
|
|
69
|
+
staleTime: 300_000,
|
|
70
|
+
refetchOnWindowFocus: false,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const content = (actor: string | null, ws: string, customer: string) => {
|
|
75
|
+
const body = (
|
|
76
|
+
<>
|
|
77
|
+
<Witness />
|
|
78
|
+
<Probe ws={ws} customer={customer} />
|
|
79
|
+
<FinanceRouteProvider prefix="">
|
|
80
|
+
<InvoiceUserHistoryAccordion wsId={ws} userId={customer} />
|
|
81
|
+
</FinanceRouteProvider>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
return (
|
|
85
|
+
<QueryClientProvider client={client}>
|
|
86
|
+
{actor ? (
|
|
87
|
+
<WorkspaceVisibilityProvider actorId={actor}>
|
|
88
|
+
{body}
|
|
89
|
+
</WorkspaceVisibilityProvider>
|
|
90
|
+
) : (
|
|
91
|
+
body
|
|
92
|
+
)}
|
|
93
|
+
</QueryClientProvider>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
const result = render(content(initialActor, 'ws-1', 'customer-1'));
|
|
97
|
+
return {
|
|
98
|
+
...result,
|
|
99
|
+
client,
|
|
100
|
+
redraw: (
|
|
101
|
+
actor: string | null = 'account-A',
|
|
102
|
+
ws = 'ws-1',
|
|
103
|
+
customer = 'customer-1'
|
|
104
|
+
) => {
|
|
105
|
+
owner = actor ?? 'missing-account';
|
|
106
|
+
result.rerender(content(actor, ws, customer));
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async function openHistory(label: string) {
|
|
111
|
+
const trigger = await screen.findByRole('button', {
|
|
112
|
+
name: /ws-invoices.plural/,
|
|
113
|
+
});
|
|
114
|
+
if (trigger.getAttribute('aria-expanded') !== 'true')
|
|
115
|
+
fireEvent.click(trigger);
|
|
116
|
+
await waitFor(() => expect(screen.getByText(label)).toBeVisible());
|
|
117
|
+
}
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
owner = 'account-A';
|
|
120
|
+
transport.mockReset();
|
|
121
|
+
transport.mockImplementation((input: string) => {
|
|
122
|
+
if (!parsed(input).pathname.endsWith('/finance/invoices'))
|
|
123
|
+
throw new Error('Unexpected synthetic history route');
|
|
124
|
+
return Promise.resolve(reply(owner));
|
|
125
|
+
});
|
|
126
|
+
vi.stubGlobal('fetch', transport);
|
|
127
|
+
vi.stubGlobal(
|
|
128
|
+
'IntersectionObserver',
|
|
129
|
+
class {
|
|
130
|
+
observe() {}
|
|
131
|
+
unobserve() {}
|
|
132
|
+
disconnect() {}
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
vi.stubGlobal(
|
|
136
|
+
'ResizeObserver',
|
|
137
|
+
class {
|
|
138
|
+
observe() {}
|
|
139
|
+
unobserve() {}
|
|
140
|
+
disconnect() {}
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
cleanup();
|
|
146
|
+
vi.unstubAllGlobals();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('actual mounted invoice history cache account lifetime', () => {
|
|
150
|
+
it('reauthorizes the replacement actor before showing cached invoice history', async () => {
|
|
151
|
+
const view = mount();
|
|
152
|
+
await openHistory('account-A');
|
|
153
|
+
const expired = actorLease;
|
|
154
|
+
view.redraw('account-B');
|
|
155
|
+
expect(() => expired?.assertActive()).toThrow('Workspace account changed');
|
|
156
|
+
await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
|
|
157
|
+
await openHistory('account-B');
|
|
158
|
+
expect(screen.queryByText('account-A')).not.toBeInTheDocument();
|
|
159
|
+
expect(screen.getByRole('link')).toHaveAttribute(
|
|
160
|
+
'href',
|
|
161
|
+
'/ws-1/invoices/synthetic-invoice'
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
for (const boundary of ['replacement', 'ABA'] as const) {
|
|
165
|
+
it(`does not show an old held response after actor ${boundary}`, async () => {
|
|
166
|
+
const old = held();
|
|
167
|
+
transport
|
|
168
|
+
.mockReturnValueOnce(old.promise)
|
|
169
|
+
.mockImplementation(() => Promise.resolve(reply('new-lifetime')));
|
|
170
|
+
const view = mount();
|
|
171
|
+
await waitFor(() => expect(transport).toHaveBeenCalledOnce());
|
|
172
|
+
const expired = actorLease;
|
|
173
|
+
view.redraw('account-B');
|
|
174
|
+
if (boundary === 'ABA') {
|
|
175
|
+
await act(async () => {
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
});
|
|
178
|
+
view.redraw('account-A');
|
|
179
|
+
}
|
|
180
|
+
expect(() => expired?.assertActive()).toThrow(
|
|
181
|
+
'Workspace account changed'
|
|
182
|
+
);
|
|
183
|
+
await act(async () => {
|
|
184
|
+
old.resolve(reply('expired-lifetime'));
|
|
185
|
+
await old.promise;
|
|
186
|
+
});
|
|
187
|
+
await openHistory('new-lifetime');
|
|
188
|
+
expect(screen.queryByText('expired-lifetime')).not.toBeInTheDocument();
|
|
189
|
+
const active = view.client
|
|
190
|
+
.getQueryCache()
|
|
191
|
+
.getAll()
|
|
192
|
+
.filter((entry) => entry.getObserversCount() > 0);
|
|
193
|
+
expect(active).toHaveLength(1);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
it('reuses fresh history within the same actor lifetime', async () => {
|
|
197
|
+
const view = mount();
|
|
198
|
+
await openHistory('account-A');
|
|
199
|
+
view.redraw();
|
|
200
|
+
await openHistory('account-A');
|
|
201
|
+
expect(transport).toHaveBeenCalledOnce();
|
|
202
|
+
});
|
|
203
|
+
for (const scope of ['workspace', 'customer'] as const) {
|
|
204
|
+
it(`keeps same-actor ${scope} history isolated`, async () => {
|
|
205
|
+
const view = mount();
|
|
206
|
+
await openHistory('account-A');
|
|
207
|
+
transport.mockImplementation(() =>
|
|
208
|
+
Promise.resolve(reply('other-history'))
|
|
209
|
+
);
|
|
210
|
+
view.redraw(
|
|
211
|
+
'account-A',
|
|
212
|
+
scope === 'workspace' ? 'ws-2' : 'ws-1',
|
|
213
|
+
scope === 'customer' ? 'customer-2' : 'customer-1'
|
|
214
|
+
);
|
|
215
|
+
await openHistory('other-history');
|
|
216
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
217
|
+
expect(screen.queryByText('account-A')).not.toBeInTheDocument();
|
|
218
|
+
const request = parsed(transport.mock.calls[1]?.[0]);
|
|
219
|
+
expect(request.pathname).toContain(
|
|
220
|
+
scope === 'workspace' ? '/ws-2/' : '/ws-1/'
|
|
221
|
+
);
|
|
222
|
+
expect(request.searchParams.getAll('customerIds')).toEqual([
|
|
223
|
+
scope === 'customer' ? 'customer-2' : 'customer-1',
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
it('uses the actual load-more control with preserved page/count admission', async () => {
|
|
228
|
+
transport.mockImplementation((input: string) =>
|
|
229
|
+
Promise.resolve(
|
|
230
|
+
parsed(input).searchParams.get('page') === '2'
|
|
231
|
+
? reply('second-page', 11, 'synthetic-second')
|
|
232
|
+
: reply('first-page', 11)
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
mount();
|
|
236
|
+
await openHistory('first-page');
|
|
237
|
+
fireEvent.click(
|
|
238
|
+
screen.getByRole('button', { name: 'ws-invoices.load_more' })
|
|
239
|
+
);
|
|
240
|
+
await waitFor(() => expect(screen.getByText('second-page')).toBeVisible());
|
|
241
|
+
expect(screen.getByText('first-page')).toBeVisible();
|
|
242
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
243
|
+
expect(
|
|
244
|
+
Object.fromEntries(parsed(transport.mock.calls[1]?.[0]).searchParams)
|
|
245
|
+
).toMatchObject({ page: '2', pageSize: '10', customerIds: 'customer-1' });
|
|
246
|
+
expect(
|
|
247
|
+
screen.queryByRole('button', { name: 'ws-invoices.load_more' })
|
|
248
|
+
).not.toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
for (const boundary of ['replacement', 'ABA'] as const) {
|
|
251
|
+
it(`rejects a held old error after actor ${boundary} without replacing current history`, async () => {
|
|
252
|
+
const old = held();
|
|
253
|
+
transport
|
|
254
|
+
.mockReturnValueOnce(old.promise)
|
|
255
|
+
.mockImplementation(() => Promise.resolve(reply('current-history')));
|
|
256
|
+
const view = mount();
|
|
257
|
+
await waitFor(() => expect(transport).toHaveBeenCalledOnce());
|
|
258
|
+
const oldKey = view.client.getQueryCache().getAll()[0]?.queryKey ?? [];
|
|
259
|
+
view.redraw('account-B');
|
|
260
|
+
if (boundary === 'ABA') {
|
|
261
|
+
await act(async () => {
|
|
262
|
+
await Promise.resolve();
|
|
263
|
+
});
|
|
264
|
+
view.redraw('account-A');
|
|
265
|
+
}
|
|
266
|
+
await act(async () => {
|
|
267
|
+
old.reject(new Error('Synthetic transport failure'));
|
|
268
|
+
});
|
|
269
|
+
await openHistory('current-history');
|
|
270
|
+
expect(screen.getByTestId('history-error')).toBeEmptyDOMElement();
|
|
271
|
+
expect(view.client.getQueryState(oldKey)?.error).toMatchObject({
|
|
272
|
+
message: 'Workspace account changed',
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
it('denies missing-actor automatic, manual and page admission without transport', async () => {
|
|
277
|
+
mount(null);
|
|
278
|
+
expect(transport).not.toHaveBeenCalled();
|
|
279
|
+
await act(async () => {
|
|
280
|
+
const result = await history.refetch();
|
|
281
|
+
expect(result.error?.message).toBe('Workspace account unavailable');
|
|
282
|
+
const page = await history.fetchNextPage();
|
|
283
|
+
expect(page.error?.message).toBe('Workspace account unavailable');
|
|
284
|
+
});
|
|
285
|
+
expect(transport).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
it('denies a retained prior-actor page callback and keeps current pagination usable', async () => {
|
|
288
|
+
transport.mockImplementation((input: string) =>
|
|
289
|
+
Promise.resolve(
|
|
290
|
+
reply(`${owner}-${parsed(input).searchParams.get('page')}`, 11)
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
const view = mount();
|
|
294
|
+
await openHistory('account-A-1');
|
|
295
|
+
const oldPage = history.fetchNextPage;
|
|
296
|
+
view.redraw('account-B');
|
|
297
|
+
await openHistory('account-B-1');
|
|
298
|
+
const before = transport.mock.calls.length;
|
|
299
|
+
await act(async () => {
|
|
300
|
+
const result = await oldPage();
|
|
301
|
+
expect(result.error?.message).toBe('Workspace account changed');
|
|
302
|
+
});
|
|
303
|
+
expect(transport).toHaveBeenCalledTimes(before);
|
|
304
|
+
await act(async () => {
|
|
305
|
+
await history.fetchNextPage();
|
|
306
|
+
});
|
|
307
|
+
await waitFor(() => expect(screen.getByText('account-B-2')).toBeVisible());
|
|
308
|
+
});
|
|
309
|
+
it('retains workspace mutation invalidation and refetches active actor history', async () => {
|
|
310
|
+
const view = mount();
|
|
311
|
+
await openHistory('account-A');
|
|
312
|
+
transport.mockImplementation(() =>
|
|
313
|
+
Promise.resolve(reply('refreshed-history'))
|
|
314
|
+
);
|
|
315
|
+
await act(async () => {
|
|
316
|
+
await invalidateInvoiceMutationQueries(view.client, 'ws-2');
|
|
317
|
+
});
|
|
318
|
+
expect(transport).toHaveBeenCalledOnce();
|
|
319
|
+
await act(async () => {
|
|
320
|
+
await invalidateInvoiceMutationQueries(view.client, 'ws-1');
|
|
321
|
+
});
|
|
322
|
+
await openHistory('refreshed-history');
|
|
323
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
324
|
+
});
|
|
325
|
+
it('keeps a current transport error truthful and permits explicit retry', async () => {
|
|
326
|
+
transport.mockRejectedValueOnce(new Error('Synthetic current failure'));
|
|
327
|
+
mount();
|
|
328
|
+
await waitFor(() =>
|
|
329
|
+
expect(screen.getByTestId('history-error')).toHaveTextContent(
|
|
330
|
+
'Synthetic current failure'
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
expect(transport).toHaveBeenCalledOnce();
|
|
334
|
+
await act(async () => {
|
|
335
|
+
await history.refetch();
|
|
336
|
+
});
|
|
337
|
+
await openHistory('account-A');
|
|
338
|
+
expect(screen.getByTestId('history-error')).toBeEmptyDOMElement();
|
|
339
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
cleanup,
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
screen,
|
|
7
|
+
waitFor,
|
|
8
|
+
} from '@testing-library/react';
|
|
9
|
+
import { NextIntlClientProvider } from 'next-intl';
|
|
10
|
+
import { Component, type ReactNode } from 'react';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import en from '../../../../../../../../apps/contacts/messages/en.json';
|
|
13
|
+
import viMessages from '../../../../../../../../apps/contacts/messages/vi.json';
|
|
14
|
+
import { WorkspaceVisibilityProvider } from '../../../../../hooks/use-workspace-visibility';
|
|
15
|
+
import { FinanceRouteProvider } from '../../finance-route-context';
|
|
16
|
+
import { InvoiceUserHistoryAccordion } from './invoice-user-history-accordion';
|
|
17
|
+
|
|
18
|
+
const transport = vi.fn();
|
|
19
|
+
let caught: string[] = [];
|
|
20
|
+
class HistoryBoundary extends Component<
|
|
21
|
+
{ children: ReactNode },
|
|
22
|
+
{ failed: boolean }
|
|
23
|
+
> {
|
|
24
|
+
state = { failed: false };
|
|
25
|
+
static getDerivedStateFromError() {
|
|
26
|
+
return { failed: true };
|
|
27
|
+
}
|
|
28
|
+
componentDidCatch(error: Error) {
|
|
29
|
+
caught.push(error.name);
|
|
30
|
+
}
|
|
31
|
+
render() {
|
|
32
|
+
return this.state.failed ? (
|
|
33
|
+
<p>Synthetic history boundary</p>
|
|
34
|
+
) : (
|
|
35
|
+
this.props.children
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function mount(locale: 'en' | 'vi', date: string | null | undefined) {
|
|
40
|
+
transport.mockImplementation((input: string) => {
|
|
41
|
+
if (
|
|
42
|
+
!new URL(input, window.location.origin).pathname.endsWith(
|
|
43
|
+
'/finance/invoices'
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
throw new Error('Unexpected synthetic history route');
|
|
47
|
+
return Promise.resolve(
|
|
48
|
+
new Response(
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
data: [
|
|
51
|
+
{
|
|
52
|
+
id: 'synthetic-invoice',
|
|
53
|
+
notice: 'synthetic-history',
|
|
54
|
+
price: 100,
|
|
55
|
+
total_diff: 0,
|
|
56
|
+
created_at: date,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
count: 1,
|
|
60
|
+
}),
|
|
61
|
+
{ status: 200 }
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
const client = new QueryClient({
|
|
66
|
+
defaultOptions: {
|
|
67
|
+
queries: {
|
|
68
|
+
retry: false,
|
|
69
|
+
staleTime: 300_000,
|
|
70
|
+
refetchOnWindowFocus: false,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
render(
|
|
75
|
+
<QueryClientProvider client={client}>
|
|
76
|
+
<WorkspaceVisibilityProvider actorId="account-A">
|
|
77
|
+
<NextIntlClientProvider
|
|
78
|
+
locale={locale}
|
|
79
|
+
messages={locale === 'en' ? en : viMessages}
|
|
80
|
+
timeZone="UTC"
|
|
81
|
+
now={new Date('2026-09-01T12:00:00Z')}
|
|
82
|
+
>
|
|
83
|
+
<FinanceRouteProvider prefix="">
|
|
84
|
+
<HistoryBoundary>
|
|
85
|
+
<InvoiceUserHistoryAccordion wsId="ws-1" userId="customer-1" />
|
|
86
|
+
</HistoryBoundary>
|
|
87
|
+
</FinanceRouteProvider>
|
|
88
|
+
</NextIntlClientProvider>
|
|
89
|
+
</WorkspaceVisibilityProvider>
|
|
90
|
+
</QueryClientProvider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
caught = [];
|
|
95
|
+
transport.mockReset();
|
|
96
|
+
vi.stubGlobal('fetch', transport);
|
|
97
|
+
vi.stubGlobal(
|
|
98
|
+
'IntersectionObserver',
|
|
99
|
+
class {
|
|
100
|
+
observe() {}
|
|
101
|
+
unobserve() {}
|
|
102
|
+
disconnect() {}
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
vi.stubGlobal(
|
|
106
|
+
'ResizeObserver',
|
|
107
|
+
class {
|
|
108
|
+
observe() {}
|
|
109
|
+
unobserve() {}
|
|
110
|
+
disconnect() {}
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
// React reports caught render errors; capture only their fixed class above.
|
|
114
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
115
|
+
});
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
cleanup();
|
|
118
|
+
vi.unstubAllGlobals();
|
|
119
|
+
vi.restoreAllMocks();
|
|
120
|
+
});
|
|
121
|
+
async function openHistory() {
|
|
122
|
+
await waitFor(() =>
|
|
123
|
+
expect(
|
|
124
|
+
screen.queryByRole('button') ||
|
|
125
|
+
screen.queryByText('Synthetic history boundary')
|
|
126
|
+
).toBeTruthy()
|
|
127
|
+
);
|
|
128
|
+
expect(caught).toEqual([]);
|
|
129
|
+
fireEvent.click(screen.getByRole('button'));
|
|
130
|
+
await waitFor(() =>
|
|
131
|
+
expect(screen.getByText('synthetic-history')).toBeVisible()
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
describe('actual invoice history timestamp rendering boundary', () => {
|
|
135
|
+
for (const locale of ['en', 'vi'] as const) {
|
|
136
|
+
const messages = locale === 'en' ? en : viMessages;
|
|
137
|
+
for (const date of ['infinity', '-infinity', 'synthetic-invalid-date']) {
|
|
138
|
+
it(`${locale}: retains the invoice when its nonempty timestamp cannot be displayed: ${date}`, async () => {
|
|
139
|
+
mount(locale, date);
|
|
140
|
+
await openHistory();
|
|
141
|
+
expect(screen.getByText(messages['ws-invoices'].no_date)).toBeVisible();
|
|
142
|
+
expect(screen.getByRole('link')).toHaveAttribute(
|
|
143
|
+
'href',
|
|
144
|
+
'/ws-1/invoices/synthetic-invoice'
|
|
145
|
+
);
|
|
146
|
+
expect(transport).toHaveBeenCalledOnce();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
for (const date of [null, undefined, '']) {
|
|
150
|
+
it(`${locale}: preserves missing-date history for ${String(date)}`, async () => {
|
|
151
|
+
mount(locale, date);
|
|
152
|
+
await openHistory();
|
|
153
|
+
expect(screen.getByText(messages['ws-invoices'].no_date)).toBeVisible();
|
|
154
|
+
expect(transport).toHaveBeenCalledOnce();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
it(`${locale}: preserves valid timestamp formatting and the invoice link`, async () => {
|
|
158
|
+
mount(locale, '2026-09-01T12:00:00.000Z');
|
|
159
|
+
await openHistory();
|
|
160
|
+
expect(
|
|
161
|
+
screen.getByText(locale === 'en' ? '9/1/2026' : '1/9/2026')
|
|
162
|
+
).toBeVisible();
|
|
163
|
+
expect(
|
|
164
|
+
screen.queryByText(messages['ws-invoices'].no_date)
|
|
165
|
+
).not.toBeInTheDocument();
|
|
166
|
+
expect(screen.getByRole('link')).toHaveAttribute(
|
|
167
|
+
'href',
|
|
168
|
+
'/ws-1/invoices/synthetic-invoice'
|
|
169
|
+
);
|
|
170
|
+
expect(transport).toHaveBeenCalledOnce();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
});
|