Skip to content

Commit 23ee3ae

Browse files
fix(app-shell): the record page reads the lock service's 501 silently and asks no more (#26841)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xAVdK2axL7ynomwkD2iDbXe)_ Fixes #26632 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录页读审批锁时不再走 SDK 客户端,改用 hook 自己的 fetch;501 静默并记住,本次页面加载内不再读锁;其它失败仍按原格式记日志。 - 为什么改:没有审批服务的部署(新脚手架)每打开一条记录就在浏览器控制台打一条红色 `HTTP request failed ... -> 501`,而 501 正是设计中的「没有锁服务」。 - 风险与回滚:只动 app-shell 一个 hook;锁读请求改走与审批列表读相同的传输,不再带 SDK 客户端的头。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/hooks/useRecordApprovals.ts` 的 `readRecordLock` 与 `startRead` 里的读锁分支;`RecordDetailView.approvalLock-25934.test.tsx` 新增两条用例。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/app-shell/src/views/RecordDetailView.approvalLock-25934.test.tsx`,预期 6/6 通过。 - 需要你做的:无,席位落地。 ## What changes `useRecordApprovals` asks `GET /approvals/records/OBJECT/ID/lock` when the approvals list finds no pending request (#25934). It used to read it through `client.approvals.getRecordLock`. The SDK client logs every non-2xx with `logger.error('HTTP request failed', …)` before its caller sees the answer, and the console's client logger (`createQuietHttpLogger` in `@object-ui/data-objectstack`) prints that with `console.error`. So on a composition without the approvals service, whose designed answer to that read is `501 NOT_IMPLEMENTED`, every record open printed a red line, although the hook already treated the 501 as "not locked". Measured producer: `packages/client/src/index.ts` `fetch()` (the unconditional `logger.error` on `!res.ok`) → `ui/packages/data-objectstack/src/index.ts` `createQuietHttpLogger().error` → `console.error`. The PM hypothesis held. The fix lands in app-shell, the expected file surface, so `@objectstack/client` is not changed. - The lock is now read through the hook's own `fetchJson`, the same transport the approvals list read beside it already uses. That transport logs nothing. The `{ success, data }` envelope is unwrapped as the SDK did. - A 501 is read silently and latched in a module-scoped flag (`recordLockAbsent`): no record asks again until the page reloads. `__resetRecordApprovalsStore` clears it. - Any other failure still logs, through the same `createQuietHttpLogger` the SDK client would have used: `HTTP request failed: GET URL -> STATUS [CODE]`, a 404 still demoted to debug. The next open asks again. - The read still happens only where an adapter is mounted, as before. A first draft dropped that gate, and `RecordDetailView.recordOpenWaves-13010` caught the lock read appearing as a second wave on its adapter-less page. The gate is kept. ## Why The triage ruling on the card (6441358448): the record page treats a 501 from the lock read as "no lock service", silent, no retry; any other failure still logs. ## Tests - `RecordDetailView.approvalLock-25934.test.tsx` now stubs the lock route on the global fetch rather than the adapter's SDK method, and pins that the SDK method is never called. - New: the 501 logs no `console.error` line naming the lock path, and a second open of the record reads the list again but not the lock. - New: a 500 logs one identified line (`GET … -> 500`), and the next open asks again. - Ablation: with the 501 branch mutated to never match (`scripts/ablation-replace.mjs`, restore verified by blob hash), the new 501 test went red on the `HTTP request failed: GET /api/v1/approvals/records/account/acc_northwind/lock -> 501 [NOT_IMPLEMENTED]` line, 1 failed / 5 passed. ## Acceptance notes - The browser's own network line for the first 501 still appears once per page load. JS cannot suppress it, and the console cannot know the service is absent without asking, because the approvals list door answers `200 { data: [] }` when the service is missing while the lock door answers 501. Removing that last line needs the two server doors to agree, which is `domain:server` work (`packages/rest/src/approvals-routes.ts`) and outside this card. Noted, not filed. - The lock read no longer carries the SDK client's `X-Environment-Id` and `Accept-Language` headers. The approvals list read beside it never did, and the console's adapter sets no environment id. ## Verification All on head a0185cf, in a shared container (seconds are shared-box seconds). - `cd ui && pnpm exec vitest run packages/app-shell/src/hooks/useRecordApprovals packages/app-shell/src/hooks/__tests__/useRecordApprovals packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx packages/app-shell/src/views/RecordDetailView.approvalLock packages/app-shell/src/views/RecordApprovalsPanel`: Test Files 15 passed (15), Tests 107 passed (107). - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived commands, each run locally with exit 0, `pnpm check:type-check-debt` included (re-run under the verify lock after its first run could not get the lock). `node scripts/check-sdui-manifest.mjs` exit 0. - Narrowed, declared: the full app-shell package suite was not run to completion locally. It was stopped after 40 minutes on the shared box, after it had already surfaced the waves-13010 regression fixed above. CI's `Test UI` runs it. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. Lint is left to CI.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01J8VVAojjEQUUoMeSBSdgW4)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 67cf0b1 commit 23ee3ae

