Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds the shared optimistic-concurrency contract so a stale second tab can no longer silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate; PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472). Contract - `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule, Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection, RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration `AddAggregateVersions` (nullable:false, default 0). - Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch → 412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag. - `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`; `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent = Phase-1 force-write. Block reference wiring - Handler: standalone `Either` via `CheckVersion` AFTER validation (never through `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`, `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412). - `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the ETag on GET items and on the successful PUT. - SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock` If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412 opens a blocking "changed elsewhere — reload" dialog. Tests - Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success + bump, no-op save still bumps, and a two-context racing save → 412; proven non-vacuous (drop `.IsConcurrencyToken()` → the race test fails). - Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog. Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log. Refs #253 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { setStoredApiKey } from './auth';
|
|
import { ApiError, request, requestWithMeta } from './client';
|
|
|
|
describe('API request client', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('omits the API key header for read requests', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify([{ id: 1, number: '1', name: 'News' }]), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
status: 200
|
|
})
|
|
);
|
|
setStoredApiKey('write-secret');
|
|
|
|
await request('/api/channels');
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/channels',
|
|
expect.objectContaining({
|
|
headers: expect.not.objectContaining({ 'X-Api-Key': 'write-secret' })
|
|
})
|
|
);
|
|
});
|
|
|
|
it('adds the API key header for mutating requests', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
|
|
setStoredApiKey('write-secret');
|
|
|
|
await request('/api/channels/1', { method: 'DELETE' });
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/channels/1',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ 'X-Api-Key': 'write-secret' }),
|
|
method: 'DELETE'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws an ApiError with problem details for non-OK responses', async () => {
|
|
vi.spyOn(window, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ status: 422, title: 'Validation failed', detail: 'Number exists' }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
status: 422
|
|
})
|
|
);
|
|
|
|
await expect(request('/api/channels', { method: 'POST', body: { name: 'News' } })).rejects.toMatchObject({
|
|
detail: 'Number exists',
|
|
message: 'Validation failed',
|
|
status: 422
|
|
} satisfies Partial<ApiError>);
|
|
});
|
|
|
|
it('returns undefined for successful responses without a JSON body', async () => {
|
|
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
|
|
|
|
await expect(request('/api/channels', { method: 'POST' })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('reads problem details from application/problem+json responses', async () => {
|
|
vi.spyOn(window, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ status: 401, title: 'Unauthorized', detail: 'A valid API key is required for write requests.' }), {
|
|
headers: { 'Content-Type': 'application/problem+json' },
|
|
status: 401
|
|
})
|
|
);
|
|
|
|
await expect(request('/api/channels', { method: 'POST' })).rejects.toMatchObject({
|
|
detail: 'A valid API key is required for write requests.',
|
|
message: 'Unauthorized',
|
|
status: 401
|
|
} satisfies Partial<ApiError>);
|
|
});
|
|
|
|
it('does not add a duplicate JSON content type when callers provide lowercase content-type', async () => {
|
|
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
|
|
|
|
await request('/api/channels', {
|
|
body: { name: 'News' },
|
|
headers: { 'content-type': 'application/merge-patch+json' },
|
|
method: 'PATCH'
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/channels',
|
|
expect.objectContaining({
|
|
headers: expect.not.objectContaining({ 'Content-Type': 'application/json' })
|
|
})
|
|
);
|
|
});
|
|
|
|
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"');
|
|
});
|
|
});
|