add unit tests

This commit is contained in:
2026-03-10 00:05:08 +01:00
parent db6a230696
commit 7170e4a08e
17 changed files with 862 additions and 50 deletions

View File

@@ -0,0 +1,73 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Router} from '@angular/router';
import {of} from 'rxjs';
import {GlobalSettingsService} from '../../../services/global-settings.service';
import {ShowService} from '../../shows/services/show.service';
import {SelectComponent} from './select.component';
describe('SelectComponent', () => {
let component: SelectComponent;
let fixture: ComponentFixture<SelectComponent>;
let showServiceSpy: jasmine.SpyObj<ShowService>;
let globalSettingsServiceSpy: jasmine.SpyObj<GlobalSettingsService>;
let routerSpy: jasmine.SpyObj<Router>;
beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']);
globalSettingsServiceSpy = jasmine.createSpyObj<GlobalSettingsService>('GlobalSettingsService', ['set']);
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);
showServiceSpy.list$.and.returnValue(
of([
{id: 'older', date: {toDate: () => new Date('2025-12-15T00:00:00Z')}},
{id: 'recent-a', date: {toDate: () => new Date('2026-03-01T00:00:00Z')}},
{id: 'recent-b', date: {toDate: () => new Date('2026-02-20T00:00:00Z')}},
] as never)
);
showServiceSpy.update$.and.resolveTo();
globalSettingsServiceSpy.set.and.resolveTo();
routerSpy.navigateByUrl.and.resolveTo(true);
await TestBed.configureTestingModule({
imports: [SelectComponent],
providers: [
{provide: ShowService, useValue: showServiceSpy},
{provide: GlobalSettingsService, useValue: globalSettingsServiceSpy},
{provide: Router, useValue: routerSpy},
],
}).compileComponents();
fixture = TestBed.createComponent(SelectComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should become visible on init', () => {
component.ngOnInit();
expect(component.visible).toBeTrue();
});
it('should expose recent shows sorted descending by date', done => {
component.shows$.subscribe(shows => {
expect(showServiceSpy.list$).toHaveBeenCalledWith(true);
expect(shows.map(show => show.id)).toEqual(['recent-a', 'recent-b']);
done();
});
});
it('should persist the selected show, trigger presentation reset and navigate', async () => {
const show = {id: 'show-1'} as never;
component.visible = true;
await component.selectShow(show);
expect(component.visible).toBeFalse();
expect(globalSettingsServiceSpy.set).toHaveBeenCalledWith({currentShow: 'show-1'});
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {presentationSongId: 'title'});
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/presentation/remote');
});
});

View File

@@ -0,0 +1,82 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ActivatedRoute, Router} from '@angular/router';
import {Timestamp} from '@angular/fire/firestore';
import {of} from 'rxjs';
import {ShowDataService} from '../services/show-data.service';
import {ShowService} from '../services/show.service';
import {EditComponent} from './edit.component';
describe('EditComponent', () => {
let component: EditComponent;
let fixture: ComponentFixture<EditComponent>;
let showServiceSpy: jasmine.SpyObj<ShowService>;
let showDataServiceStub: Pick<ShowDataService, 'list$'>;
let routerSpy: jasmine.SpyObj<Router>;
beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['read$', 'update$']);
showDataServiceStub = {list$: of([] as never)};
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);
showServiceSpy.read$.and.returnValue(
of({
id: 'show-1',
showType: 'service-worship',
date: {toDate: () => new Date('2026-03-10T00:00:00Z')},
} as never)
);
showServiceSpy.update$.and.resolveTo();
routerSpy.navigateByUrl.and.resolveTo(true);
await TestBed.configureTestingModule({
imports: [EditComponent],
providers: [
{provide: ShowService, useValue: showServiceSpy},
{provide: ShowDataService, useValue: showDataServiceStub},
{provide: Router, useValue: routerSpy},
{provide: ActivatedRoute, useValue: {params: of({showId: 'show-1'})}},
],
}).compileComponents();
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load the current show into the form on init', () => {
component.ngOnInit();
expect(showServiceSpy.read$).toHaveBeenCalledWith('show-1');
expect(component.form.value.id).toBe('show-1');
expect(component.form.value.showType).toBe('service-worship');
expect(component.form.value.date).toEqual(new Date('2026-03-10T00:00:00Z'));
});
it('should not save when the form is invalid', async () => {
component.form.setValue({id: null, date: null, showType: null});
await component.onSave();
expect(showServiceSpy.update$).not.toHaveBeenCalled();
expect(routerSpy.navigateByUrl).not.toHaveBeenCalled();
});
it('should update the show and navigate back to the detail page', async () => {
const date = new Date('2026-03-11T00:00:00Z');
const firestoreTimestamp = {seconds: 1} as never;
spyOn(Timestamp, 'fromDate').and.returnValue(firestoreTimestamp);
component.form.setValue({id: 'show-1', date, showType: 'home-group'});
await component.onSave();
expect(Timestamp.fromDate).toHaveBeenCalledWith(date);
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {
date: firestoreTimestamp,
showType: 'home-group',
} as never);
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/shows/show-1');
});
});