3 files changed

Lines changed: 167 additions & 51 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(app-shell): a record page on a deployment without the approvals service no longer logs the record-lock read's 501
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
On a deployment that composes no approvals service, such as a fresh `npm create objectstack` scaffold, every record page asks the server whether an approval locks the record, and the server answers `501 NOT_IMPLEMENTED`. That answer is designed: it means there is no lock service, so no approval can lock the record, and the page already treated it that way. But the read went through the SDK client, which logs every non-2xx response as an error before its caller sees it. So each record open printed `HTTP request failed: GET /api/v1/approvals/records/OBJECT/ID/lock -> 501 [NOT_IMPLEMENTED]` in red, which reads like a broken server.
12+
13+
What changes:
14+
15+
- The record page reads the lock directly, the same way it already reads the approval requests, so no client logger runs first. A 501 is read silently.
16+
- After a 501, the page does not ask again for any record until the page is reloaded. The browser's own network line for that first request still appears once, because the server answers the approval-requests read `200` with an empty list and the page cannot tell the service is missing until it asks.
17+
- Any other failure of the lock read still logs, through the same logger the SDK client used: `HTTP request failed: GET URL -> STATUS [CODE]`, with a 404 still demoted to a debug line. The next record open asks again.
18+
19+
What to do: nothing. Nothing a flow, page or object declares changes.

