Merge RB-25 — add the UPLOAD_TRANSPORT injection token

TE-003: UploadShellService documented UploadTransport as the swap seam, then
bound the concrete, unexported KeepaliveTransport class directly, so a spec
could not fake it. UPLOAD_TRANSPORT copies the SESSION_PORT shape; the
default factory returns the same instance, so runtime behaviour is
unchanged. upload-shell.service.ts goes from 0% to 88.57% line coverage
across 16 new specs for upload(), cancel(), delete() and pollReturning().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
This commit is contained in:
eho
2026-08-28 08:33:36 +02:00
5 changed files with 401 additions and 4 deletions
+26 -1
View File
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 474 frontend behaviours across
**is** the suite, reshaped for a business reader. 487 frontend behaviours across
9 contexts; 261 backend behaviours across 42 test
classes.
@@ -675,6 +675,31 @@ classes.
- map only touches Success
- map2 precedence: Failure &gt; Loading &gt; Success
#### UploadShellService.cancel
- calls the transport cancel function for an in-flight upload and forgets it
- is a no-op for a localId with nothing in flight
#### UploadShellService.delete
- dispatches UploadDeleting, then UploadDeleteComplete on success
- dispatches UploadDeleteFailed with the server detail on failure
- falls back to an empty reason when the server sends no detail
#### UploadShellService.pollReturning
- does nothing when there are no uploads to poll
- dispatches BackgroundUploadsReturned for uploads the server reports complete
- does not dispatch when nothing has arrived yet
#### UploadShellService.upload
- dispatches UploadQueued with the transport backgroundSync flag, then sends via the transport
- translates a progress callback into UploadProgress
- translates a resolved transport into UploadComplete
- translates a rejected transport into UploadFailed with the reason
- does not dispatch UploadFailed on a user-initiated abort
#### authGuard
- allows an authenticated user
@@ -0,0 +1,230 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import {
UploadAdapter,
UPLOAD_ABORTED,
XhrUploadHandle,
} from '@shared/infrastructure/upload.adapter';
import { UploadMsg } from '@shared/domain/upload.machine';
import { UploadShellService, UploadTransport, UPLOAD_TRANSPORT } from './upload-shell.service';
/** Records every `send()` call and lets a test resolve/reject/report progress by hand. */
function fakeTransport(backgroundSyncAvailable = false) {
const handles: Array<{
req: Parameters<UploadTransport['send']>[0];
onProgress: (pct: number) => void;
resolveDone: (v: { documentId: string }) => void;
rejectDone: (e: unknown) => void;
cancel: ReturnType<typeof vi.fn>;
}> = [];
const transport: UploadTransport = {
backgroundSyncAvailable,
send: vi.fn((req, onProgress) => {
let resolveDone!: (v: { documentId: string }) => void;
let rejectDone!: (e: unknown) => void;
const done = new Promise<{ documentId: string }>((res, rej) => {
resolveDone = res;
rejectDone = rej;
});
const cancel = vi.fn();
handles.push({ req, onProgress, resolveDone, rejectDone, cancel });
const handle: XhrUploadHandle = { done, cancel };
return handle;
}),
};
return { transport, handles };
}
function setup(opts: { backgroundSync?: boolean; adapter?: Partial<UploadAdapter> } = {}) {
const { transport, handles } = fakeTransport(opts.backgroundSync ?? false);
const adapter: Partial<UploadAdapter> = {
status: vi.fn().mockResolvedValue([]),
deleteDocument: vi.fn().mockResolvedValue(undefined),
...opts.adapter,
};
TestBed.configureTestingModule({
providers: [
{ provide: UPLOAD_TRANSPORT, useValue: transport },
{ provide: UploadAdapter, useValue: adapter },
],
});
const service = TestBed.inject(UploadShellService);
const dispatch = vi.fn<(m: UploadMsg) => void>();
return { service, dispatch, transport, handles, adapter };
}
const req = { localId: 'l1', categoryId: 'c1', wizardId: 'w1', file: new File(['x'], 'x.pdf') };
describe('UploadShellService.upload', () => {
it('dispatches UploadQueued with the transport backgroundSync flag, then sends via the transport', () => {
const { service, dispatch, transport } = setup({ backgroundSync: true });
service.upload(req, dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'UploadQueued',
localId: 'l1',
backgroundSync: true,
});
expect(transport.send).toHaveBeenCalledOnce();
});
it('translates a progress callback into UploadProgress', () => {
const { service, dispatch, handles } = setup();
service.upload(req, dispatch);
handles[0].onProgress(42);
expect(dispatch).toHaveBeenCalledWith({
type: 'UploadProgress',
localId: 'l1',
progressPct: 42,
});
});
it('translates a resolved transport into UploadComplete', async () => {
const { service, dispatch, handles } = setup();
service.upload(req, dispatch);
handles[0].resolveDone({ documentId: 'doc-1' });
await Promise.resolve();
await Promise.resolve();
expect(dispatch).toHaveBeenCalledWith({
type: 'UploadComplete',
localId: 'l1',
documentId: 'doc-1',
});
});
it('translates a rejected transport into UploadFailed with the reason', async () => {
const { service, dispatch, handles } = setup();
service.upload(req, dispatch);
handles[0].rejectDone('network down');
await Promise.resolve();
await Promise.resolve();
expect(dispatch).toHaveBeenCalledWith({
type: 'UploadFailed',
localId: 'l1',
reason: 'network down',
});
});
it('does not dispatch UploadFailed on a user-initiated abort', async () => {
const { service, dispatch, handles } = setup();
service.upload(req, dispatch);
handles[0].rejectDone(UPLOAD_ABORTED);
await Promise.resolve();
await Promise.resolve();
expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'UploadFailed' }));
});
});
describe('UploadShellService.cancel', () => {
it('calls the transport cancel function for an in-flight upload and forgets it', async () => {
const { service, dispatch, handles } = setup();
service.upload(req, dispatch);
service.cancel(['l1']);
expect(handles[0].cancel).toHaveBeenCalledOnce();
// Cancelling twice is a no-op the second time — the entry is already forgotten.
service.cancel(['l1']);
expect(handles[0].cancel).toHaveBeenCalledOnce();
});
it('is a no-op for a localId with nothing in flight', () => {
const { service } = setup();
expect(() => service.cancel(['unknown'])).not.toThrow();
});
});
describe('UploadShellService.delete', () => {
it('dispatches UploadDeleting, then UploadDeleteComplete on success', async () => {
const { service, dispatch } = setup();
service.delete('l1', 'doc-1', dispatch);
expect(dispatch).toHaveBeenCalledWith({ type: 'UploadDeleting', localId: 'l1' });
await Promise.resolve();
await Promise.resolve();
expect(dispatch).toHaveBeenCalledWith({ type: 'UploadDeleteComplete', localId: 'l1' });
});
it('dispatches UploadDeleteFailed with the server detail on failure', async () => {
const { service, dispatch } = setup({
adapter: { deleteDocument: vi.fn().mockRejectedValue({ detail: 'Document is gekoppeld.' }) },
});
service.delete('l1', 'doc-1', dispatch);
await Promise.resolve();
await Promise.resolve();
expect(dispatch).toHaveBeenCalledWith({
type: 'UploadDeleteFailed',
localId: 'l1',
reason: 'Document is gekoppeld.',
});
});
it('falls back to an empty reason when the server sends no detail', async () => {
const { service, dispatch } = setup({
adapter: { deleteDocument: vi.fn().mockRejectedValue(new Error('boom')) },
});
service.delete('l1', 'doc-1', dispatch);
await Promise.resolve();
await Promise.resolve();
expect(dispatch).toHaveBeenCalledWith({
type: 'UploadDeleteFailed',
localId: 'l1',
reason: '',
});
});
});
describe('UploadShellService.pollReturning', () => {
it('does nothing when there are no uploads to poll', async () => {
const status = vi.fn().mockResolvedValue([]);
const { service, dispatch } = setup({ adapter: { status } });
await service.pollReturning([], dispatch);
expect(status).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
it('dispatches BackgroundUploadsReturned for uploads the server reports complete', async () => {
const status = vi.fn().mockResolvedValue([
{ localId: 'l1', status: 'complete', documentId: 'doc-1' },
{ localId: 'l2', status: 'unknown' },
]);
const { service, dispatch } = setup({ adapter: { status } });
const uploads = [
{
localId: 'l1',
categoryId: 'c1',
fileName: 'a.pdf',
fileSizeMb: 1,
status: { type: 'queued' as const },
backgroundSync: true,
},
{
localId: 'l2',
categoryId: 'c1',
fileName: 'b.pdf',
fileSizeMb: 1,
status: { type: 'queued' as const },
backgroundSync: true,
},
];
await service.pollReturning(uploads, dispatch);
expect(status).toHaveBeenCalledWith(['l1', 'l2']);
expect(dispatch).toHaveBeenCalledWith({
type: 'BackgroundUploadsReturned',
results: [{ localId: 'l1', success: true, documentId: 'doc-1' }],
});
});
it('does not dispatch when nothing has arrived yet', async () => {
const status = vi.fn().mockResolvedValue([{ localId: 'l1', status: 'unknown' }]);
const { service, dispatch } = setup({ adapter: { status } });
const uploads = [
{
localId: 'l1',
categoryId: 'c1',
fileName: 'a.pdf',
fileSizeMb: 1,
status: { type: 'queued' as const },
backgroundSync: true,
},
];
await service.pollReturning(uploads, dispatch);
expect(dispatch).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, InjectionToken, inject } from '@angular/core';
import {
UploadAdapter,
XhrUploadRequest,
@@ -28,6 +28,18 @@ class KeepaliveTransport implements UploadTransport {
}
}
/**
* The swap seam (see UploadTransport above), made real: a spec provides a fake
* transport against this token instead of the concrete class. This copies the
* `SessionPort` / `SESSION_PORT` shape (session.port.ts), the repo's one other
* explicit port. The default factory returns the same KeepaliveTransport
* instance the class-injection used to, so runtime behaviour is unchanged.
*/
export const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT', {
providedIn: 'root',
factory: () => inject(KeepaliveTransport),
});
type Dispatch = (m: UploadMsg) => void;
/**
@@ -37,7 +49,7 @@ type Dispatch = (m: UploadMsg) => void;
*/
@Injectable({ providedIn: 'root' })
export class UploadShellService {
private transport: UploadTransport = inject(KeepaliveTransport);
private transport: UploadTransport = inject(UPLOAD_TRANSPORT);
private adapter = inject(UploadAdapter);
private inflight = new Map<string, () => void>(); // localId → cancel