Merge remote-tracking branch 'origin/main' into feat/235-async-contract
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts: # docs/decisions.md
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
getBlock,
|
||||
getBlockGroups,
|
||||
getBlockItems,
|
||||
getBlockItemsWithMeta,
|
||||
getBlocks,
|
||||
previewBlock,
|
||||
replaceBlock,
|
||||
@@ -119,6 +120,44 @@ describe('blocks api client', () => {
|
||||
expect(body.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getBlockItemsWithMeta returns the items and the ETag', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify([{ id: 1 }]), {
|
||||
headers: { ETag: '"5"', 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const result = await getBlockItemsWithMeta(4);
|
||||
|
||||
expect(result.etag).toBe('"5"');
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaceBlock sends If-Match when an ETag is supplied and returns the new ETag', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 4, items: [] }), {
|
||||
headers: { ETag: '"6"', 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const result = await replaceBlock(4, sampleReplace, '"5"');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init?.headers).toMatchObject({ 'If-Match': '"5"' });
|
||||
expect(result.etag).toBe('"6"');
|
||||
});
|
||||
|
||||
it('replaceBlock omits If-Match when no ETag is supplied', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, items: [] }));
|
||||
|
||||
await replaceBlock(4, sampleReplace);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init?.headers).not.toHaveProperty('If-Match');
|
||||
});
|
||||
|
||||
it('previewBlock POSTs to the preview route', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
await previewBlock(4, sampleReplace);
|
||||
|
||||
+20
-3
@@ -1,4 +1,4 @@
|
||||
import { ApiError, request } from './client';
|
||||
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type BlockGroup = components['schemas']['BlockGroupResponseModel'];
|
||||
@@ -49,8 +49,25 @@ export function getBlockItems(id: number): Promise<BlockItem[]> {
|
||||
return request<BlockItem[]>(`/api/blocks/${id}/items`);
|
||||
}
|
||||
|
||||
export function replaceBlock(id: number, body: ReplaceBlockRequest): Promise<BlockWithItems> {
|
||||
return request<BlockWithItems>(`/api/blocks/${id}`, { body, method: 'PUT' });
|
||||
/** Load block items together with the block's concurrency ETag (issue #253). */
|
||||
export function getBlockItemsWithMeta(id: number): Promise<ResponseWithMeta<BlockItem[]>> {
|
||||
return requestWithMeta<BlockItem[]>(`/api/blocks/${id}/items`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a block. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with 412;
|
||||
* the resolved value carries the new ETag for a subsequent save (issue #253).
|
||||
*/
|
||||
export function replaceBlock(
|
||||
id: number,
|
||||
body: ReplaceBlockRequest,
|
||||
ifMatch?: string | null
|
||||
): Promise<ResponseWithMeta<BlockWithItems>> {
|
||||
return requestWithMeta<BlockWithItems>(`/api/blocks/${id}`, {
|
||||
body,
|
||||
method: 'PUT',
|
||||
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function previewBlock(id: number, body: ReplaceBlockRequest): Promise<BlockPreviewItem[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setStoredApiKey } from './auth';
|
||||
import { ApiError, request } from './client';
|
||||
import { ApiError, request, requestWithMeta } from './client';
|
||||
|
||||
describe('API request client', () => {
|
||||
beforeEach(() => {
|
||||
@@ -94,4 +94,40 @@ describe('API request client', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requestWithMeta returns the response ETag alongside the parsed body', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify([{ id: 1 }]), {
|
||||
headers: { ETag: '"7"', 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const result = await requestWithMeta<{ id: number }[]>('/api/blocks/1/items');
|
||||
|
||||
expect(result.etag).toBe('"7"');
|
||||
expect(result.data).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('requestWithMeta returns a null ETag when the response has none', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const result = await requestWithMeta('/api/blocks/1/items');
|
||||
|
||||
expect(result.etag).toBeNull();
|
||||
});
|
||||
|
||||
it('requestWithMeta surfaces the ETag on a 204 (no body) response', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { headers: { ETag: '"3"' }, status: 204 }));
|
||||
|
||||
const result = await requestWithMeta('/api/blocks/1', { method: 'PUT' });
|
||||
|
||||
expect(result.data).toBeUndefined();
|
||||
expect(result.etag).toBe('"3"');
|
||||
});
|
||||
});
|
||||
|
||||
+25
-4
@@ -24,10 +24,21 @@ export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
|
||||
const mutatingMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
|
||||
|
||||
export async function request<TResponse = unknown>(
|
||||
export interface ResponseWithMeta<T> {
|
||||
data: T;
|
||||
/** Strong ETag of the resource's version, when the endpoint emits one (issue #253). */
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link request} but also returns the response's ETag. Used by the optimistic-concurrency
|
||||
* editors: read the ETag from the load GET, send it back as `If-Match` on the replace PUT, and
|
||||
* replace it from the PUT response's ETag on every successful save (issue #253 / spa-conventions).
|
||||
*/
|
||||
export async function requestWithMeta<TResponse = unknown>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<TResponse> {
|
||||
): Promise<ResponseWithMeta<TResponse>> {
|
||||
const method = (options.method ?? 'GET').toUpperCase();
|
||||
const headers = normalizeHeaders({
|
||||
Accept: 'application/json',
|
||||
@@ -52,11 +63,21 @@ export async function request<TResponse = unknown>(
|
||||
throw new ApiError(response.status, await readProblemDetails(response));
|
||||
}
|
||||
|
||||
const etag = response.headers.get('ETag');
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponse;
|
||||
return { data: undefined as TResponse, etag };
|
||||
}
|
||||
|
||||
return await readJsonResponse(response) as TResponse;
|
||||
return { data: (await readJsonResponse(response)) as TResponse, etag };
|
||||
}
|
||||
|
||||
export async function request<TResponse = unknown>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<TResponse> {
|
||||
const { data } = await requestWithMeta<TResponse>(path, options);
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
|
||||
Reference in New Issue
Block a user