STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_checkout.py
Go to the documentation of this file.
1"""Design checkouts: at most one holder, ever.
2
3Concurrent editing is not resolved here, it is prevented. The property that
4matters is the one a single-threaded test cannot show: two people pressing Take
5at the same instant must not both succeed. That is what
6`test_only_one_of_many_simultaneous_takes_wins` exists for -- it races real
7processes through the real `flock`, because a mocked lock proves nothing about a
8lock.
9
10Everything else follows from that: only the holder may save, a checkout lapses
11if nobody saves for `lock_ttl`, and none of it touches the operations that are
12not concurrent editing (rename, share, copy).
13"""
14
15from datetime import datetime, timezone
16
17import fcntl
18import time
19import json
20import os
21import sys
22import multiprocessing
23
24import pytest
25
26sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
28pytest.importorskip("fastapi", reason="API tests need fastapi")
29pytest.importorskip("httpx", reason="fastapi TestClient needs httpx")
30
31from fastapi.testclient import TestClient # noqa: E402
32
33from fastapi import FastAPI # noqa: E402
34
35from backend.routers import documents # noqa: E402
36
37# Just the documents router: importing backend.main drags in the native engine.
38app = FastAPI()
39app.include_router(documents.router)
40
41A = {"X-Auth-Email": "alice@berkeley.edu"}
42B = {"X-Auth-Email": "bob@berkeley.edu"}
43OWNER_A = {"owner": A["X-Auth-Email"]}
44BASE = "/api/engine/documents"
45#: The <app> path segment this tool stores under.
46APP_SEGMENT = "engine"
47
48
49@pytest.fixture(autouse=True)
50def _isolate(tmp_path, monkeypatch):
51 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
52 monkeypatch.setattr(documents.store, "micro_interval", 0)
53 documents.store.last_micro.clear()
54
55
56@pytest.fixture
57def client():
58 return TestClient(app)
59
60
61def _create(client, headers=A, name="Feed system"):
62 r = client.post(BASE, headers=headers, json={"name": name, "config": {}})
63 assert r.status_code == 200, r.text
64 return r.json()["id"]
65
66
67def _free(client, doc_id, headers=A, params=None):
68 """Release a freshly created design, since creating one now checks it out.
69
70 Several tests below need a design nobody holds; this is what makes that
71 explicit rather than an assumption about what `_create` leaves behind.
72 """
73 r = client.delete(f"{BASE}/{doc_id}/checkout", headers=headers,
74 params=params if params is not None else {})
75 assert r.status_code == 200, r.text
76
77
78def _share(client, doc_id, emails):
79 assert client.put(f"{BASE}/{doc_id}/share", headers=A,
80 json={"sharedWith": emails}).status_code == 200
81
82
83def _save(client, doc_id, headers, params=None, config=None):
84 return client.post(f"{BASE}/{doc_id}/autosave", headers=headers,
85 params=params if params is not None else {},
86 json={"config": config or {"v": 1}})
87
88
89# ── the guarantee ────────────────────────────────────────────────────────────
90
91
92def _race_take(barrier, results, root, doc_id, email, base, owner_param):
93 """One contender, in its own process.
94
95 Threads would not prove anything: `flock` is held per open file description,
96 so the contention that matters is between processes, exactly as it is
97 between the API's workers.
98
99 The barrier is the point. Without it the pool tends to run these one after
100 another -- each finishes before the next starts, nobody actually races, and
101 the test passes even with the locking removed. Every process parks here
102 until all of them are ready, so they go for the design together.
103 """
104 os.environ["USERDATA_DIR"] = root
105 try:
106 from fastapi import FastAPI
107 from fastapi.testclient import TestClient as TC
108
109 # `documents` is already imported at module scope; the fork inherits it,
110 # so there is nothing to re-import (and the module is named differently
111 # in each app).
112 fresh = FastAPI()
113 fresh.include_router(documents.router)
114 with TC(fresh) as c:
115 barrier.wait(timeout=30)
116 r = c.post(f"{base}/{doc_id}/checkout",
117 headers={"X-Auth-Email": email}, params=owner_param)
118 results.put(r.status_code)
119 except Exception as exc: # a crashed contender must not look like a loser
120 results.put(f"error: {exc!r}")
121
122
123@pytest.mark.parametrize("attempt", range(5))
125 """The whole feature in one assertion, repeated -- a race that passes once
126 has proved nothing."""
127 doc_id = _create(client)
128 contenders = [f"user{i}@berkeley.edu" for i in range(8)]
129 _share(client, doc_id, contenders)
130 _free(client, doc_id) # creating it checked it out to Alice
131
132 ctx = multiprocessing.get_context("fork") # inherit the imported app
133 barrier = ctx.Barrier(len(contenders))
134 results = ctx.Queue()
135 procs = [
136 ctx.Process(target=_race_take,
137 args=(barrier, results, str(tmp_path), doc_id, e, BASE, OWNER_A))
138 for e in contenders
139 ]
140 for p in procs:
141 p.start()
142 for p in procs:
143 p.join(timeout=60)
144
145 codes = [results.get(timeout=5) for _ in contenders]
146 assert all(isinstance(c, int) for c in codes), f"a contender crashed: {codes}"
147 assert codes.count(200) == 1, f"expected exactly one winner, got {codes}"
148 assert codes.count(423) == len(contenders) - 1, codes
149
150 # And the index agrees with whoever won -- one holder, not a torn record.
151 index = json.loads((tmp_path / "alice@berkeley.edu" / "engine" / "index.json").read_text())
152 record = next(r for r in index if r["id"] == doc_id)
153 assert record["lockedBy"] in contenders
154
155
156# ── only the holder may save ────────────────────────────────────────────────
157
158
160 doc_id = _create(client)
161 _share(client, doc_id, [B["X-Auth-Email"]])
162
163 assert client.post(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
164 assert _save(client, doc_id, A).status_code == 200
165 r = _save(client, doc_id, B, params=OWNER_A)
166 assert r.status_code == 423
167 assert "checked out by" in r.json()["detail"]
168
169
171 """Opening a design does not take it, so a save without the checkout must
172 fail rather than quietly claiming the design.
173
174 Creating one *does* check it out, so this releases first to get at the state
175 a second person opening the design would be in.
176 """
177 doc_id = _create(client)
178 _free(client, doc_id)
179 r = _save(client, doc_id, A)
180 assert r.status_code == 423
181 assert "Take" in r.json()["detail"]
182
183
184@pytest.mark.parametrize("path,method,body", [
185 ("/autosave", "POST", {"config": {}}),
186 ("/flush", "POST", {"config": {}}),
187 ("/release", "POST", {"label": "0.1"}),
188])
189def test_every_content_write_needs_the_checkout(client, path, method, body):
190 doc_id = _create(client)
191 _share(client, doc_id, [B["X-Auth-Email"]])
192 assert client.post(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
193 r = client.request(method, f"{BASE}/{doc_id}{path}", headers=B, params=OWNER_A, json=body)
194 assert r.status_code == 423, f"{path} was writable by a non-holder"
195
196
197@pytest.mark.parametrize("name,method,path,body", [
198 ("rename", "PATCH", "", {"name": "Renamed"}),
199 ("share", "PUT", "/share", {"sharedWith": []}),
200])
202 """A checkout guards the design's *content*. Renaming or re-sharing is not
203 concurrent editing, and blocking it would let a stale checkout freeze a
204 design nobody can tidy."""
205 doc_id = _create(client)
206 _share(client, doc_id, [B["X-Auth-Email"]])
207 assert client.post(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
208 r = client.request(method, f"{BASE}/{doc_id}{path}", headers=B, params=OWNER_A, json=body)
209 assert r.status_code == 200, f"{name} was blocked by a checkout"
210
211
213 doc_id = _create(client)
214 assert client.post(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
215 r = client.post(f"{BASE}/copy", headers=B, json={"owner": A["X-Auth-Email"], "id": doc_id})
216 assert r.status_code == 200
217
218
219# ── lapsing ─────────────────────────────────────────────────────────────────
220
221
223 doc_id = _create(client)
224 _share(client, doc_id, [B["X-Auth-Email"]])
225 assert client.post(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
226 assert client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 423
227
228 monkeypatch.setattr(documents.store, "lock_ttl", 0) # everything is stale now
229 assert client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
230
231
233 """The dangerous case: A's checkout lapses, B takes it, and A's tab is still
234 autosaving. A must be refused, not allowed to overwrite B."""
235 doc_id = _create(client)
236 _share(client, doc_id, [B["X-Auth-Email"]])
237 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
238 monkeypatch.setattr(documents.store, "lock_ttl", 0)
239 client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A)
240 monkeypatch.setattr(documents.store, "lock_ttl", 900)
241
242 assert _save(client, doc_id, A, config={"v": "stale"}).status_code == 423
243 assert _save(client, doc_id, B, params=OWNER_A, config={"v": "fresh"}).status_code == 200
244 assert client.get(f"{BASE}/{doc_id}/load", headers=A).json()["config"] == {"v": "fresh"}
245
246
248 """`lock_ttl` measures time since the last *save*, so someone actively
249 editing never loses the design out from under themselves."""
250 doc_id = _create(client)
251 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
252 first = client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()["lockExpiresAt"]
253 _save(client, doc_id, A, config={"v": "later"})
254 assert client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()["lockExpiresAt"] >= first
255
256
257# ── releasing, and reporting ────────────────────────────────────────────────
258
259
261 doc_id = _create(client)
262 _share(client, doc_id, [B["X-Auth-Email"]])
263 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
264
265 # Not the holder: a no-op, not a theft.
266 assert client.delete(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
267 assert client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()["lockedByMe"] is True
268
269 assert client.delete(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
270 assert client.delete(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
271 assert client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()["lockedBy"] is None
272
273
275 doc_id = _create(client)
276 _share(client, doc_id, [B["X-Auth-Email"]])
277 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
278
279 mine = next(d for d in client.get(BASE, headers=A).json() if d["id"] == doc_id)
280 assert mine["lockedByMe"] is True and mine["lockedBy"] == A["X-Auth-Email"]
281
282 theirs = next(d for d in client.get(BASE, headers=B).json() if d["id"] == doc_id)
283 assert theirs["lockedByMe"] is False and theirs["lockedBy"] == A["X-Auth-Email"]
284
285
287 """Every existing design has no lock fields at all."""
288 index = tmp_path / "alice@berkeley.edu" / "engine" / "index.json"
289 index.parent.mkdir(parents=True, exist_ok=True)
290 index.write_text(json.dumps([{"id": "old", "name": "Old",
291 "createdAt": "2020-01-01", "updatedAt": "2020-01-01"}]))
292 assert client.get(f"{BASE}/old/checkout", headers=A).json()["lockedBy"] is None
293 assert client.post(f"{BASE}/old/checkout", headers=A).status_code == 200
294
295def _take_in_child(results, root, doc_id, base, owner_param, email):
296 """A single take, in its own process, reporting its status code."""
297 os.environ["USERDATA_DIR"] = root
298 try:
299 from fastapi import FastAPI
300 from fastapi.testclient import TestClient as TC
301
302 fresh = FastAPI()
303 fresh.include_router(documents.router)
304 with TC(fresh) as c:
305 r = c.post(f"{base}/{doc_id}/checkout",
306 headers={"X-Auth-Email": email}, params=owner_param)
307 results.put(r.status_code)
308 except Exception as exc:
309 results.put(f"error: {exc!r}")
310
311
313 """Deterministic proof that the compare-and-set is genuinely serialised.
314
315 The race above is probabilistic: the critical section is sub-millisecond, so
316 contenders often fail to interleave and it can pass even with the locking
317 removed. This one cannot. It grabs the very same `flock` the take path uses,
318 then asserts a take *blocks* until it is released -- which is exactly the
319 property that makes two simultaneous holders impossible.
320 """
321 doc_id = _create(client)
322 lock_path = tmp_path / "alice@berkeley.edu" / APP_SEGMENT / ".index.lock"
323 lock_path.parent.mkdir(parents=True, exist_ok=True)
324
325 ctx = multiprocessing.get_context("fork")
326 results = ctx.Queue()
327 fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
328 proc = ctx.Process(target=_take_in_child,
329 args=(results, str(tmp_path), doc_id, BASE, OWNER_A,
330 "bob@berkeley.edu"))
331 # Share BEFORE taking the lock. `_share` goes through the API, which needs
332 # the same flock -- doing it inside would deadlock this process against
333 # itself, and flock gives no warning when it does.
334 _share(client, doc_id, ["bob@berkeley.edu"])
335 _free(client, doc_id) # creating it checked it out to Alice
336
337 try:
338 fcntl.flock(fd, fcntl.LOCK_EX)
339 proc.start()
340 proc.join(timeout=3)
341 assert proc.is_alive(), (
342 "a checkout completed while the index lock was held -- the "
343 "compare-and-set is not serialised, so two people can hold one design"
344 )
345 finally:
346 fcntl.flock(fd, fcntl.LOCK_UN)
347 os.close(fd)
348
349 proc.join(timeout=30)
350 assert not proc.is_alive(), "the take never completed after the lock was released"
351 assert results.get(timeout=5) == 200
352
353
355 """You made it a moment ago; nobody else can possibly hold it.
356
357 Without this you create a design, start editing, and every save is refused
358 until you notice a button you had no reason to look at -- which reads as
359 "the thing I just made keeps losing my work".
360 """
361 doc_id = _create(client)
362 state = client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()
363 assert state["lockedByMe"] is True
364 # ...and that is not cosmetic: an edit right after creating must persist.
365 assert _save(client, doc_id, A).status_code == 200
366
367
369 """Same reasoning: you take a copy in order to work on it."""
370 a_id = _create(client)
371 copy = client.post(f"{BASE}/copy", headers=B,
372 json={"owner": A["X-Auth-Email"], "id": a_id}).json()
373 state = client.get(f"{BASE}/{copy['id']}/checkout", headers=B).json()
374 assert state["lockedByMe"] is True
375 assert _save(client, copy["id"], B).status_code == 200
376
377
379 """A new design carries its own checkout; it must not disturb a design
380 someone else is holding."""
381 a_id = _create(client)
382 _share(client, a_id, [B["X-Auth-Email"]])
383 _create(client, B, "Bob's own")
384 assert client.get(f"{BASE}/{a_id}/checkout", headers=A).json()["lockedByMe"] is True
385
386
387# ── the shape every returned record must have ────────────────────────────────
388
389
390#: Fields the design bar reads off a record. A response missing `mine` is not a
391#: cosmetic problem: the bar reads its absence as "someone else's design" and
392#: captions your own design "<name> - undefined".
393DECORATED = {"owner", "ownerName", "mine", "sharedWith", "lockedByMe"}
394
395
397 doc_id = _create(client)
398
399 listed = client.get(BASE, headers=A).json()[0]
400 assert DECORATED <= set(listed), f"list: missing {DECORATED - set(listed)}"
401
402 created = client.post(BASE, headers=A, json={"name": "Shaped", "config": {}}).json()
403 assert DECORATED <= set(created), f"create: missing {DECORATED - set(created)}"
404
405 renamed = client.patch(f"{BASE}/{doc_id}", headers=A, json={"name": "Renamed"}).json()
406 assert DECORATED <= set(renamed), f"rename: missing {DECORATED - set(renamed)}"
407
408 shared = client.put(f"{BASE}/{doc_id}/share", headers=A,
409 json={"sharedWith": [B["X-Auth-Email"]]}).json()
410 assert DECORATED <= set(shared), f"share: missing {DECORATED - set(shared)}"
411
412 copied = client.post(f"{BASE}/copy", headers=B,
413 json={"owner": A["X-Auth-Email"], "id": doc_id}).json()
414 assert DECORATED <= set(copied), f"copy: missing {DECORATED - set(copied)}"
415
416
418 """The specific failure: `mine` absent reads as false, and the bar then shows
419 your own design as belonging to an undefined owner."""
420 doc_id = _create(client)
421 for label, resp in [
422 ("create", client.post(BASE, headers=A, json={"name": "Shaped", "config": {}})),
423 ("rename", client.patch(f"{BASE}/{doc_id}", headers=A, json={"name": "Still mine"})),
424 ]:
425 body = resp.json()
426 assert body["mine"] is True, f"{label} did not report the design as yours"
427 assert body["ownerName"], f"{label} returned an empty ownerName"
428
429
431 """`lockExpiresAt` is when the hold lapses, not when it was last beaten.
432
433 It used to carry the raw heartbeat -- a time already in the past -- so a bar
434 counting down to it read as expired the moment it rendered, which is why
435 nothing ever showed the user how long they had. Fails against that.
436 """
437 doc_id = _create(client)
438 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
439 state = client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()
440 expires = datetime.fromisoformat(state["lockExpiresAt"])
441 left = (expires - datetime.now(timezone.utc)).total_seconds()
442 assert 0 < left <= documents.store.lock_ttl
443 assert state["lockTtlSeconds"] == documents.store.lock_ttl
444
445
447 """The fix for "it kicked me out while I was working".
448
449 Before this endpoint the only thing that refreshed a hold was a successful
450 autosave, so panning, measuring or thinking counted as idle. The beat must
451 push the expiry out and must NOT touch the stored payload.
452 """
453 doc_id = _create(client)
454 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
455 _save(client, doc_id, A)
456 stored = client.get(f"{BASE}/{doc_id}/load", headers=A).json()
457 before = client.get(f"{BASE}/{doc_id}/checkout", headers=A).json()["lockExpiresAt"]
458
459 r = client.post(f"{BASE}/{doc_id}/checkout/beat", headers=A)
460 assert r.status_code == 200
461 assert r.json()["lockedByMe"] is True
462 assert r.json()["lockExpiresAt"] >= before
463 # Content untouched. Compared against whatever this app's payload shape is
464 # rather than a hardcoded one -- a beat that could clobber a working copy
465 # would be worse than the bug it fixes.
466 assert client.get(f"{BASE}/{doc_id}/load", headers=A).json() == stored
467
468
470 """423 is the client's cue to stop beating and show the lost dialog.
471
472 Shared with B on purpose: an unshared user is refused at 403 long before the
473 lock is consulted, so it would prove nothing about the beat.
474 """
475 doc_id = _create(client)
476 _share(client, doc_id, [B["X-Auth-Email"]])
477 client.post(f"{BASE}/{doc_id}/checkout", headers=A)
478 assert client.post(f"{BASE}/{doc_id}/checkout/beat", headers=B, params=OWNER_A).status_code == 423
479
480
482 """The end-to-end claim, both directions.
483
484 Someone interacting without saving must keep the design; someone who has
485 genuinely walked away must lose it, because release is holder-only and the
486 on-close beacon is best-effort, so lapsing is the only thing that recovers a
487 checkout after a crash or a power cut.
488 """
489 doc_id = _create(client)
490 _share(client, doc_id, [B["X-Auth-Email"]])
491 assert client.post(f"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
492
493 monkeypatch.setattr(documents.store, "lock_ttl", 2)
494 # Beat twice, each time before it would have lapsed. Total elapsed exceeds
495 # the TTL, and no save happens anywhere in here -- which is the entire point.
496 for _ in range(2):
497 time.sleep(1.2)
498 assert client.post(f"{BASE}/{doc_id}/checkout/beat", headers=A).status_code == 200
499 assert client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 423
500
501 # stop beating and it lapses on its own -- B gets it
502 time.sleep(2.2)
503 assert client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
504
505
507 """A lapse with nobody waiting is not a conflict, and must not cost the edit.
508
509 Every content write needs the checkout, so if nobody has taken the design
510 since it lapsed on you, nobody can have changed it: your next beat or save
511 takes the hold back instead of being refused (see
512 ``DesignStore.claim_for_write``). Refusing it was how edits were lost --
513 step away past ``lock_ttl``, come back, keep editing, and every save came
514 back 423 with nobody else anywhere near the design.
515 """
516 doc_id = _create(client)
517 _share(client, doc_id, [B["X-Auth-Email"]])
518 monkeypatch.setattr(documents.store, "lock_ttl", 0) # lapsed
519 assert client.post(f"{BASE}/{doc_id}/checkout/beat", headers=A).status_code == 200
520 assert _save(client, doc_id, A).status_code == 200
521 monkeypatch.setattr(documents.store, "lock_ttl", 900)
522 assert client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 423
523
524
526 """Lapsing still frees the design for whoever acts first. Once B has taken
527 it, A's beat and save are refused -- the two-holders case checkouts exist
528 to prevent."""
529 doc_id = _create(client)
530 _share(client, doc_id, [B["X-Auth-Email"]])
531 monkeypatch.setattr(documents.store, "lock_ttl", 0)
532 assert client.post(f"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
533 monkeypatch.setattr(documents.store, "lock_ttl", 900)
534 assert client.post(f"{BASE}/{doc_id}/checkout/beat", headers=A).status_code == 423
535 assert _save(client, doc_id, A).status_code == 423
a class to store JSON values
Definition json.hpp:19401
test_every_endpoint_that_returns_a_record_returns_a_decorated_one(client)
test_creating_a_design_checks_it_out_to_you(client)
_share(client, doc_id, emails)
test_only_one_of_many_simultaneous_takes_wins(client, tmp_path, attempt)
test_lock_expires_at_is_in_the_future(client)
test_a_checkout_lapses_and_the_next_person_can_take_it(client, monkeypatch)
_take_in_child(results, root, doc_id, base, owner_param, email)
test_beat_refuses_when_you_do_not_hold_it(client)
_isolate(tmp_path, monkeypatch)
test_beat_alone_survives_the_ttl_but_idling_does_not(client, monkeypatch)
test_take_cannot_proceed_while_the_index_lock_is_held(client, tmp_path)
test_saving_without_taking_it_is_refused(client)
_free(client, doc_id, headers=A, params=None)
test_saving_keeps_the_checkout_alive(client)
test_beat_refreshes_the_hold_without_writing(client)
test_creating_does_not_touch_anyone_elses_checkout(client)
_race_take(barrier, results, root, doc_id, email, base, owner_param)
test_the_list_reports_the_checkout_without_an_extra_call(client)
test_copying_is_not_blocked_by_a_checkout(client)
test_holder_may_save_and_others_may_not(client)
test_the_previous_holder_is_refused_rather_than_silently_overwriting(client, monkeypatch)
test_copying_a_design_checks_the_copy_out_to_you(client)
test_a_lapsed_hold_nobody_took_is_reclaimed_by_its_holder(client, monkeypatch)
test_every_content_write_needs_the_checkout(client, path, method, body)
test_release_is_idempotent_and_holder_only(client)
test_a_returned_record_says_the_design_is_yours(client)
_save(client, doc_id, headers, params=None, config=None)
test_a_lapsed_hold_someone_else_took_is_not_reclaimed(client, monkeypatch)
test_housekeeping_is_not_blocked_by_someone_elses_checkout(client, name, method, path, body)
_create(client, headers=A, name="Feed system")
test_designs_predating_checkouts_read_as_free(client, tmp_path)