@startsimpli/ui 0.4.136 → 0.4.138
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/package.json +4 -4
- package/src/components/history/StaleSaveDialog.tsx +277 -0
- package/src/components/history/__tests__/conditional-write.test.ts +252 -0
- package/src/components/history/__tests__/stale-save-dialog.test.tsx +224 -0
- package/src/components/history/__tests__/use-conditional-save.test.tsx +447 -0
- package/src/components/history/conditional-write.ts +340 -0
- package/src/components/history/index.ts +64 -1
- package/src/components/history/stale-save-dialog-default-class-names.ts +45 -0
- package/src/components/history/useConditionalSave.ts +372 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reviewer sees when a stale save is refused (bd startsim-jkkn7.3).
|
|
3
|
+
*/
|
|
4
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
import { StaleSaveDialog } from '../StaleSaveDialog';
|
|
7
|
+
import { normalizeRevision } from '../revision-history';
|
|
8
|
+
import type { StaleSaveConflict } from '../useConditionalSave';
|
|
9
|
+
|
|
10
|
+
const base: StaleSaveConflict = {
|
|
11
|
+
currentVersion: 3,
|
|
12
|
+
sentVersion: 2,
|
|
13
|
+
detail: 'the record changed since it was loaded: version 2 was sent and the record is now at version 3.',
|
|
14
|
+
refusedBody: { data: { blog: 'three paragraphs' } },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const changedBy = (over: Record<string, unknown> = {}) =>
|
|
18
|
+
normalizeRevision({
|
|
19
|
+
id: 'r3',
|
|
20
|
+
version: 3,
|
|
21
|
+
created_at: '2026-10-06T12:00:00Z',
|
|
22
|
+
actor_label: 'jurga@ogmc.example',
|
|
23
|
+
actor_kind: 'person',
|
|
24
|
+
actor_kind_source: 'claim',
|
|
25
|
+
metadata: { changed: { blog: { before: 'a', after: 'b' }, judge_verdict: { after: 'pass' } } },
|
|
26
|
+
...over,
|
|
27
|
+
} as never);
|
|
28
|
+
|
|
29
|
+
describe('it says the text is safe before it says anything else', () => {
|
|
30
|
+
it('leads with the reassurance', () => {
|
|
31
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
32
|
+
expect(screen.getByTestId('stale-save-reassurance')).toHaveTextContent(
|
|
33
|
+
'Your edits are still here and have not been saved.',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders nothing without a conflict, and nothing when closed', () => {
|
|
38
|
+
const { container, rerender } = render(
|
|
39
|
+
<StaleSaveDialog conflict={null} onDismiss={jest.fn()} />,
|
|
40
|
+
);
|
|
41
|
+
expect(container).toBeEmptyDOMElement();
|
|
42
|
+
rerender(<StaleSaveDialog conflict={base} open={false} onDismiss={jest.fn()} />);
|
|
43
|
+
expect(container).toBeEmptyDOMElement();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('who changed it', () => {
|
|
48
|
+
it('names the person in the headline', () => {
|
|
49
|
+
render(<StaleSaveDialog conflict={{ ...base, changedBy: changedBy() }} onDismiss={jest.fn()} />);
|
|
50
|
+
expect(screen.getByTestId('stale-save-title')).toHaveTextContent(
|
|
51
|
+
/jurga@ogmc\.example\s*changed this record while you were editing\./,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks a machine, because a machine overwriting a human is the case this epic exists for', () => {
|
|
56
|
+
render(
|
|
57
|
+
<StaleSaveDialog
|
|
58
|
+
conflict={{
|
|
59
|
+
...base,
|
|
60
|
+
changedBy: changedBy({ actor_label: 'svc:n8n-ogmc', actor_kind: 'machine' }),
|
|
61
|
+
}}
|
|
62
|
+
onDismiss={jest.fn()}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByTestId('actor-kind')).toHaveTextContent('machine');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('NEVER renders an unrecorded kind as a machine', () => {
|
|
69
|
+
// `unknown` means central did not say. The claim is minted at login, so for
|
|
70
|
+
// up to the 14-day refresh expiry a genuine human edit carries no kind.
|
|
71
|
+
render(
|
|
72
|
+
<StaleSaveDialog
|
|
73
|
+
conflict={{ ...base, changedBy: changedBy({ actor_kind: undefined, actor_kind_source: undefined }) }}
|
|
74
|
+
onDismiss={jest.fn()}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
expect(screen.getByTestId('actor-identity')).toHaveAttribute('data-actor-kind', 'unknown');
|
|
78
|
+
expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument();
|
|
79
|
+
expect(screen.getByTestId('stale-save-dialog')).not.toHaveTextContent(/machine/i);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('says the record changed without guessing at a person when the trail said nobody', () => {
|
|
83
|
+
render(
|
|
84
|
+
<StaleSaveDialog
|
|
85
|
+
conflict={{ ...base, identityGap: 'forbidden' }}
|
|
86
|
+
onDismiss={jest.fn()}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId('stale-save-title')).toHaveTextContent(
|
|
90
|
+
'This record changed while you were editing.',
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByTestId('stale-save-identity-gap')).toHaveTextContent(
|
|
93
|
+
/history is not available to you/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('shows the fields verbatim, never recased', () => {
|
|
98
|
+
render(<StaleSaveDialog conflict={{ ...base, changedBy: changedBy() }} onDismiss={jest.fn()} />);
|
|
99
|
+
const fields = screen.getByTestId('stale-save-fields');
|
|
100
|
+
expect(fields).toHaveTextContent('blog');
|
|
101
|
+
expect(fields).toHaveTextContent('judge_verdict');
|
|
102
|
+
expect(fields).not.toHaveTextContent('judgeVerdict');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("shows the server's own sentence", () => {
|
|
106
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
107
|
+
expect(screen.getByTestId('stale-save-detail')).toHaveTextContent('now at version 3');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('the accidental paths dismiss and resolve nothing', () => {
|
|
112
|
+
it('Escape dismisses', () => {
|
|
113
|
+
const onDismiss = jest.fn();
|
|
114
|
+
const onKeepMine = jest.fn();
|
|
115
|
+
const onAcceptTheirs = jest.fn();
|
|
116
|
+
render(
|
|
117
|
+
<StaleSaveDialog
|
|
118
|
+
conflict={base}
|
|
119
|
+
onDismiss={onDismiss}
|
|
120
|
+
onKeepMine={onKeepMine}
|
|
121
|
+
onAcceptTheirs={onAcceptTheirs}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
126
|
+
|
|
127
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(onKeepMine).not.toHaveBeenCalled();
|
|
129
|
+
expect(onAcceptTheirs).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('the backdrop dismisses, and a click inside does not', () => {
|
|
133
|
+
const onDismiss = jest.fn();
|
|
134
|
+
render(<StaleSaveDialog conflict={base} onDismiss={onDismiss} />);
|
|
135
|
+
|
|
136
|
+
fireEvent.click(screen.getByTestId('stale-save-dialog'));
|
|
137
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
138
|
+
|
|
139
|
+
fireEvent.click(screen.getByTestId('stale-save-backdrop'));
|
|
140
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('focuses a control that can neither write nor discard', () => {
|
|
144
|
+
const { unmount } = render(
|
|
145
|
+
<StaleSaveDialog
|
|
146
|
+
conflict={base}
|
|
147
|
+
onDismiss={jest.fn()}
|
|
148
|
+
onKeepMine={jest.fn()}
|
|
149
|
+
onAcceptTheirs={jest.fn()}
|
|
150
|
+
onReviewChange={jest.fn()}
|
|
151
|
+
/>,
|
|
152
|
+
);
|
|
153
|
+
expect(screen.getByTestId('stale-save-review')).toHaveFocus();
|
|
154
|
+
unmount();
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<StaleSaveDialog conflict={base} onDismiss={jest.fn()} onKeepMine={jest.fn()} />,
|
|
158
|
+
);
|
|
159
|
+
expect(screen.getByTestId('stale-save-dismiss')).toHaveFocus();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('the actions', () => {
|
|
164
|
+
it('labels the overwrite with what it does to their change', () => {
|
|
165
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} onKeepMine={jest.fn()} />);
|
|
166
|
+
expect(screen.getByTestId('stale-save-keep-mine')).toHaveTextContent(
|
|
167
|
+
'Save mine, replacing their change',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('offers each action only when the host handles it', () => {
|
|
172
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
173
|
+
expect(screen.queryByTestId('stale-save-keep-mine')).not.toBeInTheDocument();
|
|
174
|
+
expect(screen.queryByTestId('stale-save-accept-theirs')).not.toBeInTheDocument();
|
|
175
|
+
expect(screen.queryByTestId('stale-save-review')).not.toBeInTheDocument();
|
|
176
|
+
// Dismissal is always available: there is always a way back to the text.
|
|
177
|
+
expect(screen.getByTestId('stale-save-dismiss')).toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('fires the handlers', () => {
|
|
181
|
+
const onKeepMine = jest.fn();
|
|
182
|
+
const onAcceptTheirs = jest.fn();
|
|
183
|
+
const onReviewChange = jest.fn();
|
|
184
|
+
render(
|
|
185
|
+
<StaleSaveDialog
|
|
186
|
+
conflict={base}
|
|
187
|
+
onDismiss={jest.fn()}
|
|
188
|
+
onKeepMine={onKeepMine}
|
|
189
|
+
onAcceptTheirs={onAcceptTheirs}
|
|
190
|
+
onReviewChange={onReviewChange}
|
|
191
|
+
/>,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
fireEvent.click(screen.getByTestId('stale-save-keep-mine'));
|
|
195
|
+
fireEvent.click(screen.getByTestId('stale-save-accept-theirs'));
|
|
196
|
+
fireEvent.click(screen.getByTestId('stale-save-review'));
|
|
197
|
+
|
|
198
|
+
expect(onKeepMine).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(onAcceptTheirs).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(onReviewChange).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('disables the writing actions while a resolution is in flight, but never the way back', () => {
|
|
204
|
+
render(
|
|
205
|
+
<StaleSaveDialog
|
|
206
|
+
conflict={base}
|
|
207
|
+
busy
|
|
208
|
+
onDismiss={jest.fn()}
|
|
209
|
+
onKeepMine={jest.fn()}
|
|
210
|
+
onAcceptTheirs={jest.fn()}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
expect(screen.getByTestId('stale-save-keep-mine')).toBeDisabled();
|
|
214
|
+
expect(screen.getByTestId('stale-save-accept-theirs')).toBeDisabled();
|
|
215
|
+
expect(screen.getByTestId('stale-save-dismiss')).toBeEnabled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('is a labelled modal', () => {
|
|
219
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
220
|
+
const dialog = screen.getByRole('dialog');
|
|
221
|
+
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
|
222
|
+
expect(dialog).toHaveAttribute('aria-labelledby', 'stale-save-title');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conflict state machine (bd startsim-jkkn7.3).
|
|
3
|
+
*
|
|
4
|
+
* The first describe block is the bead. Everything else is support.
|
|
5
|
+
*/
|
|
6
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
useConditionalSave,
|
|
10
|
+
type ConditionalWriteAttempt,
|
|
11
|
+
type ConditionalWriteOutcome,
|
|
12
|
+
} from '../useConditionalSave';
|
|
13
|
+
import type { RevisionClient } from '../types';
|
|
14
|
+
|
|
15
|
+
const REFUSAL = {
|
|
16
|
+
status: 412,
|
|
17
|
+
body: {
|
|
18
|
+
detail: 'the record changed since it was loaded: version 2 was sent and the record is now at version 3.',
|
|
19
|
+
current_version: 3,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function writer(outcomes: ConditionalWriteOutcome[]) {
|
|
24
|
+
const sent: ConditionalWriteAttempt[] = [];
|
|
25
|
+
const write = jest.fn(async (attempt: ConditionalWriteAttempt) => {
|
|
26
|
+
sent.push(attempt);
|
|
27
|
+
return outcomes[Math.min(sent.length - 1, outcomes.length - 1)]!;
|
|
28
|
+
});
|
|
29
|
+
return { client: { write }, sent, write };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trail = (rows: unknown[]): RevisionClient => ({
|
|
33
|
+
list: jest.fn(async () => ({ results: rows as never[] })),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const JURGA = {
|
|
37
|
+
id: 'r3',
|
|
38
|
+
version: 3,
|
|
39
|
+
action: 'updated',
|
|
40
|
+
created_at: '2026-10-06T12:00:00Z',
|
|
41
|
+
actor_label: 'jurga@ogmc.example',
|
|
42
|
+
actor_kind: 'person',
|
|
43
|
+
actor_kind_source: 'claim',
|
|
44
|
+
metadata: { changed: { blog: { before: 'old', after: 'new' }, judge_verdict: { after: 'pass' } } },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('a refusal does not advance the held version', () => {
|
|
48
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
49
|
+
THIS IS THE BEAD. The tempting shortcut is to read `current_version` off
|
|
50
|
+
the refusal, adopt it, and re-send: the error goes away and every
|
|
51
|
+
happy-path test still passes. It also reduces the guard to a decoration,
|
|
52
|
+
because a blind retry at the current version IS the overwrite the 412 just
|
|
53
|
+
prevented, with one extra round-trip.
|
|
54
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
55
|
+
it('keeps the loaded version after a 412 and never retries', async () => {
|
|
56
|
+
const { client, sent } = writer([REFUSAL]);
|
|
57
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
await result.current.save({ data: { blog: 'three paragraphs' } });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.current.version).toBe(2);
|
|
64
|
+
expect(result.current.conflict?.currentVersion).toBe(3);
|
|
65
|
+
// One attempt. No retry at version 3.
|
|
66
|
+
expect(sent).toHaveLength(1);
|
|
67
|
+
expect(sent[0]!.headers).toEqual({ 'If-Match': '"2"' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps the reviewer text on the conflict so it survives a reload', async () => {
|
|
71
|
+
// The accidental path is not a rash click — it is the host refetching the
|
|
72
|
+
// record to show "what changed" and its load-into-editor effect overwriting
|
|
73
|
+
// the text. The bytes are still here when that happens.
|
|
74
|
+
const { client } = writer([REFUSAL]);
|
|
75
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
76
|
+
const typed = { data: { blog: 'three paragraphs the reviewer typed' } };
|
|
77
|
+
|
|
78
|
+
await act(async () => {
|
|
79
|
+
await result.current.save(typed);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.current.conflict?.refusedBody).toEqual(typed);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('pauses autosave and sends nothing more while the conflict is open', async () => {
|
|
86
|
+
// The editor debounces at 1,200 ms. A 412 that neither paused nor
|
|
87
|
+
// deduplicated would repaint a modal on every keystroke burst.
|
|
88
|
+
const { client, sent } = writer([REFUSAL]);
|
|
89
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
93
|
+
});
|
|
94
|
+
expect(result.current.paused).toBe(true);
|
|
95
|
+
|
|
96
|
+
for (const keystroke of ['ab', 'abc', 'abcd']) {
|
|
97
|
+
await act(async () => {
|
|
98
|
+
const outcome = await result.current.save({ data: { blog: keystroke } });
|
|
99
|
+
expect(outcome.status).toBe('paused');
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
expect(sent).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('dismissing closes the dialog but does NOT resolve or resume', async () => {
|
|
106
|
+
// Escape, the backdrop and the close button are not a fourth option.
|
|
107
|
+
const { client, sent } = writer([REFUSAL]);
|
|
108
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
109
|
+
|
|
110
|
+
await act(async () => {
|
|
111
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
112
|
+
});
|
|
113
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
114
|
+
|
|
115
|
+
act(() => result.current.dismissConflict());
|
|
116
|
+
|
|
117
|
+
expect(result.current.dialogOpen).toBe(false);
|
|
118
|
+
expect(result.current.paused).toBe(true);
|
|
119
|
+
expect(result.current.conflict).not.toBeNull();
|
|
120
|
+
expect(result.current.version).toBe(2);
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
expect((await result.current.save({ data: { blog: 'b' } })).status).toBe('paused');
|
|
124
|
+
});
|
|
125
|
+
expect(sent).toHaveLength(1);
|
|
126
|
+
|
|
127
|
+
act(() => result.current.reopenConflict());
|
|
128
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('the version is sent on every write', () => {
|
|
133
|
+
it('advances the held version from the write response body', async () => {
|
|
134
|
+
// This is what stops a 1,200 ms autosave needing a GET between saves.
|
|
135
|
+
const { client, sent } = writer([
|
|
136
|
+
{ status: 200, body: { id: 'e1', version: 3 } },
|
|
137
|
+
{ status: 200, body: { id: 'e1', version: 4 } },
|
|
138
|
+
]);
|
|
139
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
143
|
+
});
|
|
144
|
+
expect(result.current.version).toBe(3);
|
|
145
|
+
|
|
146
|
+
await act(async () => {
|
|
147
|
+
await result.current.save({ data: { blog: 'b' } });
|
|
148
|
+
});
|
|
149
|
+
expect(result.current.version).toBe(4);
|
|
150
|
+
|
|
151
|
+
expect(sent.map((s) => s.headers['If-Match'])).toEqual(['"2"', '"3"']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('falls back to the ETag when the response body carries no version', async () => {
|
|
155
|
+
const { client, sent } = writer([
|
|
156
|
+
{ status: 200, body: {}, headers: new Headers({ ETag: '"8"' }) },
|
|
157
|
+
{ status: 200, body: {} },
|
|
158
|
+
]);
|
|
159
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
160
|
+
|
|
161
|
+
await act(async () => {
|
|
162
|
+
await result.current.save({ data: {} });
|
|
163
|
+
});
|
|
164
|
+
expect(result.current.version).toBe(8);
|
|
165
|
+
|
|
166
|
+
await act(async () => {
|
|
167
|
+
await result.current.save({ data: {} });
|
|
168
|
+
});
|
|
169
|
+
expect(sent[1]!.headers).toEqual({ 'If-Match': '"8"' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('saves unguarded rather than not at all when no version is known', async () => {
|
|
173
|
+
// Every caller in production today sends none; failing closed here would be
|
|
174
|
+
// a write outage on seven live tenants.
|
|
175
|
+
const { client, sent } = writer([{ status: 200, body: { version: 1 } }]);
|
|
176
|
+
const { result } = renderHook(() => useConditionalSave({ client }));
|
|
177
|
+
|
|
178
|
+
expect(result.current.guarded).toBe(false);
|
|
179
|
+
await act(async () => {
|
|
180
|
+
expect((await result.current.save({ data: { blog: 'a' } })).status).toBe('saved');
|
|
181
|
+
});
|
|
182
|
+
expect(sent[0]!.headers).toEqual({});
|
|
183
|
+
expect(sent[0]!.body).toEqual({ data: { blog: 'a' } });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('uses the body spelling when headers cannot be set', async () => {
|
|
187
|
+
const { client, sent } = writer([{ status: 200, body: { version: 3 } }]);
|
|
188
|
+
const { result } = renderHook(() =>
|
|
189
|
+
useConditionalSave({ client, version: 2, spelling: 'body' }),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await act(async () => {
|
|
193
|
+
await result.current.save({ data: { blog: 'a' } });
|
|
194
|
+
});
|
|
195
|
+
expect(sent[0]!.body).toEqual({ data: { blog: 'a' }, expected_version: 2 });
|
|
196
|
+
expect(sent[0]!.headers).toEqual({});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('understands a client that THROWS the 412 instead of resolving it', async () => {
|
|
200
|
+
const write = jest.fn(async () => {
|
|
201
|
+
throw { response: { status: 412, data: { current_version: 9 } } };
|
|
202
|
+
});
|
|
203
|
+
const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
expect((await result.current.save({ data: {} })).status).toBe('refused');
|
|
207
|
+
});
|
|
208
|
+
expect(result.current.conflict?.currentVersion).toBe(9);
|
|
209
|
+
expect(result.current.version).toBe(2);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('reports a non-412 failure rather than throwing into an autosave', async () => {
|
|
213
|
+
const boom = new Error('gateway');
|
|
214
|
+
const write = jest.fn(async () => {
|
|
215
|
+
throw boom;
|
|
216
|
+
});
|
|
217
|
+
const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
expect(await result.current.save({ data: {} })).toEqual({ status: 'failed', error: boom });
|
|
221
|
+
});
|
|
222
|
+
// A transport failure is not a conflict: nothing is paused and nothing pops.
|
|
223
|
+
expect(result.current.conflict).toBeNull();
|
|
224
|
+
expect(result.current.paused).toBe(false);
|
|
225
|
+
expect(result.current.lastError).toBe(boom);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('who changed it comes from the trail, not from the 412', () => {
|
|
230
|
+
it('names the actor from the newest revision row', async () => {
|
|
231
|
+
const { client } = writer([REFUSAL]);
|
|
232
|
+
const { result } = renderHook(() =>
|
|
233
|
+
useConditionalSave({ client, version: 2, revisions: trail([JURGA]) }),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await act(async () => {
|
|
237
|
+
await result.current.save({ data: {} });
|
|
238
|
+
});
|
|
239
|
+
await waitFor(() => expect(result.current.conflict?.changedBy).toBeDefined());
|
|
240
|
+
|
|
241
|
+
expect(result.current.conflict?.changedBy?.actorLabel).toBe('jurga@ogmc.example');
|
|
242
|
+
expect(result.current.conflict?.changedBy?.actorKind).toBe('person');
|
|
243
|
+
// Field names are the tenant's declared attribute names and are never recased.
|
|
244
|
+
expect(Object.keys(result.current.conflict!.changedBy!.changed).sort()).toEqual([
|
|
245
|
+
'blog',
|
|
246
|
+
'judge_verdict',
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('reads an unrecorded kind as unknown and never as a machine', async () => {
|
|
251
|
+
const { client } = writer([REFUSAL]);
|
|
252
|
+
const { result } = renderHook(() =>
|
|
253
|
+
useConditionalSave({
|
|
254
|
+
client,
|
|
255
|
+
version: 2,
|
|
256
|
+
revisions: trail([{ ...JURGA, actor_kind: undefined, actor_kind_source: undefined }]),
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await result.current.save({ data: {} });
|
|
262
|
+
});
|
|
263
|
+
await waitFor(() => expect(result.current.conflict?.changedBy).toBeDefined());
|
|
264
|
+
expect(result.current.conflict?.changedBy?.actorKind).toBe('unknown');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('opens the dialog anyway when the trail 404s, which means forbidden', async () => {
|
|
268
|
+
// The route is nested: a 404 says the PARENT record is unreadable, so
|
|
269
|
+
// history is withheld rather than absent.
|
|
270
|
+
const { client } = writer([REFUSAL]);
|
|
271
|
+
const revisions: RevisionClient = {
|
|
272
|
+
list: jest.fn(async () => {
|
|
273
|
+
throw { status: 404 };
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2, revisions }));
|
|
277
|
+
|
|
278
|
+
await act(async () => {
|
|
279
|
+
await result.current.save({ data: {} });
|
|
280
|
+
});
|
|
281
|
+
await waitFor(() => expect(result.current.conflict?.identityGap).toBe('forbidden'));
|
|
282
|
+
// Useful on current_version alone.
|
|
283
|
+
expect(result.current.conflict?.currentVersion).toBe(3);
|
|
284
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('opens the dialog with no trail client at all', async () => {
|
|
288
|
+
const { client } = writer([REFUSAL]);
|
|
289
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
290
|
+
|
|
291
|
+
await act(async () => {
|
|
292
|
+
await result.current.save({ data: {} });
|
|
293
|
+
});
|
|
294
|
+
await waitFor(() => expect(result.current.conflict?.identityGap).toBe('no-client'));
|
|
295
|
+
expect(result.current.dialogOpen).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('reports an empty trail as empty rather than as nobody', async () => {
|
|
299
|
+
const { client } = writer([REFUSAL]);
|
|
300
|
+
const { result } = renderHook(() =>
|
|
301
|
+
useConditionalSave({ client, version: 2, revisions: trail([]) }),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
await act(async () => {
|
|
305
|
+
await result.current.save({ data: {} });
|
|
306
|
+
});
|
|
307
|
+
await waitFor(() => expect(result.current.conflict?.identityGap).toBe('empty'));
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('resolving is a deliberate act', () => {
|
|
312
|
+
it('keepMine re-sends the refused text at the version the server named', async () => {
|
|
313
|
+
const { client, sent } = writer([REFUSAL, { status: 200, body: { version: 4 } }]);
|
|
314
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
315
|
+
const typed = { data: { blog: 'three paragraphs' } };
|
|
316
|
+
|
|
317
|
+
await act(async () => {
|
|
318
|
+
await result.current.save(typed);
|
|
319
|
+
});
|
|
320
|
+
await act(async () => {
|
|
321
|
+
expect((await result.current.keepMine()).status).toBe('saved');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(sent).toHaveLength(2);
|
|
325
|
+
// Adopting current_version is legitimate HERE and nowhere else.
|
|
326
|
+
expect(sent[1]!.headers).toEqual({ 'If-Match': '"3"' });
|
|
327
|
+
expect(sent[1]!.body).toEqual(typed);
|
|
328
|
+
expect(result.current.version).toBe(4);
|
|
329
|
+
expect(result.current.conflict).toBeNull();
|
|
330
|
+
expect(result.current.paused).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('raises a FRESH conflict when keepMine is itself refused', async () => {
|
|
334
|
+
// A third writer moved the record again while the dialog was open.
|
|
335
|
+
const { client } = writer([
|
|
336
|
+
REFUSAL,
|
|
337
|
+
{ status: 412, body: { detail: 'again', current_version: 5 } },
|
|
338
|
+
]);
|
|
339
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
340
|
+
|
|
341
|
+
await act(async () => {
|
|
342
|
+
await result.current.save({ data: { blog: 'mine' } });
|
|
343
|
+
});
|
|
344
|
+
await act(async () => {
|
|
345
|
+
expect((await result.current.keepMine()).status).toBe('refused');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(result.current.conflict?.currentVersion).toBe(5);
|
|
349
|
+
expect(result.current.conflict?.refusedBody).toEqual({ data: { blog: 'mine' } });
|
|
350
|
+
expect(result.current.paused).toBe(true);
|
|
351
|
+
expect(result.current.version).toBe(2);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('acceptTheirs clears the conflict and adopts their version for the reload', async () => {
|
|
355
|
+
const { client } = writer([REFUSAL]);
|
|
356
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
357
|
+
|
|
358
|
+
await act(async () => {
|
|
359
|
+
await result.current.save({ data: {} });
|
|
360
|
+
});
|
|
361
|
+
act(() => result.current.acceptTheirs());
|
|
362
|
+
|
|
363
|
+
expect(result.current.conflict).toBeNull();
|
|
364
|
+
expect(result.current.paused).toBe(false);
|
|
365
|
+
expect(result.current.version).toBe(3);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('re-seeds when the host loads a different record', async () => {
|
|
369
|
+
const { client } = writer([{ status: 200, body: { version: 2 } }]);
|
|
370
|
+
const { result, rerender } = renderHook(
|
|
371
|
+
({ version }: { version?: number }) => useConditionalSave({ client, version }),
|
|
372
|
+
{ initialProps: { version: undefined as number | undefined } },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(result.current.version).toBeUndefined();
|
|
376
|
+
rerender({ version: 7 });
|
|
377
|
+
expect(result.current.version).toBe(7);
|
|
378
|
+
|
|
379
|
+
// A save's own advance is not stomped by a re-render carrying the old prop.
|
|
380
|
+
await act(async () => {
|
|
381
|
+
await result.current.save({ data: {} });
|
|
382
|
+
});
|
|
383
|
+
expect(result.current.version).toBe(2);
|
|
384
|
+
rerender({ version: 7 });
|
|
385
|
+
expect(result.current.version).toBe(2);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('a resolution cannot be raced', () => {
|
|
390
|
+
// The dialog disables these while busy, but the HOOK is exported on its own
|
|
391
|
+
// and a consumer wires its own controls (bd startsim-j19hf does exactly that).
|
|
392
|
+
it('ignores acceptTheirs mid-keepMine so the two cannot interleave', async () => {
|
|
393
|
+
let release: (v: ConditionalWriteOutcome) => void = () => {};
|
|
394
|
+
const second = new Promise<ConditionalWriteOutcome>((r) => {
|
|
395
|
+
release = r;
|
|
396
|
+
});
|
|
397
|
+
let call = 0;
|
|
398
|
+
const write = jest.fn(async () => {
|
|
399
|
+
call += 1;
|
|
400
|
+
return call === 1 ? REFUSAL : second;
|
|
401
|
+
});
|
|
402
|
+
const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
|
|
403
|
+
|
|
404
|
+
await act(async () => {
|
|
405
|
+
await result.current.save({ data: { blog: 'mine' } });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
let inflight!: Promise<unknown>;
|
|
409
|
+
act(() => {
|
|
410
|
+
inflight = result.current.keepMine();
|
|
411
|
+
});
|
|
412
|
+
expect(result.current.resolving).toBe(true);
|
|
413
|
+
|
|
414
|
+
// The user hits "discard mine" while the overwrite is still in the air.
|
|
415
|
+
act(() => result.current.acceptTheirs());
|
|
416
|
+
// IGNORED, so the two never interleave: the conflict is still the one
|
|
417
|
+
// keepMine is resolving, not a half-cleared state.
|
|
418
|
+
expect(result.current.conflict?.currentVersion).toBe(3);
|
|
419
|
+
expect(result.current.version).toBe(2);
|
|
420
|
+
|
|
421
|
+
await act(async () => {
|
|
422
|
+
release({ status: 412, body: { detail: 'again', current_version: 9 } });
|
|
423
|
+
await inflight;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// One writer decided the outcome — keepMine's — rather than two racing.
|
|
427
|
+
expect(result.current.conflict?.currentVersion).toBe(9);
|
|
428
|
+
expect(result.current.conflict?.refusedBody).toEqual({ data: { blog: 'mine' } });
|
|
429
|
+
expect(result.current.paused).toBe(true);
|
|
430
|
+
expect(result.current.version).toBe(2);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('does not fire two overwrites on a double click', async () => {
|
|
434
|
+
const { client, sent } = writer([REFUSAL, { status: 200, body: { version: 4 } }]);
|
|
435
|
+
const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
|
|
436
|
+
|
|
437
|
+
await act(async () => {
|
|
438
|
+
await result.current.save({ data: { blog: 'mine' } });
|
|
439
|
+
});
|
|
440
|
+
await act(async () => {
|
|
441
|
+
await Promise.all([result.current.keepMine(), result.current.keepMine()]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// One refused save + exactly one overwrite.
|
|
445
|
+
expect(sent).toHaveLength(2);
|
|
446
|
+
});
|
|
447
|
+
});
|