View File

@@ -1,5 +1,5 @@
import {TestBed} from '@angular/core/testing';
import {Packer} from 'docx';
import {DocxService} from './docx.service';
describe('DocxService', () => {
@@ -13,4 +13,37 @@ describe('DocxService', () => {
it('should be created', () => {
void expect(service).toBeTruthy();
});
it('should not try to save a document when the required data cannot be prepared', async () => {
const prepareDataSpy = spyOn<any>(service, 'prepareData').and.resolveTo(null);
const saveAsSpy = spyOn<any>(service, 'saveAs');
await service.create('show-1');
expect(prepareDataSpy).toHaveBeenCalledWith('show-1');
expect(saveAsSpy).not.toHaveBeenCalled();
});
it('should build and save a docx file when all data is available', async () => {
const blob = new Blob(['docx']);
const prepareDataSpy = spyOn<any>(service, 'prepareData').and.resolveTo({
show: {
showType: 'service-worship',
date: {toDate: () => new Date('2026-03-10T00:00:00Z')},
},
songs: [],
user: {name: 'Benjamin'},
config: {ccliLicenseId: '12345'},
});
const prepareNewDocumentSpy = spyOn<any>(service, 'prepareNewDocument').and.returnValue({doc: true});
const saveAsSpy = spyOn<any>(service, 'saveAs');
spyOn(Packer, 'toBlob').and.resolveTo(blob);
await service.create('show-1', {copyright: true});
expect(prepareDataSpy).toHaveBeenCalledWith('show-1');
expect(prepareNewDocumentSpy).toHaveBeenCalled();
expect(Packer.toBlob).toHaveBeenCalledWith({doc: true} as never);
expect(saveAsSpy).toHaveBeenCalledWith(blob, jasmine.stringMatching(/\.docx$/));
});
});

View File

@@ -1,23 +1,45 @@
import {TestBed} from '@angular/core/testing';
import {Storage} from '@angular/fire/storage';
import {FileService} from './file.service';
import {FileDataService} from './file-data.service';
import {FileService} from './file.service';
describe('FileService', () => {
let service: FileService;
let fileDataServiceSpy: jasmine.SpyObj<FileDataService>;
beforeEach(() => {
fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['delete']);
fileDataServiceSpy.delete.and.resolveTo();
void TestBed.configureTestingModule({
providers: [
{provide: Storage, useValue: {}},
{provide: FileDataService, useValue: {delete: () => Promise.resolve()}},
{provide: Storage, useValue: {app: 'test-storage'}},
{provide: FileDataService, useValue: fileDataServiceSpy},
],
});
service = TestBed.inject(FileService);
});
it('should be created', () => {
void expect(service).toBeTruthy();
expect(service).toBeTruthy();
});
it('should resolve download urls via AngularFire storage helpers', async () => {
const resolveSpy = spyOn<any>(service, 'resolveDownloadUrl').and.resolveTo('https://cdn.example/file.pdf');
await expectAsync(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).toBeResolvedTo('https://cdn.example/file.pdf');
expect(resolveSpy).toHaveBeenCalledWith('songs/song-1/file.pdf');
});
it('should delete the file from storage and metadata from firestore', async () => {
const deleteFromStorageSpy = spyOn<any>(service, 'deleteFromStorage').and.resolveTo();
service.delete('songs/song-1/file.pdf', 'song-1', 'file-1');
await Promise.resolve();
expect(deleteFromStorageSpy).toHaveBeenCalledWith('songs/song-1/file.pdf');
expect(fileDataServiceSpy.delete).toHaveBeenCalledWith('song-1', 'file-1');
});
});

View File

@@ -1,4 +1,4 @@
import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core';
import {EnvironmentInjector, inject, Injectable, runInInjectionContext} from '@angular/core';
import {deleteObject, getDownloadURL, ref, Storage} from '@angular/fire/storage';
import {from, Observable} from 'rxjs';
import {FileDataService} from './file-data.service';
@@ -12,11 +12,19 @@ export class FileService {
private environmentInjector = inject(EnvironmentInjector);
public getDownloadUrl(path: string): Observable<string> {
return from(runInInjectionContext(this.environmentInjector, () => getDownloadURL(ref(this.storage, path))));
return from(runInInjectionContext(this.environmentInjector, () => this.resolveDownloadUrl(path)));
}
public delete(path: string, songId: string, fileId: string): void {
void runInInjectionContext(this.environmentInjector, () => deleteObject(ref(this.storage, path)));
void runInInjectionContext(this.environmentInjector, () => this.deleteFromStorage(path));
void this.fileDataService.delete(songId, fileId);
}
private resolveDownloadUrl(path: string): Promise<string> {
return getDownloadURL(ref(this.storage, path));
}
private deleteFromStorage(path: string): Promise<void> {
return deleteObject(ref(this.storage, path));
}
}

View File

@@ -0,0 +1,32 @@
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {SongService} from './song.service';
import {SongListResolver} from './song-list.resolver';
describe('SongListResolver', () => {
let resolver: SongListResolver;
let songServiceSpy: jasmine.SpyObj<SongService>;
beforeEach(() => {
songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['list$']);
songServiceSpy.list$.and.returnValue(of([{id: 'song-1', title: 'Amazing Grace'}] as never));
void TestBed.configureTestingModule({
providers: [{provide: SongService, useValue: songServiceSpy}],
});
resolver = TestBed.inject(SongListResolver);
});
it('should be created', () => {
expect(resolver).toBeTruthy();
});
it('should resolve the first emitted song list from the service', done => {
resolver.resolve().subscribe(songs => {
expect(songServiceSpy.list$).toHaveBeenCalled();
expect(songs).toEqual([{id: 'song-1', title: 'Amazing Grace'}] as never);
done();
});
});
});

