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:
@@ -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 > Loading > 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user