‎ui/packages/app-shell/src/hooks/useRecordApprovals.ts‎

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@
3333
* override actor), so for anyone else an answered empty read cannot tell "no
3434
* approval is running" from "one is running that you may not see". When the
3535
* list finds no pending request, the hook also asks the server whether the
36-
* record is held by a locking approval (`client.approvals.getRecordLock`,
36+
* record is held by a locking approval (`GET /approvals/records/:object/:recordId/lock`,
3737
* answered to every caller who can read the record — objectstack#25934 ruling
38-
* A) and hands the answer back as `recordLock`.
38+
* A) and hands the answer back as `recordLock`. That door has no empty answer:
39+
* a composition without the approvals service answers it 501, the designed
40+
* "no lock service", which is read silently and never asked again
41+
* (objectstack#26632); any other failure still logs.
3942
*/
4043

4144
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
45+
import { createQuietHttpLogger } from '@object-ui/data-objectstack';
4246
import { useAdapter } from '../providers/AdapterProvider.js';
4347
import { bearerAuthHeaders } from '../utils/authToken.js';
4448
import type { DecisionOutputDef } from '../utils/decisionOutputParams.js';
@@ -304,8 +308,8 @@ interface UseRecordApprovalsResult {
304308
* no answer to give: a pending request was found (it decides, with its own
305309
* `lock_record`), no adapter is mounted, or the read has never answered — a
306310
* composition without the approvals service answers it 501, so there the
307-
* record is not locked by this signal. A later failed read keeps the last
308-
* answer, as the list read does.
311+
* record is not locked by this signal, and the read is not sent again. A
312+
* later failed read keeps the last answer, as the list read does.
309313
*/
310314
recordLock: { locked: boolean } | null;
311315
refresh: () => Promise<void>;
@@ -317,6 +321,7 @@ export function __resetRecordApprovalsStore(): void {
317321
inFlight.clear();
318322
listeners.clear();
319323
latestSeq.clear();
324+
recordLockAbsent = false;
320325
}
321326

322327
function apiBase() {
@@ -500,16 +505,50 @@ function holdEntry(key: string): () => void {
500505
};
501506
}
502507

503-
/** Reads the server's record lock for the record a read is for. */
504-
type RecordLockReader = () => Promise<{ locked: boolean }>;
508+
/**
509+
* Set once the record-lock read answered 501: this deployment has no approvals
510+
* service, so no record is held by a locking approval and the read is not sent
511+
* again (objectstack#26632). Module-scoped, not per record: the answer is the
512+
* deployment's, and the read goes to the one base every read here goes to.
513+
*/
514+
let recordLockAbsent = false;
515+
516+
/** Logs a failed lock read exactly as the console's SDK client logs one. */
517+
const lockReadLogger = createQuietHttpLogger();
518+
519+
/**
520+
* The server's record lock (`IApprovalService.getRecordLock`, objectstack#25934).
521+
*
522+
* Read through `fetchJson`, not `client.approvals.getRecordLock`: the client
523+
* logs every non-2xx as an error before its caller sees the answer, and the
524+
* 501 of a composition without the approvals service is this read's designed
525+
* answer, not a failure (objectstack#26632). So the 501 is read silently and
526+
* latched, and every other failure is logged here, through the same logger
527+
* the client would have used.
528+
*/
529+
async function readRecordLock(objectName: string, recordId: string): Promise<{ locked: boolean }> {
530+
const path = `/approvals/records/${encodeURIComponent(objectName)}/${encodeURIComponent(recordId)}/lock`;
531+
try {
532+
const body = await fetchJson<any>(path);
533+
// `{ success: true, data: { locked } }`, the declared envelope.
534+
return body && typeof body.success === 'boolean' && 'data' in body ? body.data : body;
535+
} catch (err: any) {
536+
if (err?.status === 501) {
537+
recordLockAbsent = true;
538+
} else {
539+
lockReadLogger.error('HTTP request failed', undefined, {
540+
method: 'GET',
541+
url: `${apiBase()}${path}`,
542+
status: err?.status,
543+
error: { code: err?.code, message: err?.message },
544+
});
545+
}
546+
throw err;
547+
}
548+
}
505549

506550
/** Send the record's read and publish its answer to every consumer of the key. */
507-
function startRead(
508-
key: string,
509-
objectName: string,
510-
recordId: string,
511-
readLock: RecordLockReader | null,
512-
): Promise<void> {
551+
function startRead(key: string, objectName: string, recordId: string, readsLock: boolean): Promise<void> {
513552
const seq = ++readSeq;
514553
latestSeq.set(key, seq);
515554
const entry = entries.get(key);
@@ -529,10 +568,10 @@ function startRead(
529568
// `lock_record`. Without one, ask the record itself (objectstack#25934):
530569
// the list hides a request from a non-participant, the lock does not.
531570
let recordLock: { locked: boolean } | null = null;
532-
if (!pending && readLock) {
571+
if (!pending && readsLock && !recordLockAbsent) {
533572
recordLock = entries.get(key)?.snapshot.recordLock ?? null;
534573
try {
535-
const lock = await readLock();
574+
const lock = await readRecordLock(objectName, recordId);
536575
recordLock = { locked: lock?.locked === true };
537576
} catch {
538577
// A read that never answered (501 without the approvals service)
@@ -599,14 +638,9 @@ export function useRecordApprovals(
599638
// for the render, mirrored into a ref the mount effect and `refresh` read.
600639
const [unavailable, setUnavailable] = useState(false);
601640
const unavailableRef = useRef(false);
602-
// The record-lock read goes through the SDK (`client.approvals.getRecordLock`),
603-
// so it needs the adapter's client; with no adapter mounted it is skipped.
604-
const adapter = useAdapter();
605-
const lockReader = useCallback(
606-
(object: string, id: string): RecordLockReader | null =>
607-
adapter ? () => adapter.getClient().approvals.getRecordLock(object, id) : null,
608-
[adapter],
609-
);
641+
// The record lock is read only where an adapter is mounted — the console's
642+
// connection to an ObjectStack server; a host with none never asked it.
643+
const readsLock = !!useAdapter();
610644

611645
const subscribe = useCallback((l: () => void) => subscribeKey(readKey, l), [readKey]);
612646
const getSnapshot = useCallback(
@@ -629,19 +663,19 @@ export function useRecordApprovals(
629663
// read only when neither exists. The entry may be new while a read is in
630664
// flight (StrictMode's replayed mount): that read publishes into it.
631665
if (entry && !entry.read && !inFlight.has(readKey) && !unavailableRef.current) {
632-
void startRead(readKey, objectName, recordId, lockReader(objectName, recordId));
666+
void startRead(readKey, objectName, recordId, readsLock);
633667
} else if (entry && inFlight.has(readKey)) {
634668
entry.read = true;
635669
if (!entry.snapshot.loading) publish(readKey, { loading: true });
636670
}
637671
return release;
638-
}, [readKey, objectName, recordId, lockReader]);
672+
}, [readKey, objectName, recordId, readsLock]);
639673

640674
const refresh = useCallback(async () => {
641675
if (!objectName || !recordId || !readKey) return;
642676
if (unavailableRef.current) return;
643-
await startRead(readKey, objectName, recordId, lockReader(objectName, recordId));
644-
}, [objectName, recordId, readKey, lockReader]);
677+
await startRead(readKey, objectName, recordId, readsLock);
678+
}, [objectName, recordId, readKey, readsLock]);
645679

646680
const requests = readKey === null ? EMPTY_ROWS : snapshot.rows;
647681
const loading = snapshot.loading;

‎ui/packages/app-shell/src/views/RecordDetailView.approvalLock-25934.test.tsx‎

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
* `200 { data: [] }` while a `lockRecord: true` approval runs. The page took
1717
* that for "no approval is running" and offered inline edit, and the save
1818
* answered `409 RECORD_LOCKED`. The page now asks the record's own lock
19-
* (`client.approvals.getRecordLock`, answered to every reader) when the list
20-
* finds no pending request.
19+
* (`GET /approvals/records/:object/:recordId/lock`, answered to every reader)
20+
* when the list finds no pending request.
21+
*
22+
* A composition without the approvals service answers that read 501, its
23+
* designed "no lock service": the page reads it silently and does not ask
24+
* again, while any other failure still logs (objectstack#26632).
2125
*
2226
* Same probe as `RecordDetailView.approvalLock-12220`: it prints what the page
2327
* threads into its `<InlineEditProvider>`. The record carries no
@@ -126,6 +130,7 @@ const pendingRequest = (over: Record<string, unknown> = {}) => ({
126130
});
127131

128132
let listGets: string[];
133+
let lockGets: string[];
129134

130135
const jsonResponse = (status: number, body: unknown) =>
131136
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
@@ -137,12 +142,31 @@ const jsonResponse = (status: number, body: unknown) =>
137142
*/
138143
type ListAnswer = { row: Record<string, unknown> | null } | { absent: number };
139144

140-
function stubApprovalsApi(answer: ListAnswer) {
145+
/**
146+
* What the record-lock route answers: `{ locked }` in the declared envelope,
147+
* or a refusal carrying the route's status (501 when the approvals service is
148+
* absent, the body `respond501` sends).
149+
*/
150+
type LockAnswer = { locked: boolean } | { refused: number };
151+
152+
const LOCK_PATH = `/approvals/records/${OBJECT_NAME}/${RECORD_ID}/lock`;
153+
154+
function stubApprovalsApi(answer: ListAnswer, lock: LockAnswer) {
141155
listGets = [];
156+
lockGets = [];
142157
vi.stubGlobal(
143158
'fetch',
144159
vi.fn(async (url: string) => {
145160
const u = String(url);
161+
if (u.endsWith(LOCK_PATH)) {
162+
lockGets.push(u);
163+
if ('refused' in lock) {
164+
return lock.refused === 501
165+
? jsonResponse(501, { code: 'NOT_IMPLEMENTED', message: 'Approvals service is not configured on this deployment' })
166+
: jsonResponse(lock.refused, { success: false, error: { code: 'INTERNAL_ERROR', message: 'boom' } });
167+
}
168+
return jsonResponse(200, { success: true, data: { locked: lock.locked } });
169+
}
146170
if (u.includes('/approvals/requests?object=')) {
147171
listGets.push(u);
148172
if ('absent' in answer) return jsonResponse(answer.absent, { code: 'NOT_FOUND' });
@@ -181,24 +205,20 @@ function makeDataSource() {
181205
}
182206

183207
/**
184-
* The record-lock read as the SDK answers it: `{ locked }`, or a refusal
185-
* carrying the route's status (501 when the approvals service is absent).
208+
* The SDK's own record-lock read. The page must not use it: the client logs
209+
* every non-2xx as an error before the page sees the answer (objectstack#26632).
186210
*/
187-
type LockAnswer = { locked: boolean } | { refused: number };
188211
let getRecordLock: ReturnType<typeof vi.fn>;
189212

190-
function makeAdapter(answer: LockAnswer) {
191-
getRecordLock = vi.fn(async () => {
192-
if ('refused' in answer) throw Object.assign(new Error(`HTTP ${answer.refused}`), { status: answer.refused });
193-
return { locked: answer.locked };
194-
});
213+
function makeAdapter() {
214+
getRecordLock = vi.fn(async () => ({ locked: false }));
195215
return { getClient: () => ({ approvals: { getRecordLock } }) } as any;
196216
}
197217

198-
function renderRecordPage(lock: LockAnswer) {
218+
function renderRecordPage() {
199219
return render(
200220
<MemoryRouter initialEntries={[`/app/crm/${OBJECT_NAME}/${RECORD_ID}`]}>
201-
<AdapterCtx.Provider value={makeAdapter(lock)}>
221+
<AdapterCtx.Provider value={makeAdapter()}>
202222
<MetadataCtx.Provider value={METADATA}>
203223
<RecordDetailView
204224
dataSource={makeDataSource()}
@@ -216,24 +236,32 @@ function renderRecordPage(lock: LockAnswer) {
216236

217237
const attr = (probe: HTMLElement, name: string) => probe.getAttribute(`data-${name}`);
218238

239+
let consoleError: ReturnType<typeof vi.spyOn>;
240+
219241
beforeEach(() => {
220242
cleanup();
221243
__resetRecordApprovalsStore();
222244
authFetchSpy.mockClear();
245+
consoleError = vi.spyOn(console, 'error');
223246
});
224247

225248
afterEach(() => {
226249
vi.unstubAllGlobals();
250+
consoleError.mockRestore();
227251
});
228252

253+
/** The `console.error` lines that name the record-lock read. */
254+
const lockErrorLines = () =>
255+
consoleError.mock.calls.filter((args: unknown[]) => args.some((a) => String(a).includes(LOCK_PATH)));
256+
229257
describe('record page lock for a reader outside the approval (objectstack#25934)', () => {
230258
it('a non-participant on a record held by a locking approval is offered no inline edit', async () => {
231-
stubApprovalsApi({ row: null });
232-
renderRecordPage({ locked: true });
259+
stubApprovalsApi({ row: null }, { locked: true });
260+
renderRecordPage();
233261
const probe = await screen.findByTestId('inline-probe');
234262

235263
await waitFor(() => expect(attr(probe, 'locked')).toBe('true'));
236-
expect(getRecordLock).toHaveBeenCalledWith(OBJECT_NAME, RECORD_ID);
264+
expect(lockGets).toHaveLength(1);
237265
expect(attr(probe, 'approval-read-answered')).toBe('true');
238266
expect(attr(probe, 'approval-pending')).toBe('true');
239267
expect(attr(probe, 'can-edit')).toBe('false');
@@ -242,37 +270,72 @@ describe('record page lock for a reader outside the approval (objectstack#25934)
242270
});
243271

244272
it('a record the server reports unlocked opens editable', async () => {
245-
stubApprovalsApi({ row: null });
246-
renderRecordPage({ locked: false });
273+
stubApprovalsApi({ row: null }, { locked: false });
274+
renderRecordPage();
247275
const probe = await screen.findByTestId('inline-probe');
248276

249-
await waitFor(() => expect(getRecordLock).toHaveBeenCalled());
277+
await waitFor(() => expect(lockGets).toHaveLength(1));
250278
await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true'));
251279
expect(attr(probe, 'locked')).toBe('false');
252280
expect(attr(probe, 'approval-pending')).toBe('false');
253281
await waitFor(() => expect(attr(probe, 'can-edit')).toBe('true'));
254282
});
255283

256284
it('without the approvals service (lock read 501) nothing changes: an answered empty read is not locked', async () => {
257-
stubApprovalsApi({ row: null });
258-
renderRecordPage({ refused: 501 });
285+
stubApprovalsApi({ row: null }, { refused: 501 });
286+
renderRecordPage();
259287
const probe = await screen.findByTestId('inline-probe');
260288

261-
await waitFor(() => expect(getRecordLock).toHaveBeenCalled());
289+
await waitFor(() => expect(lockGets).toHaveLength(1));
262290
await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true'));
263291
expect(attr(probe, 'locked')).toBe('false');
264292
expect(attr(probe, 'approval-pending')).toBe('false');
265293
await waitFor(() => expect(attr(probe, 'can-edit')).toBe('true'));
266294
});
267295

296+
it('the 501 of a composition without the approvals service logs nothing, and the lock is not read again (objectstack#26632)', async () => {
297+
stubApprovalsApi({ row: null }, { refused: 501 });
298+
renderRecordPage();
299+
const probe = await screen.findByTestId('inline-probe');
300+
await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true'));
301+
await waitFor(() => expect(lockGets).toHaveLength(1));
302+
expect(lockErrorLines()).toEqual([]);
303+
expect(getRecordLock).not.toHaveBeenCalled();
304+
305+
// Open the record again: the list is read again, the lock is not.
306+
cleanup();
307+
renderRecordPage();
308+
await waitFor(() => expect(listGets.length).toBeGreaterThanOrEqual(2));
309+
const again = await screen.findByTestId('inline-probe');
310+
await waitFor(() => expect(attr(again, 'approval-read-answered')).toBe('true'));
311+
expect(attr(again, 'locked')).toBe('false');
312+
expect(lockGets).toHaveLength(1);
313+
expect(lockErrorLines()).toEqual([]);
314+
});
315+
316+
it('any other lock-read failure still logs, and the next open asks again', async () => {
317+
stubApprovalsApi({ row: null }, { refused: 500 });
318+
renderRecordPage();
319+
const probe = await screen.findByTestId('inline-probe');
320+
await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true'));
321+
await waitFor(() => expect(lockErrorLines()).toHaveLength(1));
322+
expect(String(lockErrorLines()[0][0])).toContain(`GET `);
323+
expect(String(lockErrorLines()[0][0])).toContain('-> 500');
324+
expect(attr(probe, 'locked')).toBe('false');
325+
326+
cleanup();
327+
renderRecordPage();
328+
await waitFor(() => expect(lockGets).toHaveLength(2));
329+
});
330+
268331
it('a participant\'s pending request decides, and the record lock is not read', async () => {
269-
stubApprovalsApi({ row: pendingRequest({ lock_record: false }) });
270-
renderRecordPage({ locked: true });
332+
stubApprovalsApi({ row: pendingRequest({ lock_record: false }) }, { locked: true });
333+
renderRecordPage();
271334
const probe = await screen.findByTestId('inline-probe');
272335

273336
await waitFor(() => expect(attr(probe, 'approval-pending')).toBe('true'));
274337
expect(attr(probe, 'locked')).toBe('false');
275338
await waitFor(() => expect(attr(probe, 'can-edit')).toBe('true'));
276-
expect(getRecordLock).not.toHaveBeenCalled();
339+
expect(lockGets).toEqual([]);
277340
});
278341
});

0 commit comments

Comments
 (0)