View File

@@ -1,22 +1,54 @@
import {TestBed} from '@angular/core/testing';
import {Storage} from '@angular/fire/storage';
import {UploadService} from './upload.service';
import {FileDataService} from './file-data.service';
import {Upload} from './upload';
import {UploadService} from './upload.service';
describe('UploadServiceService', () => {
beforeEach(
() =>
void TestBed.configureTestingModule({
providers: [
{provide: Storage, useValue: {}},
{provide: FileDataService, useValue: {set: () => Promise.resolve('')}},
],
})
);
describe('UploadService', () => {
let service: UploadService;
let fileDataServiceSpy: jasmine.SpyObj<FileDataService>;
beforeEach(() => {
fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['set']);
fileDataServiceSpy.set.and.resolveTo('file-1');
void TestBed.configureTestingModule({
providers: [
{provide: Storage, useValue: {app: 'test-storage'}},
{provide: FileDataService, useValue: fileDataServiceSpy},
],
});
service = TestBed.inject(UploadService);
});
it('should be created', () => {
const service: UploadService = TestBed.inject(UploadService);
void expect(service).toBeTruthy();
expect(service).toBeTruthy();
});
it('should upload the file, update progress and persist file metadata on success', async () => {
const task = {
on: (event: string, progress: (snapshot: {bytesTransferred: number; totalBytes: number}) => void, error: () => void, success: () => void) => {
progress({bytesTransferred: 50, totalBytes: 100});
success();
},
};
const uploadSpy = spyOn<any>(service, 'startUpload').and.returnValue(task as never);
const upload = new Upload(new File(['content'], 'test.pdf', {type: 'application/pdf'}));
service.pushUpload('song-1', upload);
await Promise.resolve();
expect(uploadSpy).toHaveBeenCalledWith('/attachments/song-1/test.pdf', upload.file);
expect(upload.progress).toBe(50);
expect(upload.path).toBe('/attachments/song-1');
expect(fileDataServiceSpy.set).toHaveBeenCalledWith(
'song-1',
jasmine.objectContaining({
name: 'test.pdf',
path: '/attachments/song-1',
createdAt: jasmine.any(Date),
})
);
});
});

View File

@@ -18,12 +18,7 @@ export class UploadService extends FileBase {
const filePath = `${directory}/${upload.file.name}`;
upload.path = directory;
const {task} = runInInjectionContext(this.environmentInjector, () => {
const storageRef = ref(this.storage, filePath);
return {
task: uploadBytesResumable(storageRef, upload.file),
};
});
const task = runInInjectionContext(this.environmentInjector, () => this.startUpload(filePath, upload.file));
task.on(
'state_changed',
@@ -45,4 +40,9 @@ export class UploadService extends FileBase {
};
await this.fileDataService.set(songId, file);
}
private startUpload(filePath: string, file: File) {
const storageRef = ref(this.storage, filePath);
return uploadBytesResumable(storageRef, file);
}
}

View File

@@ -1,5 +1,4 @@
import {TestBed} from '@angular/core/testing';
import {EditSongGuard} from './edit-song.guard';
describe('EditSongGuard', () => {
@@ -11,6 +10,22 @@ describe('EditSongGuard', () => {
});
it('should be created', () => {
void expect(guard).toBeTruthy();
expect(guard).toBeTruthy();
});
it('should allow navigation when there is no nested editSongComponent', () => {
const result = guard.canDeactivate({editSongComponent: null} as never, {} as never, {} as never, {} as never);
expect(result).toBeTrue();
});
it('should delegate to askForSave on the nested editSongComponent', async () => {
const nextState = {url: '/songs'} as never;
const askForSave = jasmine.createSpy('askForSave').and.resolveTo(true);
const result = await guard.canDeactivate({editSongComponent: {askForSave}} as never, {} as never, {} as never, nextState);
expect(askForSave).toHaveBeenCalledWith(nextState);
expect(result).toBeTrue();
});
});

View File

@@ -1,12 +1,74 @@
import {TestBed} from '@angular/core/testing';
import {EditService} from './edit.service';
describe('EditService', () => {
beforeEach(() => void TestBed.configureTestingModule({}));
let service: EditService;
beforeEach(() => {
void TestBed.configureTestingModule({});
service = TestBed.inject(EditService);
});
it('should be created', () => {
const service: EditService = TestBed.inject(EditService);
void expect(service).toBeTruthy();
expect(service).toBeTruthy();
});
it('should create a form with all editable song fields populated', () => {
const form = service.createSongForm({
text: 'Line 1',
title: 'Amazing Grace',
comment: 'Comment',
flags: 'fast',
key: 'G',
tempo: 90,
type: 'Praise',
status: 'final',
legalType: 'allowed',
legalOwner: 'CCLI',
legalOwnerId: '123',
artist: 'Artist',
label: 'Label',
termsOfUse: 'Use it',
origin: 'Origin',
} as never);
expect(form.getRawValue()).toEqual({
text: 'Line 1',
title: 'Amazing Grace',
comment: 'Comment',
flags: 'fast',
key: 'G',
tempo: 90,
type: 'Praise',
status: 'final',
legalType: 'allowed',
legalOwner: 'CCLI',
legalOwnerId: '123',
artist: 'Artist',
label: 'Label',
termsOfUse: 'Use it',
origin: 'Origin',
});
});
it('should default the status control to draft when the song has no status', () => {
const form = service.createSongForm({
text: '',
title: 'Untitled',
comment: '',
flags: '',
key: 'C',
tempo: 80,
type: 'Misc',
legalType: 'open',
legalOwner: 'other',
legalOwnerId: '',
artist: '',
label: '',
termsOfUse: '',
origin: '',
} as never);
expect(form.get('status')?.value).toBe('draft');
});
});