mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(forms): send one Idempotency-Key per opened create form
A mood, symptom, lab value, custom metric value, unscheduled dose, allergy, vaccination, visit or measurement saved on a weak signal could be stored twice: the POST committed, the answer missed the 15 s timeout, the form said it could not save, and the second tap was a new call with a new key, so the server ran it again. useIdempotentPost keeps one key until a save lands and renews it for the next entry, so the retry replays the first answer. The create forms and the three create hooks post through it; the comment on the per-call key in api-fetch now says what that key does and does not cover. LabForm and MoodForm each gain the one line that mounts the key, so their function-size baselines go up by one. The intake toast guard now recognises a dose posted through the hook.
This commit is contained in:
1 parent
121b23f4a2
commit
f761500247
15 files changed
+172
-35
No files matched your search
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* A second tap on Save after the first answer was lost must not store the
|
||||
* entry twice. The per-call key the transport mints changes with every
|
||||
* call, so a create form sends one key until a save lands.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useIdempotentPost } from "../use-idempotent-post";
|
||||
|
||||
type Post = ReturnType<typeof useIdempotentPost>;
|
||||
|
||||
function openForm(): Post {
|
||||
let post: Post | null = null;
|
||||
function Probe() {
|
||||
post = useIdempotentPost();
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(<Probe />);
|
||||
return post!;
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function keyOf(call: number): string | null {
|
||||
const init = fetchMock.mock.calls[call]![1] as RequestInit;
|
||||
return new Headers(init.headers).get("Idempotency-Key");
|
||||
}
|
||||
|
||||
const stored = () =>
|
||||
new Response(JSON.stringify({ data: { id: "e1" }, error: null }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
beforeEach(() => vi.stubGlobal("fetch", fetchMock));
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useIdempotentPost", () => {
|
||||
it("sends the first key again when the first answer never arrived", async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new DOMException("timed out", "TimeoutError"))
|
||||
.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow();
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
|
||||
expect(keyOf(0)).toBeTruthy();
|
||||
expect(keyOf(1)).toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("takes a new key for the next entry once a save landed", async () => {
|
||||
fetchMock.mockResolvedValueOnce(stored()).mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
|
||||
expect(keyOf(1)).not.toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("keeps a header the caller set", async () => {
|
||||
fetchMock.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await post("/api/x", {}, { headers: { Prefer: "respond-async" } });
|
||||
|
||||
const init = fetchMock.mock.calls[0]![1] as RequestInit;
|
||||
expect(new Headers(init.headers).get("Prefer")).toBe("respond-async");
|
||||
});
|
||||
});
|
||||
@@ -262,12 +262,11 @@ export function apiGet<T = unknown>(
|
||||
* Attach a per-call `Idempotency-Key` unless the caller already set one.
|
||||
*
|
||||
* Every `apiHandler` route accepts the header, and the write routes
|
||||
* wrapped in `withIdempotency` dedupe on it — but almost no client call
|
||||
* site sent one, so a transport-level replay of a create (a retried
|
||||
* request, a re-sent form submission) could land a duplicate dose or
|
||||
* vaccination and shift every derived series. Minting the key here
|
||||
* protects every `apiPost` / `apiPatch` surface at once; a caller with a
|
||||
* real natural key still wins by setting the header itself.
|
||||
* wrapped in `withIdempotency` dedupe on it. A key minted per call covers a
|
||||
* transport-level replay of that one request. A second tap on Save is a
|
||||
* new call and gets a new key, so a create form sends one key for as long
|
||||
* as it is open (`useIdempotentPost`); a caller that sets the header
|
||||
* itself always wins.
|
||||
*/
|
||||
function withDefaultIdempotencyKey(init: RequestInit): RequestInit {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
|
||||
/**
|
||||
* `apiPost` for a create form, under one `Idempotency-Key` until a save lands.
|
||||
*
|
||||
* The transport mints a fresh key per call, which covers a replayed request
|
||||
* but not a second tap on Save: when the first answer never arrived (the 15 s
|
||||
* timeout on a weak signal) the entry is already stored, and the retry is a
|
||||
* new call with a new key, so the server stored it twice. Every submit
|
||||
* through this function sends the same key until one succeeds, so the retry
|
||||
* replays the first answer instead; a retry with a changed body is a
|
||||
* different request and runs as one. The next entry gets a new key.
|
||||
*/
|
||||
export function useIdempotentPost() {
|
||||
const key = useRef<string | null>(null);
|
||||
return async function post<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
key.current ??= randomId();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Idempotency-Key", key.current);
|
||||
const result = await apiPost<T>(path, body, { ...init, headers });
|
||||
key.current = null;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user