fix unit tests

This commit is contained in:
2026-06-09 16:31:42 +02:00
parent 7385bc52e9
commit 5a0da5a1eb
45 changed files with 921 additions and 683 deletions
@@ -1,33 +1,36 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {Firestore} from '@angular/fire/firestore'; import {Firestore} from '@angular/fire/firestore';
import {of} from 'rxjs'; import {of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {DbService} from 'src/app/services/db.service'; import {DbService} from 'src/app/services/db.service';
import {GuestShowDataService} from './guest-show-data.service'; import {GuestShowDataService} from './guest-show-data.service';
describe('GuestShowDataService', () => { describe('GuestShowDataService', () => {
type DocumentRefStub = {update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn>};
type CollectionRefStub = {add: ReturnType<typeof vi.fn>};
type PathSpy<T> = ReturnType<typeof vi.fn> & ((path: string) => T);
let service: GuestShowDataService; let service: GuestShowDataService;
let docUpdateSpy: jasmine.Spy<() => Promise<void>>; let docUpdateSpy: ReturnType<typeof vi.fn>;
let docDeleteSpy: jasmine.Spy<() => Promise<void>>; let docDeleteSpy: ReturnType<typeof vi.fn>;
let docSpy: jasmine.Spy; let docSpy: PathSpy<DocumentRefStub>;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; let colAddSpy: ReturnType<typeof vi.fn>;
let colSpy: jasmine.Spy; let colSpy: PathSpy<CollectionRefStub>;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {col$: ReturnType<typeof vi.fn>; doc$: ReturnType<typeof vi.fn>; doc: ReturnType<typeof vi.fn>; col: ReturnType<typeof vi.fn>};
let firestoreStub: Firestore; let firestoreStub: Firestore;
beforeEach(async () => { beforeEach(async () => {
docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); docUpdateSpy = vi.fn().mockResolvedValue(undefined);
docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); docDeleteSpy = vi.fn().mockResolvedValue(undefined);
docSpy = jasmine.createSpy('doc').and.returnValue({ docSpy = vi.fn().mockReturnValue({
update: docUpdateSpy, update: docUpdateSpy,
delete: docDeleteSpy, delete: docDeleteSpy,
}); }) as PathSpy<DocumentRefStub>;
colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'guest-2'}); colAddSpy = vi.fn().mockResolvedValue({id: 'guest-2'});
colSpy = jasmine.createSpy('col').and.returnValue({add: colAddSpy}); colSpy = vi.fn().mockReturnValue({add: colAddSpy}) as PathSpy<CollectionRefStub>;
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['col$', 'doc$', 'doc', 'col']); dbServiceSpy = {col$: vi.fn(), doc$: vi.fn(), doc: docSpy, col: colSpy};
dbServiceSpy.col$.and.returnValue(of([{id: 'guest-1'}]) as never); dbServiceSpy.col$.mockReturnValue(of([{id: 'guest-1'}]) as never);
dbServiceSpy.doc$.and.returnValue(of({id: 'guest-1'}) as never); dbServiceSpy.doc$.mockReturnValue(of({id: 'guest-1'}) as never);
dbServiceSpy.doc.and.callFake(docSpy);
dbServiceSpy.col.and.callFake(colSpy);
firestoreStub = {} as Firestore; firestoreStub = {} as Firestore;
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -60,15 +63,15 @@ describe('GuestShowDataService', () => {
await service.update$('guest-7', {published: true} as never); await service.update$('guest-7', {published: true} as never);
expect(docSpy).toHaveBeenCalledWith('guest/guest-7'); expect(docSpy).toHaveBeenCalledWith('guest/guest-7');
const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [updatePayload] = docUpdateSpy.mock.calls.at(-1) as [Record<string, unknown>];
expect(updatePayload).toEqual({published: true}); expect(updatePayload).toEqual({published: true});
}); });
it('should add a guest show and return the created id', async () => { it('should add a guest show and return the created id', async () => {
await expectAsync(service.add({published: false} as never)).toBeResolvedTo('guest-2'); await expect(service.add({published: false} as never)).resolves.toEqual('guest-2');
expect(colSpy).toHaveBeenCalledWith('guest'); expect(colSpy).toHaveBeenCalledWith('guest');
const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [addPayload] = colAddSpy.mock.calls.at(-1) as [Record<string, unknown>];
expect(addPayload).toEqual({published: false}); expect(addPayload).toEqual({published: false});
}); });
@@ -4,18 +4,19 @@ import {GuestShowService} from './guest-show.service';
import {ShowService} from '../shows/services/show.service'; import {ShowService} from '../shows/services/show.service';
import {Show} from '../shows/services/show'; import {Show} from '../shows/services/show';
import {Song} from '../songs/services/song'; import {Song} from '../songs/services/song';
import {beforeEach, describe, expect, it, vi} from 'vitest';
describe('GuestShowService', () => { describe('GuestShowService', () => {
let service: GuestShowService; let service: GuestShowService;
let guestShowDataServiceSpy: jasmine.SpyObj<GuestShowDataService>; let guestShowDataServiceSpy: {add: ReturnType<typeof vi.fn>; update$: ReturnType<typeof vi.fn>};
let showServiceSpy: jasmine.SpyObj<ShowService>; let showServiceSpy: {update$: ReturnType<typeof vi.fn>};
beforeEach(async () => { beforeEach(async () => {
guestShowDataServiceSpy = jasmine.createSpyObj<GuestShowDataService>('GuestShowDataService', ['add', 'update$']); guestShowDataServiceSpy = {add: vi.fn(), update$: vi.fn()};
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['update$']); showServiceSpy = {update$: vi.fn()};
guestShowDataServiceSpy.add.and.resolveTo('share-1'); guestShowDataServiceSpy.add.mockResolvedValue('share-1');
guestShowDataServiceSpy.update$.and.resolveTo(); guestShowDataServiceSpy.update$.mockResolvedValue(undefined);
showServiceSpy.update$.and.resolveTo(); showServiceSpy.update$.mockResolvedValue(undefined);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -36,17 +37,17 @@ describe('GuestShowService', () => {
const songs = [{id: 'song-1'}] as unknown as Song[]; const songs = [{id: 'song-1'}] as unknown as Song[];
const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-1'; const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-1';
await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); await expect(service.share(show, songs)).resolves.toEqual(expectedUrl);
const [addPayload] = guestShowDataServiceSpy.add.calls.mostRecent().args as [Record<string, unknown>]; const [addPayload] = guestShowDataServiceSpy.add.mock.calls.at(-1) as [Record<string, unknown>];
expect(addPayload).toEqual( expect(addPayload).toEqual(
jasmine.objectContaining({ expect.objectContaining({
showType: 'service-worship', showType: 'service-worship',
date: show.date, date: show.date,
songs, songs,
}) })
); );
expect(addPayload['updatedAt']).toEqual(jasmine.any(Date)); expect(addPayload['updatedAt']).toEqual(expect.any(Date));
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {shareId: 'share-1'}); expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {shareId: 'share-1'});
}); });
@@ -55,18 +56,18 @@ describe('GuestShowService', () => {
const songs = [{id: 'song-1'}] as unknown as Song[]; const songs = [{id: 'song-1'}] as unknown as Song[];
const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-9'; const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-9';
await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); await expect(service.share(show, songs)).resolves.toEqual(expectedUrl);
const [shareId, updatePayload] = guestShowDataServiceSpy.update$.calls.mostRecent().args as [string, Record<string, unknown>]; const [shareId, updatePayload] = guestShowDataServiceSpy.update$.mock.calls.at(-1) as [string, Record<string, unknown>];
expect(shareId).toBe('share-9'); expect(shareId).toBe('share-9');
expect(updatePayload).toEqual( expect(updatePayload).toEqual(
jasmine.objectContaining({ expect.objectContaining({
showType: 'service-worship', showType: 'service-worship',
date: show.date, date: show.date,
songs, songs,
}) })
); );
expect(updatePayload['updatedAt']).toEqual(jasmine.any(Date)); expect(updatePayload['updatedAt']).toEqual(expect.any(Date));
expect(guestShowDataServiceSpy.add).not.toHaveBeenCalled(); expect(guestShowDataServiceSpy.add).not.toHaveBeenCalled();
expect(showServiceSpy.update$).not.toHaveBeenCalled(); expect(showServiceSpy.update$).not.toHaveBeenCalled();
}); });
@@ -1,13 +1,14 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ActivatedRoute} from '@angular/router'; import {ActivatedRoute} from '@angular/router';
import {BehaviorSubject, of} from 'rxjs'; import {BehaviorSubject, of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {GuestComponent} from './guest.component'; import {GuestComponent} from './guest.component';
import {GuestShowDataService} from './guest-show-data.service'; import {GuestShowDataService} from './guest-show-data.service';
describe('GuestComponent', () => { describe('GuestComponent', () => {
let component: GuestComponent; let component: GuestComponent;
let fixture: ComponentFixture<GuestComponent>; let fixture: ComponentFixture<GuestComponent>;
let guestShowDataServiceSpy: jasmine.SpyObj<GuestShowDataService>; let guestShowDataServiceSpy: {read: ReturnType<typeof vi.fn>; read$: ReturnType<typeof vi.fn>};
let guestShowSubject: BehaviorSubject<unknown>; let guestShowSubject: BehaviorSubject<unknown>;
beforeEach(async () => { beforeEach(async () => {
@@ -26,8 +27,8 @@ describe('GuestComponent', () => {
}, },
], ],
}); });
guestShowDataServiceSpy = jasmine.createSpyObj<GuestShowDataService>('GuestShowDataService', ['read', 'read$']); guestShowDataServiceSpy = {read: vi.fn(), read$: vi.fn()};
guestShowDataServiceSpy.read.and.resolveTo({ guestShowDataServiceSpy.read.mockResolvedValue({
id: 'guest-1', id: 'guest-1',
showType: 'service-worship', showType: 'service-worship',
date: { date: {
@@ -42,7 +43,7 @@ describe('GuestComponent', () => {
}, },
], ],
} as never); } as never);
guestShowDataServiceSpy.read$.and.returnValue(guestShowSubject.asObservable() as never); guestShowDataServiceSpy.read$.mockReturnValue(guestShowSubject.asObservable() as never);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [GuestComponent], imports: [GuestComponent],
+5 -1
View File
@@ -1,7 +1,11 @@
<app-page-frame title="Hilfe" [withMenu]="false"> <app-page-frame title="Hilfe" [withMenu]="false">
<div content> <div content>
<app-card [heading]="heading"> <app-card [heading]="heading">
<div (click)="onContentClick($event)" [innerHTML]="renderedHtml" class="help-content"></div> <div (click)="onContentClick($event)"
(keydown)="onContentKeydown($event)"
[innerHTML]="renderedHtml"
class="help-content"
tabindex="0"></div>
<!-- @if (loading && !hasLoadedContent) {--> <!-- @if (loading && !hasLoadedContent) {-->
<!-- <div class="help-state">Hilfe wird geladen.</div>--> <!-- <div class="help-state">Hilfe wird geladen.</div>-->
+8 -5
View File
@@ -6,6 +6,7 @@ describe('HelpComponent', () => {
let component: HelpComponent; let component: HelpComponent;
let fixture: ComponentFixture<HelpComponent>; let fixture: ComponentFixture<HelpComponent>;
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
type FetchResponse = {ok: boolean; text: () => Promise<string>};
beforeEach(async () => { beforeEach(async () => {
fetchMock = vi.fn().mockResolvedValue({ fetchMock = vi.fn().mockResolvedValue({
@@ -40,11 +41,11 @@ describe('HelpComponent', () => {
}); });
it('should keep the current content visible while the next page loads', async () => { it('should keep the current content visible while the next page loads', async () => {
let resolveFetch: ((value: {ok: boolean; text: () => Promise<string>}) => void) | null = null; let resolveFetch!: (value: FetchResponse) => void;
fetchMock.mockImplementationOnce( fetchMock.mockImplementationOnce(
() => () =>
new Promise(resolve => { new Promise<FetchResponse>(resolve => {
resolveFetch = resolve; resolveFetch = resolve;
}) })
); );
@@ -52,19 +53,21 @@ describe('HelpComponent', () => {
const oldHtml = component.renderedHtml; const oldHtml = component.renderedHtml;
const oldHeading = component.heading; const oldHeading = component.heading;
void (component as {loadDocument(path: string): Promise<void>}).loadDocument('man/pages/lieder-liste.md'); const loadPromise = (component as unknown as {loadDocument(path: string): Promise<void>}).loadDocument(
'man/pages/lieder-liste.md'
);
fixture.detectChanges(); fixture.detectChanges();
expect(component.loading).toBe(true); expect(component.loading).toBe(true);
expect(component.renderedHtml).toBe(oldHtml); expect(component.renderedHtml).toBe(oldHtml);
expect(component.heading).toBe(oldHeading); expect(component.heading).toBe(oldHeading);
resolveFetch?.({ resolveFetch({
ok: true, ok: true,
text: () => Promise.resolve('# Liedliste\n\nInhalt'), text: () => Promise.resolve('# Liedliste\n\nInhalt'),
}); });
await fixture.whenStable(); await loadPromise;
fixture.detectChanges(); fixture.detectChanges();
expect(component.loading).toBe(false); expect(component.loading).toBe(false);
+15 -3
View File
@@ -46,6 +46,14 @@ export class HelpComponent implements OnInit {
void this.loadDocument(resolvedPath); void this.loadDocument(resolvedPath);
} }
public onContentKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
this.onContentClick(event as unknown as MouseEvent);
}
private async loadDocument(path: string): Promise<void> { private async loadDocument(path: string): Promise<void> {
if (!path.startsWith('man/')) { if (!path.startsWith('man/')) {
if (!this.hasLoadedContent) { if (!this.hasLoadedContent) {
@@ -300,14 +308,18 @@ export class HelpComponent implements OnInit {
return `<a href="${safeHref}">${safeLabel}</a>`; return `<a href="${safeHref}">${safeLabel}</a>`;
}); });
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(^|[^\*])\*([^*]+)\*/g, '$1<em>$2</em>'); html = html.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
return html; return html;
} }
private decodePlainText(text: string): string { private decodePlainText(text: string): string {
const html = this.renderInlineMarkdown(text); const html = this.renderInlineMarkdown(this.decodeHtmlEntities(text));
const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, html)?.replace(/<[^>]+>/g, '').trim() ?? text; const sanitized =
this.sanitizer
.sanitize(SecurityContext.HTML, html)
?.replace(/<[^>]+>/g, '')
.trim() ?? text;
return this.decodeHtmlEntities(sanitized); return this.decodeHtmlEntities(sanitized);
} }
@@ -1,6 +1,7 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {firstValueFrom, of} from 'rxjs'; import {firstValueFrom, of} from 'rxjs';
import {vi} from 'vitest';
import {GlobalSettingsService} from '../../../services/global-settings.service'; import {GlobalSettingsService} from '../../../services/global-settings.service';
import {ShowService} from '../../shows/services/show.service'; import {ShowService} from '../../shows/services/show.service';
import {SelectComponent} from './select.component'; import {SelectComponent} from './select.component';
@@ -8,29 +9,47 @@ import {SelectComponent} from './select.component';
describe('SelectComponent', () => { describe('SelectComponent', () => {
let component: SelectComponent; let component: SelectComponent;
let fixture: ComponentFixture<SelectComponent>; let fixture: ComponentFixture<SelectComponent>;
let showServiceSpy: jasmine.SpyObj<ShowService>; let showServiceSpy: {
let globalSettingsServiceSpy: jasmine.SpyObj<GlobalSettingsService>; list$: ReturnType<typeof vi.fn>;
let routerSpy: jasmine.SpyObj<Router>; update$: ReturnType<typeof vi.fn>;
const createShow = (id: string, isoDate: string) => ({id, date: {toDate: () => new Date(isoDate)}}); };
let globalSettingsServiceSpy: {
set: ReturnType<typeof vi.fn>;
};
let routerSpy: {
navigateByUrl: ReturnType<typeof vi.fn>;
};
const createShow = (id: string, daysAgo: number) => {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
return {id, date: {toDate: () => date}};
};
beforeEach(async () => { beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']); showServiceSpy = {
globalSettingsServiceSpy = jasmine.createSpyObj<GlobalSettingsService>('GlobalSettingsService', ['set']); list$: vi.fn().mockReturnValue(
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']); of([
createShow('older', 45),
showServiceSpy.list$.and.returnValue( createShow('recent-a', 7),
of([createShow('older', '2025-12-15T00:00:00Z'), createShow('recent-a', '2026-03-01T00:00:00Z'), createShow('recent-b', '2026-02-20T00:00:00Z')]) as never createShow('recent-b', 14),
); ]) as never
showServiceSpy.update$.and.resolveTo(); ),
globalSettingsServiceSpy.set.and.resolveTo(); update$: vi.fn().mockResolvedValue(undefined),
routerSpy.navigateByUrl.and.resolveTo(true); };
globalSettingsServiceSpy = {
set: vi.fn().mockResolvedValue(undefined),
};
routerSpy = {
navigateByUrl: vi.fn().mockResolvedValue(true),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [SelectComponent], imports: [SelectComponent],
providers: [ providers: [
{provide: ShowService, useValue: showServiceSpy}, {provide: ShowService, useValue: showServiceSpy as unknown as ShowService},
{provide: GlobalSettingsService, useValue: globalSettingsServiceSpy}, {provide: GlobalSettingsService, useValue: globalSettingsServiceSpy as unknown as GlobalSettingsService},
{provide: Router, useValue: routerSpy}, {provide: Router, useValue: routerSpy as unknown as Router},
], ],
}).compileComponents(); }).compileComponents();
@@ -45,7 +64,7 @@ describe('SelectComponent', () => {
it('should become visible on init', () => { it('should become visible on init', () => {
component.ngOnInit(); component.ngOnInit();
expect(component.visible).toBeTrue(); expect(component.visible).toBe(true);
}); });
it('should expose recent shows sorted descending by date', async () => { it('should expose recent shows sorted descending by date', async () => {
@@ -61,7 +80,7 @@ describe('SelectComponent', () => {
await component.selectShow(show); await component.selectShow(show);
expect(component.visible).toBeFalse(); expect(component.visible).toBe(false);
expect(globalSettingsServiceSpy.set).toHaveBeenCalledWith({currentShow: 'show-1'}); expect(globalSettingsServiceSpy.set).toHaveBeenCalledWith({currentShow: 'show-1'});
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {presentationSongId: 'title'}); expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {presentationSongId: 'title'});
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/presentation/remote'); expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/presentation/remote');
@@ -31,10 +31,10 @@ describe('ReportDialogComponent', () => {
}); });
it('should mark numbers as opened locally', () => { it('should mark numbers as opened locally', () => {
expect(component.wasOpened('12345')).toBeFalse(); expect(component.wasOpened('12345')).toBe(false);
component.markOpened('12345'); component.markOpened('12345');
expect(component.wasOpened('12345')).toBeTrue(); expect(component.wasOpened('12345')).toBe(true);
}); });
}); });
@@ -1,11 +1,12 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ShareDialogComponent} from './share-dialog.component'; import {ShareDialogComponent} from './share-dialog.component';
import {MAT_DIALOG_DATA} from '@angular/material/dialog'; import {MAT_DIALOG_DATA} from '@angular/material/dialog';
import {vi} from 'vitest';
describe('ShareDialogComponent', () => { describe('ShareDialogComponent', () => {
let component: ShareDialogComponent; let component: ShareDialogComponent;
let fixture: ComponentFixture<ShareDialogComponent>; let fixture: ComponentFixture<ShareDialogComponent>;
type ShareDialogComponentInternals = ShareDialogComponent & { type ShareDialogComponentInternals = {
generateQrCode: () => Promise<string>; generateQrCode: () => Promise<string>;
}; };
@@ -28,10 +29,14 @@ describe('ShareDialogComponent', () => {
fixture = TestBed.createComponent(ShareDialogComponent); fixture = TestBed.createComponent(ShareDialogComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
spyOn(component as ShareDialogComponentInternals, 'generateQrCode').and.resolveTo('data:image/jpeg;base64,test'); vi.spyOn(component as unknown as ShareDialogComponentInternals, 'generateQrCode').mockResolvedValue('data:image/jpeg;base64,test');
fixture.detectChanges(); fixture.detectChanges();
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => { it('should create', () => {
void expect(component).toBeTruthy(); void expect(component).toBeTruthy();
}); });
@@ -2,6 +2,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {Timestamp} from '@angular/fire/firestore'; import {Timestamp} from '@angular/fire/firestore';
import {of} from 'rxjs'; import {of} from 'rxjs';
import {vi} from 'vitest';
import {ShowDataService} from '../services/show-data.service'; import {ShowDataService} from '../services/show-data.service';
import {ShowService} from '../services/show.service'; import {ShowService} from '../services/show.service';
import {EditComponent} from './edit.component'; import {EditComponent} from './edit.component';
@@ -9,25 +10,35 @@ import {EditComponent} from './edit.component';
describe('EditComponent', () => { describe('EditComponent', () => {
let component: EditComponent; let component: EditComponent;
let fixture: ComponentFixture<EditComponent>; let fixture: ComponentFixture<EditComponent>;
let showServiceSpy: jasmine.SpyObj<ShowService>; let showServiceSpy: {
read$: ReturnType<typeof vi.fn>;
update$: ReturnType<typeof vi.fn>;
};
let showDataServiceStub: Pick<ShowDataService, 'list$'>; let showDataServiceStub: Pick<ShowDataService, 'list$'>;
let routerSpy: jasmine.SpyObj<Router>; let routerSpy: {
navigateByUrl: ReturnType<typeof vi.fn>;
};
const createDate = (isoDate: string) => ({toDate: () => new Date(isoDate)}); const createDate = (isoDate: string) => ({toDate: () => new Date(isoDate)});
beforeEach(async () => { beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['read$', 'update$']); showServiceSpy = {
read$: vi.fn(),
update$: vi.fn(),
};
showDataServiceStub = {list$: of([]) as ShowDataService['list$']}; showDataServiceStub = {list$: of([]) as ShowDataService['list$']};
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']); routerSpy = {
navigateByUrl: vi.fn(),
};
showServiceSpy.read$.and.returnValue( showServiceSpy.read$.mockReturnValue(
of({ of({
id: 'show-1', id: 'show-1',
showType: 'service-worship', showType: 'service-worship',
date: createDate('2026-03-10T00:00:00Z'), date: createDate('2026-03-10T00:00:00Z'),
} as never) } as never)
); );
showServiceSpy.update$.and.resolveTo(); showServiceSpy.update$.mockResolvedValue(undefined);
routerSpy.navigateByUrl.and.resolveTo(true); routerSpy.navigateByUrl.mockResolvedValue(true);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [EditComponent], imports: [EditComponent],
@@ -43,6 +54,10 @@ describe('EditComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
@@ -68,7 +83,7 @@ describe('EditComponent', () => {
it('should update the show and navigate back to the detail page', async () => { it('should update the show and navigate back to the detail page', async () => {
const date = new Date('2026-03-11T00:00:00Z'); const date = new Date('2026-03-11T00:00:00Z');
const firestoreTimestamp = {seconds: 1} as never; const firestoreTimestamp = {seconds: 1} as never;
spyOn(Timestamp, 'fromDate').and.returnValue(firestoreTimestamp); vi.spyOn(Timestamp, 'fromDate').mockReturnValue(firestoreTimestamp);
component.form.setValue({id: 'show-1', date, showType: 'home-group'}); component.form.setValue({id: 'show-1', date, showType: 'home-group'});
await component.onSave(); await component.onSave();
@@ -1,4 +1,5 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {vi} from 'vitest';
import {DocxService} from './docx.service'; import {DocxService} from './docx.service';
describe('DocxService', () => { describe('DocxService', () => {
@@ -15,7 +16,7 @@ describe('DocxService', () => {
type DocxModuleLike = { type DocxModuleLike = {
Packer: {toBlob: (document: unknown) => Promise<Blob>}; Packer: {toBlob: (document: unknown) => Promise<Blob>};
}; };
type DocxServiceInternals = DocxService & { type DocxServiceInternals = {
prepareData: (showId: string) => Promise<PreparedData | null>; prepareData: (showId: string) => Promise<PreparedData | null>;
renderTitle: (docx: unknown, title: string) => unknown[]; renderTitle: (docx: unknown, title: string) => unknown[];
renderSongs: (docx: unknown, songs: unknown[], options: unknown, config: unknown) => unknown[]; renderSongs: (docx: unknown, songs: unknown[], options: unknown, config: unknown) => unknown[];
@@ -29,14 +30,18 @@ describe('DocxService', () => {
service = TestBed.inject(DocxService); service = TestBed.inject(DocxService);
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it('should be created', () => { it('should be created', () => {
void expect(service).toBeTruthy(); void expect(service).toBeTruthy();
}); });
it('should not try to save a document when the required data cannot be prepared', async () => { it('should not try to save a document when the required data cannot be prepared', async () => {
const serviceInternals = service as DocxServiceInternals; const serviceInternals = service as unknown as DocxServiceInternals;
const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo(null); const prepareDataSpy = vi.spyOn(serviceInternals, 'prepareData').mockResolvedValue(null);
const saveAsSpy = spyOn(serviceInternals, 'saveAs'); const saveAsSpy = vi.spyOn(serviceInternals, 'saveAs');
await service.create('show-1'); await service.create('show-1');
@@ -48,11 +53,11 @@ describe('DocxService', () => {
const blob = new Blob(['docx']); const blob = new Blob(['docx']);
const docxModule: DocxModuleLike = { const docxModule: DocxModuleLike = {
Packer: { Packer: {
toBlob: jasmine.createSpy('toBlob').and.resolveTo(blob), toBlob: vi.fn().mockResolvedValue(blob),
}, },
}; };
const serviceInternals = service as DocxServiceInternals; const serviceInternals = service as unknown as DocxServiceInternals;
const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo({ const prepareDataSpy = vi.spyOn(serviceInternals, 'prepareData').mockResolvedValue({
show: { show: {
showType: 'service-worship', showType: 'service-worship',
date: {toDate: () => new Date('2026-03-10T00:00:00Z')}, date: {toDate: () => new Date('2026-03-10T00:00:00Z')},
@@ -61,17 +66,17 @@ describe('DocxService', () => {
user: {name: 'Benjamin'}, user: {name: 'Benjamin'},
config: {ccliLicenseId: '12345'}, config: {ccliLicenseId: '12345'},
}); });
spyOn(serviceInternals, 'loadDocx').and.resolveTo(docxModule); vi.spyOn(serviceInternals, 'loadDocx').mockResolvedValue(docxModule);
spyOn(serviceInternals, 'renderTitle').and.returnValue([]); vi.spyOn(serviceInternals, 'renderTitle').mockReturnValue([]);
spyOn(serviceInternals, 'renderSongs').and.returnValue([]); vi.spyOn(serviceInternals, 'renderSongs').mockReturnValue([]);
const prepareNewDocumentSpy = spyOn(serviceInternals, 'prepareNewDocument').and.returnValue({doc: true}); const prepareNewDocumentSpy = vi.spyOn(serviceInternals, 'prepareNewDocument').mockReturnValue({doc: true});
const saveAsSpy = spyOn(serviceInternals, 'saveAs'); const saveAsSpy = vi.spyOn(serviceInternals, 'saveAs');
await service.create('show-1', {copyright: true}); await service.create('show-1', {copyright: true});
expect(prepareDataSpy).toHaveBeenCalledWith('show-1'); expect(prepareDataSpy).toHaveBeenCalledWith('show-1');
expect(prepareNewDocumentSpy).toHaveBeenCalled(); expect(prepareNewDocumentSpy).toHaveBeenCalled();
expect(docxModule.Packer.toBlob).toHaveBeenCalledWith({doc: true} as never); expect(docxModule.Packer.toBlob).toHaveBeenCalledWith({doc: true} as never);
expect(saveAsSpy).toHaveBeenCalledWith(blob, jasmine.stringMatching(/\.docx$/)); expect(saveAsSpy).toHaveBeenCalledWith(blob, expect.stringMatching(/\.docx$/));
}); });
}); });
@@ -1,29 +1,36 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {firstValueFrom, of, Subject} from 'rxjs'; import {firstValueFrom, of, Subject} from 'rxjs';
import {take} from 'rxjs/operators'; import {take} from 'rxjs/operators';
import {vi} from 'vitest';
import {DbService} from '../../../services/db.service'; import {DbService} from '../../../services/db.service';
import {ShowDataService} from './show-data.service'; import {ShowDataService} from './show-data.service';
describe('ShowDataService', () => { describe('ShowDataService', () => {
let service: ShowDataService; let service: ShowDataService;
let shows$: Subject<Array<{id: string; date: {toMillis: () => number}; archived?: boolean}>>; let shows$: Subject<Array<{id: string; date: {toMillis: () => number}; archived?: boolean}>>;
let docUpdateSpy: jasmine.Spy<() => Promise<void>>; let docUpdateSpy: ReturnType<typeof vi.fn>;
let docSpy: jasmine.Spy; let docSpy: ReturnType<typeof vi.fn>;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; let colAddSpy: ReturnType<typeof vi.fn>;
let colSpy: jasmine.Spy; let colSpy: ReturnType<typeof vi.fn>;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {
col$: ReturnType<typeof vi.fn>;
doc$: ReturnType<typeof vi.fn>;
doc: ReturnType<typeof vi.fn>;
col: ReturnType<typeof vi.fn>;
};
beforeEach(async () => { beforeEach(async () => {
shows$ = new Subject<Array<{id: string; date: {toMillis: () => number}; archived?: boolean}>>(); shows$ = new Subject<Array<{id: string; date: {toMillis: () => number}; archived?: boolean}>>();
docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); docUpdateSpy = vi.fn().mockResolvedValue(undefined);
docSpy = jasmine.createSpy('doc').and.returnValue({update: docUpdateSpy}); docSpy = vi.fn().mockReturnValue({update: docUpdateSpy});
colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'show-3'}); colAddSpy = vi.fn().mockResolvedValue({id: 'show-3'});
colSpy = jasmine.createSpy('col').and.returnValue({add: colAddSpy}); colSpy = vi.fn().mockReturnValue({add: colAddSpy});
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['col$', 'doc$', 'doc', 'col']); dbServiceSpy = {
dbServiceSpy.col$.and.returnValue(shows$.asObservable()); col$: vi.fn().mockReturnValue(shows$.asObservable()),
dbServiceSpy.doc$.and.returnValue(of(null)); doc$: vi.fn().mockReturnValue(of(null)),
dbServiceSpy.doc.and.callFake(docSpy); doc: docSpy,
dbServiceSpy.col.and.callFake(colSpy); col: colSpy,
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}], providers: [{provide: DbService, useValue: dbServiceSpy}],
@@ -77,12 +84,12 @@ describe('ShowDataService', () => {
it('should request only published recent shows and filter archived entries', async () => { it('should request only published recent shows and filter archived entries', async () => {
const publicShows$ = of([{id: 'show-1', archived: false}, {id: 'show-2', archived: true}, {id: 'show-3'}]); const publicShows$ = of([{id: 'show-1', archived: false}, {id: 'show-2', archived: true}, {id: 'show-3'}]);
dbServiceSpy.col$.and.returnValue(publicShows$ as never); dbServiceSpy.col$.mockReturnValue(publicShows$ as never);
const result = await firstValueFrom(service.listPublicSince$(3)); const result = await firstValueFrom(service.listPublicSince$(3));
expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows', jasmine.any(Array)); expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows', expect.any(Array));
const [, queryConstraints] = dbServiceSpy.col$.calls.mostRecent().args as [string, unknown[]]; const [, queryConstraints] = dbServiceSpy.col$.mock.lastCall as [string, unknown[]];
expect(queryConstraints.length).toBe(3); expect(queryConstraints.length).toBe(3);
expect(result.map(show => show.id)).toEqual(['show-1', 'show-3']); expect(result.map(show => show.id)).toEqual(['show-1', 'show-3']);
}); });
@@ -97,15 +104,15 @@ describe('ShowDataService', () => {
await service.update('show-8', {archived: true}); await service.update('show-8', {archived: true});
expect(docSpy).toHaveBeenCalledWith('shows/show-8'); expect(docSpy).toHaveBeenCalledWith('shows/show-8');
const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [updatePayload] = docUpdateSpy.mock.lastCall as [Record<string, unknown>];
expect(updatePayload).toEqual({archived: true}); expect(updatePayload).toEqual({archived: true});
}); });
it('should add a show to the shows collection and return the new id', async () => { it('should add a show to the shows collection and return the new id', async () => {
await expectAsync(service.add({published: true})).toBeResolvedTo('show-3'); await expect(service.add({published: true})).resolves.toBe('show-3');
expect(colSpy).toHaveBeenCalledWith('shows'); expect(colSpy).toHaveBeenCalledWith('shows');
const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [addPayload] = colAddSpy.mock.lastCall as [Record<string, unknown>];
expect(addPayload).toEqual({published: true}); expect(addPayload).toEqual({published: true});
}); });
}); });
@@ -1,31 +1,38 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs'; import {of} from 'rxjs';
import {vi} from 'vitest';
import {DbService} from '../../../services/db.service'; import {DbService} from '../../../services/db.service';
import {ShowSongDataService} from './show-song-data.service'; import {ShowSongDataService} from './show-song-data.service';
describe('ShowSongDataService', () => { describe('ShowSongDataService', () => {
let service: ShowSongDataService; let service: ShowSongDataService;
let docUpdateSpy: jasmine.Spy<() => Promise<void>>; let docUpdateSpy: ReturnType<typeof vi.fn>;
let docDeleteSpy: jasmine.Spy<() => Promise<void>>; let docDeleteSpy: ReturnType<typeof vi.fn>;
let docSpy: jasmine.Spy; let docSpy: ReturnType<typeof vi.fn>;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; let colAddSpy: ReturnType<typeof vi.fn>;
let colSpy: jasmine.Spy; let colSpy: ReturnType<typeof vi.fn>;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {
col$: ReturnType<typeof vi.fn>;
doc$: ReturnType<typeof vi.fn>;
doc: ReturnType<typeof vi.fn>;
col: ReturnType<typeof vi.fn>;
};
beforeEach(async () => { beforeEach(async () => {
docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); docUpdateSpy = vi.fn().mockResolvedValue(undefined);
docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); docDeleteSpy = vi.fn().mockResolvedValue(undefined);
docSpy = jasmine.createSpy('doc').and.returnValue({ docSpy = vi.fn().mockReturnValue({
update: docUpdateSpy, update: docUpdateSpy,
delete: docDeleteSpy, delete: docDeleteSpy,
}); });
colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'show-song-3'}); colAddSpy = vi.fn().mockResolvedValue({id: 'show-song-3'});
colSpy = jasmine.createSpy('col').and.returnValue({add: colAddSpy}); colSpy = vi.fn().mockReturnValue({add: colAddSpy});
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['col$', 'doc$', 'doc', 'col']); dbServiceSpy = {
dbServiceSpy.col$.and.callFake(() => of([{id: 'show-song-1'}]) as never); col$: vi.fn().mockImplementation(() => of([{id: 'show-song-1'}]) as never),
dbServiceSpy.doc$.and.returnValue(of({id: 'show-song-1'}) as never); doc$: vi.fn().mockReturnValue(of({id: 'show-song-1'}) as never),
dbServiceSpy.doc.and.callFake(docSpy); doc: docSpy,
dbServiceSpy.col.and.callFake(colSpy); col: colSpy,
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}], providers: [{provide: DbService, useValue: dbServiceSpy}],
@@ -77,7 +84,7 @@ describe('ShowSongDataService', () => {
await service.update$('show-4', 'song-5', {title: 'Updated'} as never); await service.update$('show-4', 'song-5', {title: 'Updated'} as never);
expect(docSpy).toHaveBeenCalledWith('shows/show-4/songs/song-5'); expect(docSpy).toHaveBeenCalledWith('shows/show-4/songs/song-5');
const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [updatePayload] = docUpdateSpy.mock.lastCall as [Record<string, unknown>];
expect(updatePayload).toEqual({title: 'Updated'}); expect(updatePayload).toEqual({title: 'Updated'});
}); });
@@ -89,10 +96,10 @@ describe('ShowSongDataService', () => {
}); });
it('should add a song to the nested show songs collection and return the id', async () => { it('should add a song to the nested show songs collection and return the id', async () => {
await expectAsync(service.add('show-4', {songId: 'song-5'} as never)).toBeResolvedTo('show-song-3'); await expect(service.add('show-4', {songId: 'song-5'} as never)).resolves.toBe('show-song-3');
expect(colSpy).toHaveBeenCalledWith('shows/show-4/songs'); expect(colSpy).toHaveBeenCalledWith('shows/show-4/songs');
const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [addPayload] = colAddSpy.mock.lastCall as [Record<string, unknown>];
expect(addPayload).toEqual({songId: 'song-5'}); expect(addPayload).toEqual({songId: 'song-5'});
}); });
}); });
@@ -1,5 +1,6 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs'; import {Observable, of} from 'rxjs';
import {vi} from 'vitest';
import {ShowDataService} from './show-data.service'; import {ShowDataService} from './show-data.service';
import {ShowSongDataService} from './show-song-data.service'; import {ShowSongDataService} from './show-song-data.service';
import {ShowSongIndexService} from './show-song-index.service'; import {ShowSongIndexService} from './show-song-index.service';
@@ -8,20 +9,34 @@ import {User} from '../../../services/user/user';
describe('ShowSongIndexService', () => { describe('ShowSongIndexService', () => {
let service: ShowSongIndexService; let service: ShowSongIndexService;
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>; let showDataServiceSpy: {
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>; listRaw$: ReturnType<typeof vi.fn>;
let sessionSpy: jasmine.SpyObj<UserSessionService>; update: ReturnType<typeof vi.fn>;
};
let showSongDataServiceSpy: {
list$: ReturnType<typeof vi.fn>;
};
let sessionSpy: {
update$: ReturnType<typeof vi.fn>;
user$: Observable<User>;
};
beforeEach(async () => { beforeEach(async () => {
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['listRaw$', 'update']); showDataServiceSpy = {
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['list$']); listRaw$: vi.fn(),
sessionSpy = jasmine.createSpyObj<UserSessionService>('UserSessionService', ['update$'], { update: vi.fn(),
};
showSongDataServiceSpy = {
list$: vi.fn(),
};
sessionSpy = {
update$: vi.fn(),
user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User), user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User),
}); };
showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType<ShowDataService['listRaw$']>); showDataServiceSpy.listRaw$.mockReturnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType<ShowDataService['listRaw$']>);
showDataServiceSpy.update.and.resolveTo(); showDataServiceSpy.update.mockResolvedValue(undefined);
showSongDataServiceSpy.list$.and.callFake((showId: string) => { showSongDataServiceSpy.list$.mockImplementation((showId: string) => {
if (showId === 'show-1') { if (showId === 'show-1') {
return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never; return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never;
} }
@@ -41,7 +56,7 @@ describe('ShowSongIndexService', () => {
}); });
it('should rebuild the distinct songIds index for all shows', async () => { it('should rebuild the distinct songIds index for all shows', async () => {
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({ await expect(service.rebuildShowSongIds()).resolves.toEqual({
showsProcessed: 2, showsProcessed: 2,
showSongsProcessed: 4, showSongsProcessed: 4,
}); });
@@ -55,6 +70,6 @@ describe('ShowSongIndexService', () => {
value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User), value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User),
}); });
await expectAsync(service.rebuildShowSongIds()).toBeRejectedWithError('Admin role required to rebuild show song ids.'); await expect(service.rebuildShowSongIds()).rejects.toThrow('Admin role required to rebuild show song ids.');
}); });
}); });
@@ -1,5 +1,6 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {BehaviorSubject, of} from 'rxjs'; import {BehaviorSubject, of} from 'rxjs';
import {vi} from 'vitest';
import {SongDataService} from '../../songs/services/song-data.service'; import {SongDataService} from '../../songs/services/song-data.service';
import {UserService} from '../../../services/user/user.service'; import {UserService} from '../../../services/user/user.service';
import {ShowService} from './show.service'; import {ShowService} from './show.service';
@@ -12,10 +13,25 @@ import {User} from '../../../services/user/user';
describe('ShowSongService', () => { describe('ShowSongService', () => {
let service: ShowSongService; let service: ShowSongService;
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>; let showSongDataServiceSpy: {
let songDataServiceSpy: jasmine.SpyObj<SongDataService>; add: ReturnType<typeof vi.fn>;
let userServiceSpy: jasmine.SpyObj<UserService>; read$: ReturnType<typeof vi.fn>;
let showServiceSpy: jasmine.SpyObj<ShowService>; list$: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
update$: ReturnType<typeof vi.fn>;
};
let songDataServiceSpy: {
read$: ReturnType<typeof vi.fn>;
};
let userServiceSpy: {
incSongCount: ReturnType<typeof vi.fn>;
decSongCount: ReturnType<typeof vi.fn>;
user$: ReturnType<BehaviorSubject<User | null>['asObservable']>;
};
let showServiceSpy: {
read$: ReturnType<typeof vi.fn>;
update$: ReturnType<typeof vi.fn>;
};
let user$: BehaviorSubject<User | null>; let user$: BehaviorSubject<User | null>;
const song = {id: 'song-1', key: 'G', title: 'Amazing Grace'} as unknown as Song; const song = {id: 'song-1', key: 'G', title: 'Amazing Grace'} as unknown as Song;
const showSong = {id: 'show-song-1', songId: 'song-1'} as unknown as ShowSong; const showSong = {id: 'show-song-1', songId: 'song-1'} as unknown as ShowSong;
@@ -23,23 +39,36 @@ describe('ShowSongService', () => {
beforeEach(async () => { beforeEach(async () => {
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}}); user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}});
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']); showSongDataServiceSpy = {
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']); add: vi.fn(),
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], { read$: vi.fn(),
list$: vi.fn(),
delete: vi.fn(),
update$: vi.fn(),
};
songDataServiceSpy = {
read$: vi.fn(),
};
userServiceSpy = {
incSongCount: vi.fn(),
decSongCount: vi.fn(),
user$: user$.asObservable(), user$: user$.asObservable(),
}); };
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['read$', 'update$']); showServiceSpy = {
read$: vi.fn(),
update$: vi.fn(),
};
showSongDataServiceSpy.add.and.resolveTo('show-song-2'); showSongDataServiceSpy.add.mockResolvedValue('show-song-2');
showSongDataServiceSpy.read$.and.returnValue(of(showSong)); showSongDataServiceSpy.read$.mockReturnValue(of(showSong));
showSongDataServiceSpy.list$.and.returnValue(of([showSong])); showSongDataServiceSpy.list$.mockReturnValue(of([showSong]));
showSongDataServiceSpy.delete.and.resolveTo(); showSongDataServiceSpy.delete.mockResolvedValue(undefined);
showSongDataServiceSpy.update$.and.resolveTo(); showSongDataServiceSpy.update$.mockResolvedValue(undefined);
songDataServiceSpy.read$.and.returnValue(of(song)); songDataServiceSpy.read$.mockReturnValue(of(song));
userServiceSpy.incSongCount.and.resolveTo(); userServiceSpy.incSongCount.mockResolvedValue(undefined);
userServiceSpy.decSongCount.and.resolveTo(); userServiceSpy.decSongCount.mockResolvedValue(undefined);
showServiceSpy.read$.and.returnValue(of(show)); showServiceSpy.read$.mockReturnValue(of(show));
showServiceSpy.update$.and.resolveTo(); showServiceSpy.update$.mockResolvedValue(undefined);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -58,7 +87,7 @@ describe('ShowSongService', () => {
}); });
it('should create a show song from the song and current user', async () => { it('should create a show song from the song and current user', async () => {
await expectAsync(service.new$('show-1', 'song-1', true)).toBeResolvedTo('show-song-2'); await expect(service.new$('show-1', 'song-1', true)).resolves.toBe('show-song-2');
expect(userServiceSpy.incSongCount).toHaveBeenCalledWith('song-1'); expect(userServiceSpy.incSongCount).toHaveBeenCalledWith('song-1');
expect(showSongDataServiceSpy.add).toHaveBeenCalledWith('show-1', { expect(showSongDataServiceSpy.add).toHaveBeenCalledWith('show-1', {
@@ -72,9 +101,9 @@ describe('ShowSongService', () => {
}); });
it('should return null when the song is missing', async () => { it('should return null when the song is missing', async () => {
songDataServiceSpy.read$.and.returnValue(of(null)); songDataServiceSpy.read$.mockReturnValue(of(null));
await expectAsync(service.new$('show-1', 'song-1')).toBeResolvedTo(null); await expect(service.new$('show-1', 'song-1')).resolves.toBe(null);
expect(showSongDataServiceSpy.add).not.toHaveBeenCalled(); expect(showSongDataServiceSpy.add).not.toHaveBeenCalled();
expect(userServiceSpy.incSongCount).not.toHaveBeenCalled(); expect(userServiceSpy.incSongCount).not.toHaveBeenCalled();
@@ -83,19 +112,19 @@ describe('ShowSongService', () => {
it('should return null when the current user is missing', async () => { it('should return null when the current user is missing', async () => {
user$.next(null); user$.next(null);
await expectAsync(service.new$('show-1', 'song-1')).toBeResolvedTo(null); await expect(service.new$('show-1', 'song-1')).resolves.toBe(null);
expect(showSongDataServiceSpy.add).not.toHaveBeenCalled(); expect(showSongDataServiceSpy.add).not.toHaveBeenCalled();
expect(userServiceSpy.incSongCount).not.toHaveBeenCalled(); expect(userServiceSpy.incSongCount).not.toHaveBeenCalled();
}); });
it('should delegate reads to the data service', async () => { it('should delegate reads to the data service', async () => {
await expectAsync(service.read('show-1', 'show-song-1')).toBeResolvedTo(showSong); await expect(service.read('show-1', 'show-song-1')).resolves.toBe(showSong);
expect(showSongDataServiceSpy.read$).toHaveBeenCalledWith('show-1', 'show-song-1'); expect(showSongDataServiceSpy.read$).toHaveBeenCalledWith('show-1', 'show-song-1');
}); });
it('should delegate list access to the data service', async () => { it('should delegate list access to the data service', async () => {
await expectAsync(service.list('show-1')).toBeResolvedTo([showSong]); await expect(service.list('show-1')).resolves.toEqual([showSong]);
expect(showSongDataServiceSpy.list$).toHaveBeenCalledWith('show-1'); expect(showSongDataServiceSpy.list$).toHaveBeenCalledWith('show-1');
}); });
@@ -103,12 +132,12 @@ describe('ShowSongService', () => {
await service.delete$('show-1', 'show-song-1', 0); await service.delete$('show-1', 'show-song-1', 0);
expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1'); expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1');
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', jasmine.objectContaining({order: ['show-song-2']})); expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', expect.objectContaining({order: ['show-song-2']}));
expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1'); expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1');
}); });
it('should stop delete when the show is missing', async () => { it('should stop delete when the show is missing', async () => {
showServiceSpy.read$.and.returnValue(of(null)); showServiceSpy.read$.mockReturnValue(of(null));
await service.delete$('show-1', 'show-song-1', 0); await service.delete$('show-1', 'show-song-1', 0);
@@ -118,7 +147,7 @@ describe('ShowSongService', () => {
}); });
it('should stop delete when the show song is missing', async () => { it('should stop delete when the show song is missing', async () => {
showSongDataServiceSpy.read$.and.returnValue(of(null)); showSongDataServiceSpy.read$.mockReturnValue(of(null));
await service.delete$('show-1', 'show-song-1', 0); await service.delete$('show-1', 'show-song-1', 0);
@@ -1,12 +1,19 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {vi} from 'vitest';
import {ShowDataService} from './show-data.service'; import {ShowDataService} from './show-data.service';
import {ShowService} from './show.service'; import {ShowService} from './show.service';
import {UserService} from '../../../services/user/user.service'; import {UserService} from '../../../services/user/user.service';
describe('ShowService', () => { describe('ShowService', () => {
let service: ShowService; let service: ShowService;
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>; let showDataServiceSpy: {
read$: ReturnType<typeof vi.fn>;
listPublicSince$: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
add: ReturnType<typeof vi.fn>;
list$: ReturnType<BehaviorSubject<unknown[]>['asObservable']>;
};
let user$: BehaviorSubject<unknown>; let user$: BehaviorSubject<unknown>;
let shows$: BehaviorSubject<unknown[]>; let shows$: BehaviorSubject<unknown[]>;
const shows = [ const shows = [
@@ -18,13 +25,17 @@ describe('ShowService', () => {
beforeEach(async () => { beforeEach(async () => {
user$ = new BehaviorSubject<unknown>({id: 'user-1'}); user$ = new BehaviorSubject<unknown>({id: 'user-1'});
shows$ = new BehaviorSubject<unknown[]>(shows as unknown[]); shows$ = new BehaviorSubject<unknown[]>(shows as unknown[]);
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], { showDataServiceSpy = {
read$: vi.fn(),
listPublicSince$: vi.fn(),
update: vi.fn(),
add: vi.fn(),
list$: shows$.asObservable() as unknown as ShowDataService['list$'], list$: shows$.asObservable() as unknown as ShowDataService['list$'],
}); };
showDataServiceSpy.read$.and.returnValue(of(shows[0])); showDataServiceSpy.read$.mockReturnValue(of(shows[0]));
showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]])); showDataServiceSpy.listPublicSince$.mockReturnValue(of([shows[1]]));
showDataServiceSpy.update.and.resolveTo(); showDataServiceSpy.update.mockResolvedValue(undefined);
showDataServiceSpy.add.and.resolveTo('new-show-id'); showDataServiceSpy.add.mockResolvedValue('new-show-id');
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -63,12 +74,12 @@ describe('ShowService', () => {
}); });
it('should delegate public listing to the data service', async () => { it('should delegate public listing to the data service', async () => {
await expectAsync(firstValueFrom(service.listPublicSince$(6))).toBeResolvedTo([shows[1]]); await expect(firstValueFrom(service.listPublicSince$(6))).resolves.toEqual([shows[1]]);
expect(showDataServiceSpy.listPublicSince$).toHaveBeenCalledWith(6); expect(showDataServiceSpy.listPublicSince$).toHaveBeenCalledWith(6);
}); });
it('should delegate reads to the data service', async () => { it('should delegate reads to the data service', async () => {
await expectAsync(firstValueFrom(service.read$('show-1'))).toBeResolvedTo(shows[0]); await expect(firstValueFrom(service.read$('show-1'))).resolves.toBe(shows[0]);
expect(showDataServiceSpy.read$).toHaveBeenCalledWith('show-1'); expect(showDataServiceSpy.read$).toHaveBeenCalledWith('show-1');
}); });
@@ -79,7 +90,7 @@ describe('ShowService', () => {
}); });
it('should return null when creating a show without showType', async () => { it('should return null when creating a show without showType', async () => {
await expectAsync(service.new$({published: true})).toBeResolvedTo(null); await expect(service.new$({published: true})).resolves.toBe(null);
expect(showDataServiceSpy.add).not.toHaveBeenCalled(); expect(showDataServiceSpy.add).not.toHaveBeenCalled();
}); });
@@ -87,7 +98,7 @@ describe('ShowService', () => {
it('should return null when no user is available for show creation', async () => { it('should return null when no user is available for show creation', async () => {
user$.next(null); user$.next(null);
await expectAsync(service.new$({showType: 'misc-public'})).toBeResolvedTo(null); await expect(service.new$({showType: 'misc-public'})).resolves.toBe(null);
expect(showDataServiceSpy.add).not.toHaveBeenCalled(); expect(showDataServiceSpy.add).not.toHaveBeenCalled();
}); });
@@ -1,5 +1,6 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BehaviorSubject, of} from 'rxjs'; import {BehaviorSubject, of} from 'rxjs';
import {vi} from 'vitest';
import {ShowComponent} from './show.component'; import {ShowComponent} from './show.component';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {ShowService} from '../services/show.service'; import {ShowService} from '../services/show.service';
@@ -13,23 +14,39 @@ import {GuestShowService} from '../../guest/guest-show.service';
describe('ShowComponent', () => { describe('ShowComponent', () => {
let component: ShowComponent; let component: ShowComponent;
let fixture: ComponentFixture<ShowComponent>; let fixture: ComponentFixture<ShowComponent>;
let showServiceSpy: jasmine.SpyObj<ShowService>; let showServiceSpy: {
let showSongServiceSpy: jasmine.SpyObj<ShowSongService>; update$: ReturnType<typeof vi.fn>;
let dialogSpy: jasmine.SpyObj<MatDialog>; read$: ReturnType<typeof vi.fn>;
};
let showSongServiceSpy: {
list$: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
};
let dialogSpy: {
open: ReturnType<typeof vi.fn>;
};
let user$: BehaviorSubject<unknown>; let user$: BehaviorSubject<unknown>;
let userId$: BehaviorSubject<string | null>; let userId$: BehaviorSubject<string | null>;
beforeEach(async () => { beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['update$', 'read$']); showServiceSpy = {
showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('ShowSongService', ['list$', 'list']); update$: vi.fn(),
dialogSpy = jasmine.createSpyObj<MatDialog>('MatDialog', ['open']); read$: vi.fn(),
};
showSongServiceSpy = {
list$: vi.fn(),
list: vi.fn(),
};
dialogSpy = {
open: vi.fn(),
};
user$ = new BehaviorSubject<unknown>({id: 'user-1', role: ['leader']}); user$ = new BehaviorSubject<unknown>({id: 'user-1', role: ['leader']});
userId$ = new BehaviorSubject<string | null>('user-1'); userId$ = new BehaviorSubject<string | null>('user-1');
showServiceSpy.read$.and.returnValue(of(null)); showServiceSpy.read$.mockReturnValue(of(null));
showServiceSpy.update$.and.resolveTo(); showServiceSpy.update$.mockResolvedValue(undefined);
showSongServiceSpy.list$.and.returnValue(of([])); showSongServiceSpy.list$.mockReturnValue(of([]));
showSongServiceSpy.list.and.resolveTo([]); showSongServiceSpy.list.mockResolvedValue([]);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ShowComponent], imports: [ShowComponent],
@@ -38,8 +55,8 @@ describe('ShowComponent', () => {
{provide: ShowService, useValue: showServiceSpy}, {provide: ShowService, useValue: showServiceSpy},
{provide: SongService, useValue: {list$: () => of([])}}, {provide: SongService, useValue: {list$: () => of([])}},
{provide: ShowSongService, useValue: showSongServiceSpy}, {provide: ShowSongService, useValue: showSongServiceSpy},
{provide: DocxService, useValue: {create: jasmine.createSpy('create').and.resolveTo()}}, {provide: DocxService, useValue: {create: vi.fn().mockResolvedValue(undefined)}},
{provide: Router, useValue: {navigateByUrl: jasmine.createSpy('navigateByUrl')}}, {provide: Router, useValue: {navigateByUrl: vi.fn()}},
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
@@ -49,7 +66,7 @@ describe('ShowComponent', () => {
}, },
}, },
{provide: MatDialog, useValue: dialogSpy}, {provide: MatDialog, useValue: dialogSpy},
{provide: GuestShowService, useValue: {share: jasmine.createSpy('share').and.resolveTo('https://example.invalid')}}, {provide: GuestShowService, useValue: {share: vi.fn().mockResolvedValue('https://example.invalid')}},
], ],
}).compileComponents(); }).compileComponents();
@@ -74,7 +91,7 @@ describe('ShowComponent', () => {
}); });
it('should set pending for public shows with reportable CCLI songs', async () => { it('should set pending for public shows with reportable CCLI songs', async () => {
showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: '123'}] as never); showSongServiceSpy.list.mockResolvedValue([{legalOwner: 'CCLI', legalOwnerId: '123'}] as never);
await component.onPublish({id: 'show-1', public: true} as never, true); await component.onPublish({id: 'show-1', public: true} as never, true);
@@ -82,7 +99,7 @@ describe('ShowComponent', () => {
}); });
it('should set not-required for public shows without reportable CCLI songs', async () => { it('should set not-required for public shows without reportable CCLI songs', async () => {
showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: ''}] as never); showSongServiceSpy.list.mockResolvedValue([{legalOwner: 'CCLI', legalOwnerId: ''}] as never);
await component.onPublish({id: 'show-1', public: true} as never, true); await component.onPublish({id: 'show-1', public: true} as never, true);
@@ -96,11 +113,11 @@ describe('ShowComponent', () => {
{id: 'show-song-3', songId: 'song-2', title: 'Beta', legalOwner: 'other', legalOwnerId: '456'}, {id: 'show-song-3', songId: 'song-2', title: 'Beta', legalOwner: 'other', legalOwnerId: '456'},
{id: 'show-song-4', songId: 'song-3', title: 'Gamma', legalOwner: 'CCLI', legalOwnerId: '789'}, {id: 'show-song-4', songId: 'song-3', title: 'Gamma', legalOwner: 'CCLI', legalOwnerId: '789'},
] as never; ] as never;
dialogSpy.open.and.returnValue({afterClosed: () => of(true)} as never); dialogSpy.open.mockReturnValue({afterClosed: () => of(true)} as never);
component.onReport({id: 'show-1', order: ['show-song-1', 'show-song-2', 'show-song-3', 'show-song-4']} as never); component.onReport({id: 'show-1', order: ['show-song-1', 'show-song-2', 'show-song-3', 'show-song-4']} as never);
expect(dialogSpy.open).toHaveBeenCalledWith(jasmine.any(Function), { expect(dialogSpy.open).toHaveBeenCalledWith(expect.any(Function), {
width: '640px', width: '640px',
data: { data: {
songs: [ songs: [
@@ -5,30 +5,29 @@ import {FileDataService} from './file-data.service';
describe('FileDataService', () => { describe('FileDataService', () => {
let service: FileDataService; let service: FileDataService;
let filesCollectionValueChangesSpy: jasmine.Spy; let filesCollectionValueChangesSpy: ReturnType<typeof vi.fn<() => unknown>>;
let filesCollectionAddSpy: jasmine.Spy; let filesCollectionAddSpy: ReturnType<typeof vi.fn<() => Promise<{id: string}>>>;
let songDocCollectionSpy: jasmine.Spy; let songDocCollectionSpy: ReturnType<typeof vi.fn<() => {add: typeof filesCollectionAddSpy; valueChanges: typeof filesCollectionValueChangesSpy}>>;
let songDocSpy: jasmine.Spy; let songDocSpy: ReturnType<typeof vi.fn<(path: string) => unknown>>;
let fileDeleteSpy: jasmine.Spy; let fileDeleteSpy: ReturnType<typeof vi.fn<() => Promise<void>>>;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {doc: typeof songDocSpy};
beforeEach(async () => { beforeEach(async () => {
filesCollectionValueChangesSpy = jasmine.createSpy('valueChanges').and.returnValue(of([{id: 'file-1', name: 'plan.pdf'}])); filesCollectionValueChangesSpy = vi.fn().mockReturnValue(of([{id: 'file-1', name: 'plan.pdf'}]));
filesCollectionAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'file-2'}); filesCollectionAddSpy = vi.fn().mockResolvedValue({id: 'file-2'});
songDocCollectionSpy = jasmine.createSpy('collection').and.returnValue({ songDocCollectionSpy = vi.fn().mockReturnValue({
add: filesCollectionAddSpy, add: filesCollectionAddSpy,
valueChanges: filesCollectionValueChangesSpy, valueChanges: filesCollectionValueChangesSpy,
}); });
songDocSpy = jasmine.createSpy('songDoc').and.callFake((path: string) => { songDocSpy = vi.fn().mockImplementation((path: string) => {
if (path.includes('/files/')) { if (path.includes('/files/')) {
return {delete: fileDeleteSpy}; return {delete: fileDeleteSpy};
} }
return {collection: songDocCollectionSpy}; return {collection: songDocCollectionSpy};
}); });
fileDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); fileDeleteSpy = vi.fn().mockResolvedValue(undefined);
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['doc']); dbServiceSpy = {doc: songDocSpy};
dbServiceSpy.doc.and.callFake(songDocSpy);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}], providers: [{provide: DbService, useValue: dbServiceSpy}],
@@ -44,7 +43,7 @@ describe('FileDataService', () => {
it('should add files to the song files subcollection and return the generated id', async () => { it('should add files to the song files subcollection and return the generated id', async () => {
const file = {name: 'setlist.pdf', path: 'songs/song-1/files', createdAt: new Date()}; const file = {name: 'setlist.pdf', path: 'songs/song-1/files', createdAt: new Date()};
await expectAsync(service.set('song-1', file)).toBeResolvedTo('file-2'); await expect(service.set('song-1', file)).resolves.toEqual('file-2');
expect(songDocSpy).toHaveBeenCalledWith('songs/song-1'); expect(songDocSpy).toHaveBeenCalledWith('songs/song-1');
expect(songDocCollectionSpy).toHaveBeenCalledWith('files'); expect(songDocCollectionSpy).toHaveBeenCalledWith('files');
@@ -5,18 +5,16 @@ import {FileService} from './file.service';
describe('FileService', () => { describe('FileService', () => {
let service: FileService; let service: FileService;
let fileDataServiceSpy: jasmine.SpyObj<FileDataService>; let fileDataServiceSpy: {delete: ReturnType<typeof vi.fn>};
type FileServiceInternals = FileService & {
resolveDownloadUrl: (path: string) => Promise<string>;
deleteFromStorage: (path: string) => Promise<void>;
};
beforeEach(async () => { beforeEach(async () => {
fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['delete']); fileDataServiceSpy = {
fileDataServiceSpy.delete.and.resolveTo(); delete: vi.fn().mockResolvedValue(undefined),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
FileService,
{provide: Storage, useValue: {app: 'test-storage'}}, {provide: Storage, useValue: {app: 'test-storage'}},
{provide: FileDataService, useValue: fileDataServiceSpy}, {provide: FileDataService, useValue: fileDataServiceSpy},
], ],
@@ -25,20 +23,26 @@ describe('FileService', () => {
service = TestBed.inject(FileService); service = TestBed.inject(FileService);
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it('should be created', () => { it('should be created', () => {
expect(service).toBeTruthy(); expect(service).toBeTruthy();
}); });
it('should resolve download urls via AngularFire storage helpers', async () => { it('should resolve download urls via AngularFire storage helpers', async () => {
const resolveSpy = spyOn(service as FileServiceInternals, 'resolveDownloadUrl').and.resolveTo('https://cdn.example/file.pdf'); const resolveSpy = vi.fn<(path: string) => Promise<string>>().mockResolvedValue('https://cdn.example/file.pdf');
Object.assign(service, {resolveDownloadUrl: resolveSpy});
await expectAsync(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).toBeResolvedTo('https://cdn.example/file.pdf'); await expect(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).resolves.toEqual('https://cdn.example/file.pdf');
expect(resolveSpy).toHaveBeenCalledWith('songs/song-1/file.pdf'); expect(resolveSpy).toHaveBeenCalledWith('songs/song-1/file.pdf');
}); });
it('should delete the file from storage and metadata from firestore', async () => { it('should delete the file from storage and metadata from firestore', async () => {
const deleteFromStorageSpy = spyOn(service as FileServiceInternals, 'deleteFromStorage').and.resolveTo(); const deleteFromStorageSpy = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined);
Object.assign(service, {deleteFromStorage: deleteFromStorageSpy});
await service.delete('songs/song-1/file.pdf', 'song-1', 'file-1'); await service.delete('songs/song-1/file.pdf', 'song-1', 'file-1');
@@ -52,7 +52,7 @@ describe('key.helper', () => {
}); });
it('should detect whether a root has any available stored key', () => { it('should detect whether a root has any available stored key', () => {
void expect(hasAvailableKeyForRoot('C', ['a'])).toBe(true); void expect(hasAvailableKeyForRoot('C', ['C'])).toBe(true);
void expect(hasAvailableKeyForRoot('E', ['F'])).toBe(true); void expect(hasAvailableKeyForRoot('E', ['F'])).toBe(true);
void expect(hasAvailableKeyForRoot('H', ['C'])).toBe(true); void expect(hasAvailableKeyForRoot('H', ['C'])).toBe(true);
void expect(hasAvailableKeyForRoot('D', ['F', 'a'])).toBe(false); void expect(hasAvailableKeyForRoot('D', ['F', 'a'])).toBe(false);
@@ -7,30 +7,36 @@ import {SongDataService} from './song-data.service';
describe('SongDataService', () => { describe('SongDataService', () => {
let service: SongDataService; let service: SongDataService;
let songs$: Subject<Array<{id: string; title: string}>>; let songs$: Subject<Array<{id: string; title: string}>>;
let docUpdateSpy: jasmine.Spy<() => Promise<void>>; let docUpdateSpy: ReturnType<typeof vi.fn<() => Promise<void>>>;
let docDeleteSpy: jasmine.Spy<() => Promise<void>>; let docDeleteSpy: ReturnType<typeof vi.fn<() => Promise<void>>>;
let docSpy: jasmine.Spy; let docSpy: ReturnType<typeof vi.fn<(path: string) => {update: typeof docUpdateSpy; delete: typeof docDeleteSpy}>>;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; let colAddSpy: ReturnType<typeof vi.fn<() => Promise<{id: string}>>>;
let colSpy: jasmine.Spy; let colSpy: ReturnType<typeof vi.fn<(path: string) => {add: typeof colAddSpy}>>;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {
col$: ReturnType<typeof vi.fn>;
doc$: ReturnType<typeof vi.fn>;
doc: typeof docSpy;
col: typeof colSpy;
};
beforeEach(async () => { beforeEach(async () => {
songs$ = new Subject<Array<{id: string; title: string}>>(); songs$ = new Subject<Array<{id: string; title: string}>>();
docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); docUpdateSpy = vi.fn().mockResolvedValue(undefined);
docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); docDeleteSpy = vi.fn().mockResolvedValue(undefined);
docSpy = jasmine.createSpy('doc').and.callFake(() => ({ docSpy = vi.fn().mockImplementation(() => ({
update: docUpdateSpy, update: docUpdateSpy,
delete: docDeleteSpy, delete: docDeleteSpy,
})); }));
colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'song-3'}); colAddSpy = vi.fn().mockResolvedValue({id: 'song-3'});
colSpy = jasmine.createSpy('col').and.returnValue({ colSpy = vi.fn().mockReturnValue({
add: colAddSpy, add: colAddSpy,
}); });
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['col$', 'doc$', 'doc', 'col']); dbServiceSpy = {
dbServiceSpy.col$.and.returnValue(songs$.asObservable()); col$: vi.fn().mockReturnValue(songs$.asObservable()),
dbServiceSpy.doc$.and.returnValue(songs$.asObservable() as never); doc$: vi.fn().mockReturnValue(songs$.asObservable() as never),
dbServiceSpy.doc.and.callFake(docSpy); doc: docSpy,
dbServiceSpy.col.and.callFake(colSpy); col: colSpy,
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}], providers: [{provide: DbService, useValue: dbServiceSpy}],
@@ -83,15 +89,15 @@ describe('SongDataService', () => {
await service.update$('song-8', {title: 'Updated'}); await service.update$('song-8', {title: 'Updated'});
expect(docSpy).toHaveBeenCalledWith('songs/song-8'); expect(docSpy).toHaveBeenCalledWith('songs/song-8');
const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [updatePayload] = docUpdateSpy.mock.calls.at(-1) as unknown as [Record<string, unknown>];
expect(updatePayload).toEqual({title: 'Updated'}); expect(updatePayload).toEqual({title: 'Updated'});
}); });
it('should add a song to the songs collection and return the new id', async () => { it('should add a song to the songs collection and return the new id', async () => {
await expectAsync(service.add({title: 'New Song'})).toBeResolvedTo('song-3'); await expect(service.add({title: 'New Song'})).resolves.toEqual('song-3');
expect(colSpy).toHaveBeenCalledWith('songs'); expect(colSpy).toHaveBeenCalledWith('songs');
const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record<string, unknown>]; const [addPayload] = colAddSpy.mock.calls.at(-1) as unknown as [Record<string, unknown>];
expect(addPayload).toEqual({title: 'New Song'}); expect(addPayload).toEqual({title: 'New Song'});
}); });
@@ -0,0 +1,76 @@
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {UserSessionService} from '../../../services/user/user-session.service';
import {User} from '../../../services/user/user';
import {SongDataService} from './song-data.service';
import {SongDownloadService} from './song-download.service';
describe('SongDownloadService', () => {
let service: SongDownloadService;
let songDataServiceSpy: {listLoaded$: ReturnType<typeof vi.fn>};
let sessionSpy: {update$: ReturnType<typeof vi.fn>; user$: unknown};
let createObjectUrlSpy: ReturnType<typeof vi.spyOn>;
let revokeObjectUrlSpy: ReturnType<typeof vi.spyOn>;
let clickSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
songDataServiceSpy = {
listLoaded$: vi.fn(),
};
sessionSpy = {
update$: vi.fn(),
user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User),
};
songDataServiceSpy.listLoaded$.mockReturnValue(
of([
{id: 'song-2', number: 2, title: 'Zweiter Song'},
{id: 'song-1', number: 1, title: 'Erster Song'},
] as never)
);
createObjectUrlSpy = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:songs');
revokeObjectUrlSpy = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => undefined);
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
await TestBed.configureTestingModule({
providers: [
{provide: SongDataService, useValue: songDataServiceSpy},
{provide: UserSessionService, useValue: sessionSpy},
],
});
service = TestBed.inject(SongDownloadService);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should download all songs as sorted JSON', async () => {
vi.useFakeTimers();
const result = await service.downloadSongs();
const [blob, fileName] = createObjectUrlSpy.mock.calls.at(-1) as unknown as [Blob, string];
expect(blob.type).toBe('application/json');
expect(fileName).toBeUndefined();
expect(clickSpy).toHaveBeenCalled();
expect(result?.songsDownloaded).toBe(2);
expect(result?.fileName).toMatch(/^songs-\d{4}-\d{2}-\d{2}\.json$/);
const text = await blob.text();
expect(JSON.parse(text).map((song: {id: string}) => song.id)).toEqual(['song-1', 'song-2']);
await vi.advanceTimersByTimeAsync(1000);
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:songs');
});
it('should reject downloads for non-admin users', async () => {
Object.defineProperty(sessionSpy, 'user$', {
value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User),
});
await expect(service.downloadSongs()).rejects.toThrow('Admin role required to download songs.');
});
});
@@ -0,0 +1,77 @@
import {Injectable, inject} from '@angular/core';
import {firstValueFrom} from 'rxjs';
import {take} from 'rxjs/operators';
import {UserSessionService} from '../../../services/user/user-session.service';
import {SongDataService} from './song-data.service';
export interface SongDownloadResult {
fileName: string;
songsDownloaded: number;
}
@Injectable({
providedIn: 'root',
})
export class SongDownloadService {
private session = inject(UserSessionService);
private songDataService = inject(SongDataService);
public async downloadSongs(): Promise<SongDownloadResult> {
const currentUser = await firstValueFrom(this.session.user$.pipe(take(1)));
if (!currentUser || !this.hasAdminRole(currentUser.role)) {
throw new Error('Admin role required to download songs.');
}
const songs = await firstValueFrom(this.songDataService.listLoaded$());
const sortedSongs = [...songs].sort((left, right) => {
const numberCompare = (left.number ?? Number.MAX_SAFE_INTEGER) - (right.number ?? Number.MAX_SAFE_INTEGER);
if (numberCompare !== 0) {
return numberCompare;
}
return (left.title ?? '').localeCompare(right.title ?? '', 'de');
});
const fileName = `songs-${this.formatDate(new Date())}.json`;
this.saveAs(new Blob([JSON.stringify(sortedSongs, null, 2)], {type: 'application/json'}), fileName);
return {
fileName,
songsDownloaded: sortedSongs.length,
};
}
private saveAs(blob: Blob, fileName: string) {
const a = document.createElement('a');
document.body.appendChild(a);
a.setAttribute('target', '_self');
a.style.display = 'none';
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}, 1000);
}
private formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private hasAdminRole(role: string | null | undefined): boolean {
if (!role) {
return false;
}
return role.split(';').includes('admin');
}
}
@@ -5,11 +5,12 @@ import {SongListResolver} from './song-list.resolver';
describe('SongListResolver', () => { describe('SongListResolver', () => {
let resolver: SongListResolver; let resolver: SongListResolver;
let songServiceSpy: jasmine.SpyObj<SongService>; let songServiceSpy: {listLoaded$: ReturnType<typeof vi.fn>};
beforeEach(async () => { beforeEach(async () => {
songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['listLoaded$']); songServiceSpy = {
songServiceSpy.listLoaded$.and.returnValue(of([{id: 'song-1', title: 'Amazing Grace'}]) as never); listLoaded$: vi.fn().mockReturnValue(of([{id: 'song-1', title: 'Amazing Grace'}]) as never),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: SongService, useValue: songServiceSpy}], providers: [{provide: SongService, useValue: songServiceSpy}],
@@ -23,7 +24,7 @@ describe('SongListResolver', () => {
}); });
it('should resolve the first emitted song list from the service', async () => { it('should resolve the first emitted song list from the service', async () => {
await expectAsync(firstValueFrom(resolver.resolve())).toBeResolvedTo([{id: 'song-1', title: 'Amazing Grace'}] as never); await expect(firstValueFrom(resolver.resolve())).resolves.toEqual([{id: 'song-1', title: 'Amazing Grace'}] as never);
expect(songServiceSpy.listLoaded$).toHaveBeenCalled(); expect(songServiceSpy.listLoaded$).toHaveBeenCalled();
}); });
}); });
@@ -7,8 +7,14 @@ import {Timestamp} from '@angular/fire/firestore';
describe('SongService', () => { describe('SongService', () => {
let service: SongService; let service: SongService;
let songDataServiceSpy: jasmine.SpyObj<SongDataService>; let songDataServiceSpy: {
let userServiceSpy: jasmine.SpyObj<UserService>; read$: ReturnType<typeof vi.fn>;
update$: ReturnType<typeof vi.fn>;
add: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
list$: unknown;
};
let userServiceSpy: {currentUser: ReturnType<typeof vi.fn>};
const song = { const song = {
id: 'song-1', id: 'song-1',
title: 'Amazing Grace', title: 'Amazing Grace',
@@ -16,16 +22,22 @@ describe('SongService', () => {
} as never; } as never;
beforeEach(async () => { beforeEach(async () => {
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$', 'update$', 'add', 'delete'], { songDataServiceSpy = {
read$: vi.fn(),
update$: vi.fn(),
add: vi.fn(),
delete: vi.fn(),
list$: of([song]), list$: of([song]),
}); };
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['currentUser']); userServiceSpy = {
currentUser: vi.fn(),
};
songDataServiceSpy.read$.and.returnValue(of(song)); songDataServiceSpy.read$.mockReturnValue(of(song));
songDataServiceSpy.update$.and.resolveTo(); songDataServiceSpy.update$.mockResolvedValue(undefined);
songDataServiceSpy.add.and.resolveTo('song-2'); songDataServiceSpy.add.mockResolvedValue('song-2');
songDataServiceSpy.delete.and.resolveTo(); songDataServiceSpy.delete.mockResolvedValue(undefined);
userServiceSpy.currentUser.and.resolveTo({name: 'Benjamin'} as never); userServiceSpy.currentUser.mockResolvedValue({name: 'Benjamin'} as never);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -37,33 +49,37 @@ describe('SongService', () => {
service = TestBed.inject(SongService); service = TestBed.inject(SongService);
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it('should be created', () => { it('should be created', () => {
expect(service).toBeTruthy(); expect(service).toBeTruthy();
}); });
it('should list songs from the data service', async () => { it('should list songs from the data service', async () => {
await expectAsync(firstValueFrom(service.list$())).toBeResolvedTo([song]); await expect(firstValueFrom(service.list$())).resolves.toEqual([song]);
}); });
it('should delegate reads to the data service', async () => { it('should delegate reads to the data service', async () => {
await expectAsync(service.read('song-1')).toBeResolvedTo(song); await expect(service.read('song-1')).resolves.toEqual(song);
expect(songDataServiceSpy.read$).toHaveBeenCalledWith('song-1'); expect(songDataServiceSpy.read$).toHaveBeenCalledWith('song-1');
}); });
it('should append an edit with the current user when updating a song', async () => { it('should append an edit with the current user when updating a song', async () => {
const timestamp = {seconds: 1} as never; const timestamp = {seconds: 1} as never;
spyOn(Timestamp, 'now').and.returnValue(timestamp); vi.spyOn(Timestamp, 'now').mockReturnValue(timestamp);
await service.update$('song-1', {title: 'Updated'}); await service.update$('song-1', {title: 'Updated'});
expect(songDataServiceSpy.update$).toHaveBeenCalled(); expect(songDataServiceSpy.update$).toHaveBeenCalled();
const [, payload] = songDataServiceSpy.update$.calls.mostRecent().args as unknown as [string, Record<string, unknown>]; const [, payload] = songDataServiceSpy.update$.mock.calls.at(-1) as unknown as [string, Record<string, unknown>];
expect(payload.title).toBe('Updated'); expect(payload.title).toBe('Updated');
expect(payload.edits).toEqual([{username: 'Benjamin', timestamp}]); expect(payload.edits).toEqual([{username: 'Benjamin', timestamp}]);
}); });
it('should not update when the song does not exist', async () => { it('should not update when the song does not exist', async () => {
songDataServiceSpy.read$.and.returnValue(of(null)); songDataServiceSpy.read$.mockReturnValue(of(null));
await service.update$('missing-song', {title: 'Updated'}); await service.update$('missing-song', {title: 'Updated'});
@@ -71,7 +87,7 @@ describe('SongService', () => {
}); });
it('should not update when no current user is available', async () => { it('should not update when no current user is available', async () => {
userServiceSpy.currentUser.and.resolveTo(null); userServiceSpy.currentUser.mockResolvedValue(null);
await service.update$('song-1', {title: 'Updated'}); await service.update$('song-1', {title: 'Updated'});
@@ -79,7 +95,7 @@ describe('SongService', () => {
}); });
it('should create new songs with the expected defaults', async () => { it('should create new songs with the expected defaults', async () => {
await expectAsync(service.new(42, 'New Song')).toBeResolvedTo('song-2'); await expect(service.new(42, 'New Song')).resolves.toEqual('song-2');
expect(songDataServiceSpy.add).toHaveBeenCalledWith({ expect(songDataServiceSpy.add).toHaveBeenCalledWith({
number: 42, number: 42,
@@ -7,7 +7,7 @@ import {ChordAddDescriptor} from './chord';
describe('TextRenderingService', () => { describe('TextRenderingService', () => {
const descriptor = (raw: string, partial: Partial<ChordAddDescriptor>) => const descriptor = (raw: string, partial: Partial<ChordAddDescriptor>) =>
jasmine.objectContaining({ expect.objectContaining({
raw, raw,
quality: null, quality: null,
extensions: [], extensions: [],
@@ -198,12 +198,12 @@ Text`;
void expect(sections[2].lines[0].text).toBe('c c# db c7 cmaj7 c/e'); void expect(sections[2].lines[0].text).toBe('c c# db c7 cmaj7 c/e');
void expect(sections[2].lines[0].chords).toEqual([ void expect(sections[2].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}), expect.objectContaining({chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}),
jasmine.objectContaining({chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), expect.objectContaining({chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
jasmine.objectContaining({chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null}), expect.objectContaining({chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null}),
]); ]);
}); });
@@ -228,9 +228,9 @@ Text`;
const sections = service.parse(text, null); const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null}), expect.objectContaining({chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null}),
jasmine.objectContaining({chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})}), expect.objectContaining({chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})}),
]); ]);
}); });
@@ -244,11 +244,11 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), expect.objectContaining({chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
jasmine.objectContaining({chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})}), expect.objectContaining({chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})}),
jasmine.objectContaining({chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})}), expect.objectContaining({chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})}),
jasmine.objectContaining({chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})}), expect.objectContaining({chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})}),
jasmine.objectContaining({chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), expect.objectContaining({chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
]); ]);
}); });
@@ -262,10 +262,10 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}), expect.objectContaining({chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}),
jasmine.objectContaining({chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})}), expect.objectContaining({chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})}),
jasmine.objectContaining({chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})}), expect.objectContaining({chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})}),
jasmine.objectContaining({chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})}), expect.objectContaining({chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})}),
]); ]);
}); });
@@ -278,12 +278,12 @@ Text`;
const sections = service.parse(text, null); const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}), expect.objectContaining({chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}),
jasmine.objectContaining({chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})}), expect.objectContaining({chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})}),
jasmine.objectContaining({chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})}), expect.objectContaining({chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})}),
jasmine.objectContaining({chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})}), expect.objectContaining({chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})}),
jasmine.objectContaining({chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})}), expect.objectContaining({chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})}),
jasmine.objectContaining({chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})}), expect.objectContaining({chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})}),
]); ]);
}); });
@@ -296,9 +296,9 @@ Text`;
const sections = service.parse(text, null); const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}), expect.objectContaining({chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}),
jasmine.objectContaining({chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null}), expect.objectContaining({chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null}),
jasmine.objectContaining({chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), expect.objectContaining({chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
]); ]);
}); });
@@ -333,7 +333,7 @@ Text`;
void expect(sections[0].lines[0].text).toBe('Cmaj7(add9)'); void expect(sections[0].lines[0].text).toBe('Cmaj7(add9)');
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({ expect.objectContaining({
chord: 'C', chord: 'C',
length: 11, length: 11,
position: 0, position: 0,
@@ -362,13 +362,13 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C F G e C (F G)'); void expect(sections[0].lines[0].text).toBe('C F G e C (F G)');
void expect(sections[0].lines[0].chords).toEqual([ void expect(sections[0].lines[0].chords).toEqual([
jasmine.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null}), expect.objectContaining({chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('}), expect.objectContaining({chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('}),
jasmine.objectContaining({chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'}), expect.objectContaining({chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'}),
]); ]);
}); });
@@ -389,8 +389,8 @@ Text`;
it('should call the transpose service when a transpose mode is provided', () => { it('should call the transpose service when a transpose mode is provided', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService); const service: TextRenderingService = TestBed.inject(TextRenderingService);
const transposeService = TestBed.inject(TransposeService); const transposeService = TestBed.inject(TransposeService);
const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough(); const transposeSpy = vi.spyOn(transposeService, 'transpose');
const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough(); const renderSpy = vi.spyOn(transposeService, 'renderChords');
const text = `Strophe const text = `Strophe
C D E C D E
Text`; Text`;
@@ -404,8 +404,8 @@ Text`;
it('should use renderChords when no transpose mode is provided', () => { it('should use renderChords when no transpose mode is provided', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService); const service: TextRenderingService = TestBed.inject(TextRenderingService);
const transposeService = TestBed.inject(TransposeService); const transposeService = TestBed.inject(TransposeService);
const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough(); const transposeSpy = vi.spyOn(transposeService, 'transpose');
const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough(); const renderSpy = vi.spyOn(transposeService, 'renderChords');
const text = `Strophe const text = `Strophe
C D E C D E
Text`; Text`;
@@ -434,9 +434,9 @@ Am Dm7 cdur C
Text`; Text`;
void expect(service.validateChordNotation(text)).toEqual([ void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Am', suggestion: 'a', reason: 'minor_format'}), expect.objectContaining({lineNumber: 2, token: 'Am', suggestion: 'a', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Dm7', suggestion: 'd7', reason: 'minor_format'}), expect.objectContaining({lineNumber: 2, token: 'Dm7', suggestion: 'd7', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'cdur', suggestion: 'C', reason: 'major_format'}), expect.objectContaining({lineNumber: 2, token: 'cdur', suggestion: 'C', reason: 'major_format'}),
]); ]);
}); });
@@ -447,8 +447,8 @@ Am/C# Dm7/F#
Text`; Text`;
void expect(service.validateChordNotation(text)).toEqual([ void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Am/C#', suggestion: 'a/C#', reason: 'minor_format'}), expect.objectContaining({lineNumber: 2, token: 'Am/C#', suggestion: 'a/C#', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Dm7/F#', suggestion: 'd7/F#', reason: 'minor_format'}), expect.objectContaining({lineNumber: 2, token: 'Dm7/F#', suggestion: 'd7/F#', reason: 'minor_format'}),
]); ]);
}); });
@@ -462,7 +462,7 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C Es G'); void expect(sections[0].lines[0].text).toBe('C Es G');
void expect(service.validateChordNotation(text)).toEqual([jasmine.objectContaining({lineNumber: 2, token: 'Es', reason: 'unknown_token', suggestion: null})]); void expect(service.validateChordNotation(text)).toEqual([expect.objectContaining({lineNumber: 2, token: 'Es', reason: 'unknown_token', suggestion: null})]);
}); });
it('should flag unknown tokens on mostly chord lines', () => { it('should flag unknown tokens on mostly chord lines', () => {
@@ -471,7 +471,7 @@ Text`;
C Foo G a C Foo G a
Text`; Text`;
void expect(service.validateChordNotation(text)).toEqual([jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null})]); void expect(service.validateChordNotation(text)).toEqual([expect.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null})]);
}); });
it('should reject tabs on chord lines', () => { it('should reject tabs on chord lines', () => {
@@ -480,7 +480,7 @@ Text`;
void expect(service.validateChordNotation(text)).toEqual( void expect(service.validateChordNotation(text)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
jasmine.objectContaining({ expect.objectContaining({
lineNumber: 2, lineNumber: 2,
token: '\t', token: '\t',
reason: 'tab_character', reason: 'tab_character',
@@ -80,6 +80,6 @@ describe('TransposeService', () => {
const rendered = service.renderChords(line); const rendered = service.renderChords(line);
void expect(rendered.text.length).toBe(121); void expect(rendered.text.length).toBe(121);
void expect(rendered.text.endsWith('C')).toBeTrue(); void expect(rendered.text.endsWith('C')).toBe(true);
}); });
}); });
@@ -6,20 +6,19 @@ import {UploadService} from './upload.service';
describe('UploadService', () => { describe('UploadService', () => {
let service: UploadService; let service: UploadService;
let fileDataServiceSpy: jasmine.SpyObj<FileDataService>; let fileDataServiceSpy: {set: ReturnType<typeof vi.fn>};
type UploadTaskLike = { type UploadTaskLike = {
on: (event: string, progress: (snapshot: {bytesTransferred: number; totalBytes: number}) => void, error: () => void, success: () => void) => void; on: (event: string, progress: (snapshot: {bytesTransferred: number; totalBytes: number}) => void, error: () => void, success: () => void) => void;
}; };
type UploadServiceInternals = UploadService & {
startUpload: (path: string, file: File) => UploadTaskLike;
};
beforeEach(async () => { beforeEach(async () => {
fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['set']); fileDataServiceSpy = {
fileDataServiceSpy.set.and.resolveTo('file-1'); set: vi.fn().mockResolvedValue('file-1'),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
UploadService,
{provide: Storage, useValue: {app: 'test-storage'}}, {provide: Storage, useValue: {app: 'test-storage'}},
{provide: FileDataService, useValue: fileDataServiceSpy}, {provide: FileDataService, useValue: fileDataServiceSpy},
], ],
@@ -28,6 +27,10 @@ describe('UploadService', () => {
service = TestBed.inject(UploadService); service = TestBed.inject(UploadService);
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it('should be created', () => { it('should be created', () => {
expect(service).toBeTruthy(); expect(service).toBeTruthy();
}); });
@@ -39,7 +42,8 @@ describe('UploadService', () => {
success(); success();
}, },
}; };
const uploadSpy = spyOn(service as UploadServiceInternals, 'startUpload').and.returnValue(task); const uploadSpy = vi.fn<(path: string, file: File) => UploadTaskLike>().mockReturnValue(task);
Object.assign(service, {startUpload: uploadSpy});
const upload = new Upload(new File(['content'], 'test.pdf', {type: 'application/pdf'})); const upload = new Upload(new File(['content'], 'test.pdf', {type: 'application/pdf'}));
service.pushUpload('song-1', upload); service.pushUpload('song-1', upload);
@@ -50,10 +54,10 @@ describe('UploadService', () => {
expect(upload.path).toBe('/attachments/song-1'); expect(upload.path).toBe('/attachments/song-1');
expect(fileDataServiceSpy.set).toHaveBeenCalledWith( expect(fileDataServiceSpy.set).toHaveBeenCalledWith(
'song-1', 'song-1',
jasmine.objectContaining({ expect.objectContaining({
name: 'test.pdf', name: 'test.pdf',
path: '/attachments/song-1', path: '/attachments/song-1',
createdAt: jasmine.any(Date), createdAt: expect.any(Date),
}) })
); );
}); });
@@ -16,16 +16,16 @@ describe('EditSongGuard', () => {
it('should allow navigation when there is no nested editSongComponent', () => { it('should allow navigation when there is no nested editSongComponent', () => {
const result = guard.canDeactivate({editSongComponent: null} as never, {} as never, {} as never, {} as never); const result = guard.canDeactivate({editSongComponent: null} as never, {} as never, {} as never, {} as never);
expect(result).toBeTrue(); expect(result).toBe(true);
}); });
it('should delegate to askForSave on the nested editSongComponent', async () => { it('should delegate to askForSave on the nested editSongComponent', async () => {
const nextState = {url: '/songs'} as never; const nextState = {url: '/songs'} as never;
const askForSave = jasmine.createSpy('askForSave').and.resolveTo(true); const askForSave = vi.fn().mockResolvedValue(true);
const result = await guard.canDeactivate({editSongComponent: {askForSave}} as never, {} as never, {} as never, nextState); const result = await guard.canDeactivate({editSongComponent: {askForSave}} as never, {} as never, {} as never, nextState);
expect(askForSave).toHaveBeenCalledWith(nextState); expect(askForSave).toHaveBeenCalledWith(nextState);
expect(result).toBeTrue(); expect(result).toBe(true);
}); });
}); });
@@ -18,20 +18,27 @@ describe('SongComponent', () => {
}; };
beforeEach(async () => { beforeEach(async () => {
const songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['read$']); const songServiceSpy = {
const fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['read$']); read$: vi.fn().mockReturnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never)),
const userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], { };
const fileDataServiceSpy = {
read$: vi.fn().mockReturnValue(of([])),
};
const userServiceSpy = {
incSongCount: vi.fn(),
decSongCount: vi.fn(),
user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}), user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}),
userId$: of('user-1'), userId$: of('user-1'),
}); loggedIn$: vi.fn().mockReturnValue(of(true)),
const showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']); getUserbyId$: vi.fn().mockReturnValue(of({name: 'Benjamin'})),
const showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('ShowSongService', ['new$']); };
const showServiceSpy = {
songServiceSpy.read$.and.returnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never)); list$: vi.fn().mockReturnValue(of([])),
fileDataServiceSpy.read$.and.returnValue(of([])); update$: vi.fn(),
showServiceSpy.list$.and.returnValue(of([])); };
userServiceSpy.loggedIn$ = jasmine.createSpy('loggedIn$').and.returnValue(of(true)); const showSongServiceSpy = {
userServiceSpy.getUserbyId$ = jasmine.createSpy('getUserbyId$').and.returnValue(of({name: 'Benjamin'})); new$: vi.fn(),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [SongComponent], imports: [SongComponent],
@@ -0,0 +1,45 @@
import {TestBed} from '@angular/core/testing';
import {AdminService} from './admin.service';
import {SongDownloadService} from '../../modules/songs/services/song-download.service';
import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
import {UserSongUsageService} from '../user/user-song-usage.service';
import {beforeEach, describe, expect, it, vi} from 'vitest';
describe('AdminService', () => {
let service: AdminService;
let songDownloadSpy: {downloadSongs: ReturnType<typeof vi.fn>};
let songUsageSpy: {rebuildSongUsage: ReturnType<typeof vi.fn>};
let showSongIndexSpy: {rebuildShowSongIds: ReturnType<typeof vi.fn>};
beforeEach(async () => {
songDownloadSpy = {downloadSongs: vi.fn()};
songUsageSpy = {rebuildSongUsage: vi.fn()};
showSongIndexSpy = {rebuildShowSongIds: vi.fn()};
songDownloadSpy.downloadSongs.mockResolvedValue({fileName: 'songs-2026-06-09.json', songsDownloaded: 4});
songUsageSpy.rebuildSongUsage.mockResolvedValue({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
showSongIndexSpy.rebuildShowSongIds.mockResolvedValue({showsProcessed: 2, showSongsProcessed: 3});
await TestBed.configureTestingModule({
providers: [
{provide: SongDownloadService, useValue: songDownloadSpy},
{provide: UserSongUsageService, useValue: songUsageSpy},
{provide: ShowSongIndexService, useValue: showSongIndexSpy},
],
});
service = TestBed.inject(AdminService);
});
it('should delegate admin operations to their domain services', async () => {
const onProgress = vi.fn();
await expect(service.downloadSongs()).resolves.toEqual({fileName: 'songs-2026-06-09.json', songsDownloaded: 4});
await expect(service.rebuildSongUsage()).resolves.toEqual({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
await expect(service.rebuildShowSongIds(onProgress)).resolves.toEqual({showsProcessed: 2, showSongsProcessed: 3});
expect(songDownloadSpy.downloadSongs).toHaveBeenCalled();
expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled();
expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalledWith(onProgress);
});
});
+17
View File
@@ -0,0 +1,17 @@
import {Injectable, inject} from '@angular/core';
import {SongDownloadResult, SongDownloadService} from '../../modules/songs/services/song-download.service';
import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
import {SongUsageMigrationResult, UserSongUsageService} from '../user/user-song-usage.service';
@Injectable({
providedIn: 'root',
})
export class AdminService {
private songDownload = inject(SongDownloadService);
private songUsage = inject(UserSongUsageService);
private showSongIndex = inject(ShowSongIndexService);
public downloadSongs = (): Promise<SongDownloadResult> => this.songDownload.downloadSongs();
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> => this.showSongIndex.rebuildShowSongIds(onProgress);
}
+6 -5
View File
@@ -1,15 +1,16 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {firstValueFrom, of} from 'rxjs'; import {firstValueFrom, of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {DbService} from './db.service'; import {DbService} from './db.service';
import {ConfigService} from './config.service'; import {ConfigService} from './config.service';
describe('ConfigService', () => { describe('ConfigService', () => {
let service: ConfigService; let service: ConfigService;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {doc$: ReturnType<typeof vi.fn>};
beforeEach(async () => { beforeEach(async () => {
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['doc$']); dbServiceSpy = {doc$: vi.fn()};
dbServiceSpy.doc$.and.returnValue(of({copyright: 'CCLI'}) as never); dbServiceSpy.doc$.mockReturnValue(of({copyright: 'CCLI'}) as never);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}], providers: [{provide: DbService, useValue: dbServiceSpy}],
@@ -28,10 +29,10 @@ describe('ConfigService', () => {
}); });
it('should expose the shared config stream via get$', async () => { it('should expose the shared config stream via get$', async () => {
await expectAsync(firstValueFrom(service.get$())).toBeResolvedTo({copyright: 'CCLI'} as never); await expect(firstValueFrom(service.get$())).resolves.toEqual({copyright: 'CCLI'} as never);
}); });
it('should resolve the current config via get()', async () => { it('should resolve the current config via get()', async () => {
await expectAsync(service.get()).toBeResolvedTo({copyright: 'CCLI'} as never); await expect(service.get()).resolves.toEqual({copyright: 'CCLI'} as never);
}); });
}); });
@@ -1,18 +1,19 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {firstValueFrom, of} from 'rxjs'; import {firstValueFrom, of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {DbService} from './db.service'; import {DbService} from './db.service';
import {GlobalSettingsService} from './global-settings.service'; import {GlobalSettingsService} from './global-settings.service';
describe('GlobalSettingsService', () => { describe('GlobalSettingsService', () => {
let service: GlobalSettingsService; let service: GlobalSettingsService;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {doc$: ReturnType<typeof vi.fn>; doc: ReturnType<typeof vi.fn>};
let updateSpy: jasmine.Spy; let updateSpy: ReturnType<typeof vi.fn>;
beforeEach(async () => { beforeEach(async () => {
updateSpy = jasmine.createSpy('update').and.resolveTo(); updateSpy = vi.fn().mockResolvedValue(undefined);
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['doc$', 'doc']); dbServiceSpy = {doc$: vi.fn(), doc: vi.fn()};
dbServiceSpy.doc$.and.returnValue(of({churchName: 'ICF'}) as never); dbServiceSpy.doc$.mockReturnValue(of({churchName: 'ICF'}) as never);
dbServiceSpy.doc.and.returnValue({update: updateSpy} as never); dbServiceSpy.doc.mockReturnValue({update: updateSpy} as never);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}], providers: [{provide: DbService, useValue: dbServiceSpy}],
@@ -31,7 +32,7 @@ describe('GlobalSettingsService', () => {
}); });
it('should expose the shared settings stream via the getter', async () => { it('should expose the shared settings stream via the getter', async () => {
await expectAsync(firstValueFrom(service.get$)).toBeResolvedTo({churchName: 'ICF'} as never); await expect(firstValueFrom(service.get$)).resolves.toEqual({churchName: 'ICF'} as never);
}); });
it('should update the static global settings document', async () => { it('should update the static global settings document', async () => {
@@ -1,16 +1,17 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {firstValueFrom, of} from 'rxjs'; import {firstValueFrom, of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {UserService} from '../user.service'; import {UserService} from '../user.service';
import {UserNameComponent} from './user-name.component'; import {UserNameComponent} from './user-name.component';
describe('UserNameComponent', () => { describe('UserNameComponent', () => {
let component: UserNameComponent; let component: UserNameComponent;
let fixture: ComponentFixture<UserNameComponent>; let fixture: ComponentFixture<UserNameComponent>;
let userServiceSpy: jasmine.SpyObj<UserService>; let userServiceSpy: {getUserbyId$: ReturnType<typeof vi.fn>};
beforeEach(async () => { beforeEach(async () => {
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['getUserbyId$']); userServiceSpy = {getUserbyId$: vi.fn()};
userServiceSpy.getUserbyId$.and.returnValue(of({name: 'Benjamin'} as never)); userServiceSpy.getUserbyId$.mockReturnValue(of({name: 'Benjamin'} as never));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [UserNameComponent], imports: [UserNameComponent],
@@ -32,14 +33,14 @@ describe('UserNameComponent', () => {
component.userId = 'user-1'; component.userId = 'user-1';
expect(userServiceSpy.getUserbyId$).toHaveBeenCalledWith('user-1'); expect(userServiceSpy.getUserbyId$).toHaveBeenCalledWith('user-1');
await expectAsync(firstValueFrom(component.name$!)).toBeResolvedTo('Benjamin'); await expect(firstValueFrom(component.name$!)).resolves.toEqual('Benjamin');
}); });
it('should map missing users to null names', async () => { it('should map missing users to null names', async () => {
userServiceSpy.getUserbyId$.and.returnValue(of(null)); userServiceSpy.getUserbyId$.mockReturnValue(of(null));
component.userId = 'missing-user'; component.userId = 'missing-user';
await expectAsync(firstValueFrom(component.name$!)).toBeResolvedTo(null); await expect(firstValueFrom(component.name$!)).resolves.toEqual(null);
}); });
}); });
@@ -2,23 +2,24 @@ import {TestBed} from '@angular/core/testing';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {Auth} from '@angular/fire/auth'; import {Auth} from '@angular/fire/auth';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {DbService} from '../db.service'; import {DbService} from '../db.service';
import {UserSessionService} from './user-session.service'; import {UserSessionService} from './user-session.service';
describe('UserSessionService', () => { describe('UserSessionService', () => {
let service: UserSessionService; let service: UserSessionService;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {col$: ReturnType<typeof vi.fn>; doc$: ReturnType<typeof vi.fn>; doc: ReturnType<typeof vi.fn>};
let routerSpy: jasmine.SpyObj<Router>; let routerSpy: {navigateByUrl: ReturnType<typeof vi.fn>};
let authStateSubject: BehaviorSubject<unknown>; let authStateSubject: BehaviorSubject<unknown>;
let createAuthStateSpy: jasmine.Spy; let createAuthStateSpy: ReturnType<typeof vi.fn>;
let runInFirebaseContextSpy: jasmine.Spy; let runInFirebaseContextSpy: ReturnType<typeof vi.fn>;
beforeEach(async () => { beforeEach(async () => {
authStateSubject = new BehaviorSubject<unknown>(null); authStateSubject = new BehaviorSubject<unknown>(null);
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['col$', 'doc$', 'doc']); dbServiceSpy = {col$: vi.fn(), doc$: vi.fn(), doc: vi.fn()};
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']); routerSpy = {navigateByUrl: vi.fn()};
dbServiceSpy.col$.and.returnValue(of([{id: 'user-1'}]) as never); dbServiceSpy.col$.mockReturnValue(of([{id: 'user-1'}]) as never);
dbServiceSpy.doc$.and.callFake((path: string) => { dbServiceSpy.doc$.mockImplementation((path: string) => {
if (path === 'users/user-1') { if (path === 'users/user-1') {
return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters', songUsage: {}}) as never; return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters', songUsage: {}}) as never;
} }
@@ -28,15 +29,15 @@ describe('UserSessionService', () => {
return of(null) as never; return of(null) as never;
}); });
dbServiceSpy.doc.and.returnValue({ dbServiceSpy.doc.mockReturnValue({
update: jasmine.createSpy('update').and.resolveTo(), update: vi.fn().mockResolvedValue(undefined),
set: jasmine.createSpy('set').and.resolveTo(), set: vi.fn().mockResolvedValue(undefined),
} as never); } as never);
routerSpy.navigateByUrl.and.resolveTo(true); routerSpy.navigateByUrl.mockResolvedValue(true);
createAuthStateSpy = spyOn(UserSessionService.prototype as UserSessionService & {createAuthState$: () => unknown}, 'createAuthState$').and.returnValue( createAuthStateSpy = vi
authStateSubject.asObservable() as never .spyOn(UserSessionService.prototype as unknown as {createAuthState$: () => unknown}, 'createAuthState$')
); .mockReturnValue(authStateSubject.asObservable() as never) as unknown as ReturnType<typeof vi.fn>;
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -47,7 +48,13 @@ describe('UserSessionService', () => {
}); });
service = TestBed.inject(UserSessionService); service = TestBed.inject(UserSessionService);
runInFirebaseContextSpy = spyOn(service as UserSessionService & {runInFirebaseContext: (...args: unknown[]) => Promise<unknown>}, 'runInFirebaseContext'); runInFirebaseContextSpy = vi.spyOn(service as unknown as {runInFirebaseContext: (...args: unknown[]) => Promise<unknown>}, 'runInFirebaseContext') as unknown as ReturnType<
typeof vi.fn
>;
});
afterEach(() => {
vi.restoreAllMocks();
}); });
it('should be created', () => { it('should be created', () => {
@@ -58,14 +65,14 @@ describe('UserSessionService', () => {
it('should derive userId$ and loggedIn$ from authState', async () => { it('should derive userId$ and loggedIn$ from authState', async () => {
authStateSubject.next({uid: 'user-1'}); authStateSubject.next({uid: 'user-1'});
await expectAsync(firstValueFrom(service.userId$)).toBeResolvedTo('user-1'); await expect(firstValueFrom(service.userId$)).resolves.toEqual('user-1');
await expectAsync(firstValueFrom(service.loggedIn$())).toBeResolvedTo(true); await expect(firstValueFrom(service.loggedIn$())).resolves.toEqual(true);
}); });
it('should resolve the current user document from auth state', async () => { it('should resolve the current user document from auth state', async () => {
authStateSubject.next({uid: 'user-1'}); authStateSubject.next({uid: 'user-1'});
await expectAsync(firstValueFrom(service.user$)).toBeResolvedTo(jasmine.objectContaining({id: 'user-1', name: 'Benjamin'}) as never); await expect(firstValueFrom(service.user$)).resolves.toEqual(expect.objectContaining({id: 'user-1', name: 'Benjamin'}) as never);
}); });
it('should cache user lookups by id', async () => { it('should cache user lookups by id', async () => {
@@ -73,30 +80,30 @@ describe('UserSessionService', () => {
const second$ = service.getUserbyId$('user-2'); const second$ = service.getUserbyId$('user-2');
expect(first$).toBe(second$); expect(first$).toBe(second$);
await expectAsync(firstValueFrom(first$)).toBeResolvedTo(jasmine.objectContaining({id: 'user-2'}) as never); await expect(firstValueFrom(first$)).resolves.toEqual(expect.objectContaining({id: 'user-2'}) as never);
}); });
it('should login and initialize songUsage when missing', async () => { it('should login and initialize songUsage when missing', async () => {
dbServiceSpy.doc$.and.callFake((path: string) => { dbServiceSpy.doc$.mockImplementation((path: string) => {
if (path === 'users/user-1') { if (path === 'users/user-1') {
return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters'}) as never; return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters'}) as never;
} }
return of(null) as never; return of(null) as never;
}); });
const updateSpy = jasmine.createSpy('update').and.resolveTo(); const updateSpy = vi.fn().mockResolvedValue(undefined);
dbServiceSpy.doc.and.returnValue({update: updateSpy} as never); dbServiceSpy.doc.mockReturnValue({update: updateSpy} as never);
runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-1'}}); runInFirebaseContextSpy.mockResolvedValue({user: {uid: 'user-1'}});
authStateSubject.next({uid: 'user-1'}); authStateSubject.next({uid: 'user-1'});
await expectAsync(service.login('mail', 'secret')).toBeResolvedTo('user-1'); await expect(service.login('mail', 'secret')).resolves.toEqual('user-1');
expect(runInFirebaseContextSpy).toHaveBeenCalledTimes(1); expect(runInFirebaseContextSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledWith({songUsage: {}}); expect(updateSpy).toHaveBeenCalledWith({songUsage: {}});
}); });
it('should wait for auth state propagation before resolving login', async () => { it('should wait for auth state propagation before resolving login', async () => {
runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-1'}}); runInFirebaseContextSpy.mockResolvedValue({user: {uid: 'user-1'}});
let resolved = false; let resolved = false;
const loginPromise = service.login('mail', 'secret').then(result => { const loginPromise = service.login('mail', 'secret').then(result => {
@@ -105,15 +112,15 @@ describe('UserSessionService', () => {
}); });
await Promise.resolve(); await Promise.resolve();
expect(resolved).toBeFalse(); expect(resolved).toBe(false);
authStateSubject.next({uid: 'user-1'}); authStateSubject.next({uid: 'user-1'});
await expectAsync(loginPromise).toBeResolvedTo('user-1'); await expect(loginPromise).resolves.toEqual('user-1');
}); });
it('should delegate logout and password reset to AngularFire auth APIs', async () => { it('should delegate logout and password reset to AngularFire auth APIs', async () => {
runInFirebaseContextSpy.and.resolveTo(); runInFirebaseContextSpy.mockResolvedValue(undefined);
await service.logout(); await service.logout();
await service.changePassword('mail@example.com'); await service.changePassword('mail@example.com');
@@ -122,16 +129,16 @@ describe('UserSessionService', () => {
}); });
it('should create a new user document and navigate afterwards', async () => { it('should create a new user document and navigate afterwards', async () => {
dbServiceSpy.doc$.and.callFake((path: string) => { dbServiceSpy.doc$.mockImplementation((path: string) => {
if (path === 'users/user-3') { if (path === 'users/user-3') {
return of({id: 'user-3', name: 'New User', role: 'user', chordMode: 'onlyFirst', songUsage: {}}) as never; return of({id: 'user-3', name: 'New User', role: 'user', chordMode: 'onlyFirst', songUsage: {}}) as never;
} }
return of(null) as never; return of(null) as never;
}); });
const setSpy = jasmine.createSpy('set').and.resolveTo(); const setSpy = vi.fn().mockResolvedValue(undefined);
dbServiceSpy.doc.and.returnValue({set: setSpy} as never); dbServiceSpy.doc.mockReturnValue({set: setSpy} as never);
runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-3'}}); runInFirebaseContextSpy.mockResolvedValue({user: {uid: 'user-3'}});
await service.createNewUser('mail@example.com', 'New User', 'secret'); await service.createNewUser('mail@example.com', 'New User', 'secret');
@@ -1,5 +1,6 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs'; import {of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {DbService} from '../db.service'; import {DbService} from '../db.service';
import {ShowDataService} from '../../modules/shows/services/show-data.service'; import {ShowDataService} from '../../modules/shows/services/show-data.service';
import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service'; import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service';
@@ -8,31 +9,32 @@ import {UserSongUsageService} from './user-song-usage.service';
describe('UserSongUsageService', () => { describe('UserSongUsageService', () => {
let service: UserSongUsageService; let service: UserSongUsageService;
let dbServiceSpy: jasmine.SpyObj<DbService>; let dbServiceSpy: {doc: ReturnType<typeof vi.fn>};
let sessionSpy: jasmine.SpyObj<UserSessionService>; let sessionSpy: {update$: ReturnType<typeof vi.fn>; user$: unknown; users$: unknown};
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>; let showDataServiceSpy: {listRaw$: ReturnType<typeof vi.fn>};
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>; let showSongDataServiceSpy: {list$: ReturnType<typeof vi.fn>};
beforeEach(async () => { beforeEach(async () => {
dbServiceSpy = jasmine.createSpyObj<DbService>('DbService', ['doc']); dbServiceSpy = {doc: vi.fn()};
sessionSpy = jasmine.createSpyObj<UserSessionService>('UserSessionService', ['update$'], { sessionSpy = {
update$: vi.fn(),
user$: of({id: 'user-1', role: 'admin', songUsage: {}}) as never, user$: of({id: 'user-1', role: 'admin', songUsage: {}}) as never,
users$: of([{id: 'user-1'}, {id: 'user-2'}]) as never, users$: of([{id: 'user-1'}, {id: 'user-2'}]) as never,
}); };
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['listRaw$']); showDataServiceSpy = {listRaw$: vi.fn()};
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['list$']); showSongDataServiceSpy = {list$: vi.fn()};
sessionSpy.update$.and.resolveTo(); sessionSpy.update$.mockResolvedValue(undefined);
showDataServiceSpy.listRaw$.and.returnValue( showDataServiceSpy.listRaw$.mockReturnValue(
of([ of([
{id: 'show-1', owner: 'user-1', archived: false}, {id: 'show-1', owner: 'user-1', archived: false},
{id: 'show-2', owner: 'user-2', archived: true}, {id: 'show-2', owner: 'user-2', archived: true},
]) as never ]) as never
); );
showSongDataServiceSpy.list$.and.callFake( showSongDataServiceSpy.list$.mockImplementation(
(showId: string) => of(showId === 'show-1' ? [{songId: 'song-1'}, {songId: 'song-1'}, {songId: 'song-2'}] : [{songId: 'song-3'}]) as never (showId: string) => of(showId === 'show-1' ? [{songId: 'song-1'}, {songId: 'song-1'}, {songId: 'song-2'}] : [{songId: 'song-3'}]) as never
); );
dbServiceSpy.doc.and.returnValue({update: jasmine.createSpy('update').and.resolveTo()} as never); dbServiceSpy.doc.mockReturnValue({update: vi.fn().mockResolvedValue(undefined)} as never);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -51,19 +53,19 @@ describe('UserSongUsageService', () => {
}); });
it('should increment and decrement song usage for the current user', async () => { it('should increment and decrement song usage for the current user', async () => {
const updateSpy = jasmine.createSpy('update').and.resolveTo(); const updateSpy = vi.fn().mockResolvedValue(undefined);
dbServiceSpy.doc.and.returnValue({update: updateSpy} as never); dbServiceSpy.doc.mockReturnValue({update: updateSpy} as never);
await service.incSongCount('song-1'); await service.incSongCount('song-1');
await service.decSongCount('song-2'); await service.decSongCount('song-2');
expect(dbServiceSpy.doc).toHaveBeenCalledWith('users/user-1'); expect(dbServiceSpy.doc).toHaveBeenCalledWith('users/user-1');
expect(updateSpy.calls.argsFor(0)[0]).toEqual({'songUsage.song-1': jasmine.anything()}); expect(updateSpy.mock.calls[0]?.[0]).toEqual({'songUsage.song-1': expect.anything()});
expect(updateSpy.calls.argsFor(1)[0]).toEqual({'songUsage.song-2': jasmine.anything()}); expect(updateSpy.mock.calls[1]?.[0]).toEqual({'songUsage.song-2': expect.anything()});
}); });
it('should rebuild song usage for all users based on owned show songs', async () => { it('should rebuild song usage for all users based on owned show songs', async () => {
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({ await expect(service.rebuildSongUsage()).resolves.toEqual({
usersProcessed: 2, usersProcessed: 2,
showsProcessed: 2, showsProcessed: 2,
showSongsProcessed: 3, showSongsProcessed: 3,
@@ -76,6 +78,6 @@ describe('UserSongUsageService', () => {
it('should reject song usage rebuilds for non-admin users', async () => { it('should reject song usage rebuilds for non-admin users', async () => {
Object.defineProperty(sessionSpy, 'user$', {value: of({id: 'user-1', role: 'leader'})}); Object.defineProperty(sessionSpy, 'user$', {value: of({id: 'user-1', role: 'leader'})});
await expectAsync(service.rebuildSongUsage()).toBeRejectedWithError('Admin role required to rebuild songUsage.'); await expect(service.rebuildSongUsage()).rejects.toThrow('Admin role required to rebuild songUsage.');
}); });
}); });
+51 -40
View File
@@ -1,49 +1,64 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {firstValueFrom, of} from 'rxjs'; import {firstValueFrom, of} from 'rxjs';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {UserService} from './user.service'; import {UserService} from './user.service';
import {UserSessionService} from './user-session.service'; import {UserSessionService} from './user-session.service';
import {UserSongUsageService} from './user-song-usage.service'; import {UserSongUsageService} from './user-song-usage.service';
import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
describe('UserService', () => { describe('UserService', () => {
let service: UserService; let service: UserService;
let sessionSpy: jasmine.SpyObj<UserSessionService>; let sessionSpy: {
let songUsageSpy: jasmine.SpyObj<UserSongUsageService>; currentUser: ReturnType<typeof vi.fn>;
let showSongIndexSpy: jasmine.SpyObj<ShowSongIndexService>; getUserbyId: ReturnType<typeof vi.fn>;
getUserbyId$: ReturnType<typeof vi.fn>;
login: ReturnType<typeof vi.fn>;
loggedIn$: ReturnType<typeof vi.fn>;
list$: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
update$: ReturnType<typeof vi.fn>;
changePassword: ReturnType<typeof vi.fn>;
createNewUser: ReturnType<typeof vi.fn>;
users$: unknown;
userId$: unknown;
user$: unknown;
};
let songUsageSpy: {incSongCount: ReturnType<typeof vi.fn>; decSongCount: ReturnType<typeof vi.fn>};
beforeEach(async () => { beforeEach(async () => {
sessionSpy = jasmine.createSpyObj<UserSessionService>( sessionSpy = {
'UserSessionService', currentUser: vi.fn(),
['currentUser', 'getUserbyId', 'getUserbyId$', 'login', 'loggedIn$', 'list$', 'logout', 'update$', 'changePassword', 'createNewUser'], getUserbyId: vi.fn(),
{ getUserbyId$: vi.fn(),
login: vi.fn(),
loggedIn$: vi.fn(),
list$: vi.fn(),
logout: vi.fn(),
update$: vi.fn(),
changePassword: vi.fn(),
createNewUser: vi.fn(),
users$: of([{id: 'user-1'}]) as never, users$: of([{id: 'user-1'}]) as never,
userId$: of('user-1'), userId$: of('user-1'),
user$: of({id: 'user-1'}) as never, user$: of({id: 'user-1'}) as never,
} };
); songUsageSpy = {incSongCount: vi.fn(), decSongCount: vi.fn()};
songUsageSpy = jasmine.createSpyObj<UserSongUsageService>('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']);
showSongIndexSpy = jasmine.createSpyObj<ShowSongIndexService>('ShowSongIndexService', ['rebuildShowSongIds']);
sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never); sessionSpy.currentUser.mockResolvedValue({id: 'user-1'} as never);
sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never); sessionSpy.getUserbyId.mockResolvedValue({id: 'user-2'} as never);
sessionSpy.getUserbyId$.and.returnValue(of({id: 'user-2'}) as never); sessionSpy.getUserbyId$.mockReturnValue(of({id: 'user-2'}) as never);
sessionSpy.login.and.resolveTo('user-1'); sessionSpy.login.mockResolvedValue('user-1');
sessionSpy.loggedIn$.and.returnValue(of(true)); sessionSpy.loggedIn$.mockReturnValue(of(true));
sessionSpy.list$.and.returnValue(of([{id: 'user-1'}]) as never); sessionSpy.list$.mockReturnValue(of([{id: 'user-1'}]) as never);
sessionSpy.logout.and.resolveTo(); sessionSpy.logout.mockResolvedValue(undefined);
sessionSpy.update$.and.resolveTo(); sessionSpy.update$.mockResolvedValue(undefined);
sessionSpy.changePassword.and.resolveTo(); sessionSpy.changePassword.mockResolvedValue(undefined);
sessionSpy.createNewUser.and.resolveTo(); sessionSpy.createNewUser.mockResolvedValue(undefined);
songUsageSpy.incSongCount.and.resolveTo(); songUsageSpy.incSongCount.mockResolvedValue(undefined);
songUsageSpy.decSongCount.and.resolveTo(); songUsageSpy.decSongCount.mockResolvedValue(undefined);
songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
showSongIndexSpy.rebuildShowSongIds.and.resolveTo({showsProcessed: 2, showSongsProcessed: 3});
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
{provide: UserSessionService, useValue: sessionSpy}, {provide: UserSessionService, useValue: sessionSpy},
{provide: UserSongUsageService, useValue: songUsageSpy}, {provide: UserSongUsageService, useValue: songUsageSpy},
{provide: ShowSongIndexService, useValue: showSongIndexSpy},
], ],
}); });
@@ -55,15 +70,15 @@ describe('UserService', () => {
}); });
it('should expose the session streams directly', async () => { it('should expose the session streams directly', async () => {
await expectAsync(firstValueFrom(service.userId$)).toBeResolvedTo('user-1'); await expect(firstValueFrom(service.userId$)).resolves.toEqual('user-1');
await expectAsync(firstValueFrom(service.user$)).toBeResolvedTo({id: 'user-1'} as never); await expect(firstValueFrom(service.user$)).resolves.toEqual({id: 'user-1'} as never);
await expectAsync(firstValueFrom(service.users$)).toBeResolvedTo([{id: 'user-1'}] as never); await expect(firstValueFrom(service.users$)).resolves.toEqual([{id: 'user-1'}] as never);
}); });
it('should delegate session operations to UserSessionService', async () => { it('should delegate session operations to UserSessionService', async () => {
await expectAsync(service.currentUser()).toBeResolvedTo({id: 'user-1'} as never); await expect(service.currentUser()).resolves.toEqual({id: 'user-1'} as never);
await expectAsync(service.getUserbyId('user-2')).toBeResolvedTo({id: 'user-2'} as never); await expect(service.getUserbyId('user-2')).resolves.toEqual({id: 'user-2'} as never);
await expectAsync(service.login('mail', 'secret')).toBeResolvedTo('user-1'); await expect(service.login('mail', 'secret')).resolves.toEqual('user-1');
await service.logout(); await service.logout();
await service.update$('user-1', {name: 'Benjamin'} as never); await service.update$('user-1', {name: 'Benjamin'} as never);
await service.changePassword('mail'); await service.changePassword('mail');
@@ -79,9 +94,9 @@ describe('UserService', () => {
}); });
it('should delegate user lookup and loggedIn/list streams to UserSessionService', async () => { it('should delegate user lookup and loggedIn/list streams to UserSessionService', async () => {
await expectAsync(firstValueFrom(service.getUserbyId$('user-2'))).toBeResolvedTo({id: 'user-2'} as never); await expect(firstValueFrom(service.getUserbyId$('user-2'))).resolves.toEqual({id: 'user-2'} as never);
await expectAsync(firstValueFrom(service.loggedIn$())).toBeResolvedTo(true); await expect(firstValueFrom(service.loggedIn$())).resolves.toEqual(true);
await expectAsync(firstValueFrom(service.list$())).toBeResolvedTo([{id: 'user-1'}] as never); await expect(firstValueFrom(service.list$())).resolves.toEqual([{id: 'user-1'}] as never);
expect(sessionSpy.getUserbyId$).toHaveBeenCalledWith('user-2'); expect(sessionSpy.getUserbyId$).toHaveBeenCalledWith('user-2');
expect(sessionSpy.loggedIn$).toHaveBeenCalled(); expect(sessionSpy.loggedIn$).toHaveBeenCalled();
expect(sessionSpy.list$).toHaveBeenCalled(); expect(sessionSpy.list$).toHaveBeenCalled();
@@ -90,12 +105,8 @@ describe('UserService', () => {
it('should delegate song usage operations to UserSongUsageService', async () => { it('should delegate song usage operations to UserSongUsageService', async () => {
await service.incSongCount('song-1'); await service.incSongCount('song-1');
await service.decSongCount('song-2'); await service.decSongCount('song-2');
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({showsProcessed: 2, showSongsProcessed: 3});
expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1'); expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1');
expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2'); expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2');
expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled();
expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalled();
}); });
}); });
+1 -5
View File
@@ -1,9 +1,8 @@
import {Injectable, inject} from '@angular/core'; import {Injectable, inject} from '@angular/core';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import {User} from './user'; import {User} from './user';
import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service'; import {UserSongUsageService} from './user-song-usage.service';
import {UserSessionService} from './user-session.service'; import {UserSessionService} from './user-session.service';
import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -11,7 +10,6 @@ import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} f
export class UserService { export class UserService {
private session = inject(UserSessionService); private session = inject(UserSessionService);
private songUsage = inject(UserSongUsageService); private songUsage = inject(UserSongUsageService);
private showSongIndex = inject(ShowSongIndexService);
public users$ = this.session.users$; public users$ = this.session.users$;
@@ -35,6 +33,4 @@ export class UserService {
public createNewUser = (user: string, name: string, password: string): Promise<void> => this.session.createNewUser(user, name, password); public createNewUser = (user: string, name: string, password: string): Promise<void> => this.session.createNewUser(user, name, password);
public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId); public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId);
public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId); public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> => this.showSongIndex.rebuildShowSongIds(onProgress);
} }
@@ -13,6 +13,8 @@ describe('SidebarComponent', () => {
fixture = TestBed.createComponent(PageFrameComponent); fixture = TestBed.createComponent(PageFrameComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.componentRef.setInput('title', 'Test');
fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
}); });
@@ -21,12 +23,12 @@ describe('SidebarComponent', () => {
}); });
it('should toggle and close the sidebar', () => { it('should toggle and close the sidebar', () => {
expect(component.collapsed).toBeTrue(); expect(component.collapsed).toBe(true);
component.toggle(); component.toggle();
expect(component.collapsed).toBeFalse(); expect(component.collapsed).toBe(false);
component.close(); component.close();
expect(component.collapsed).toBeTrue(); expect(component.collapsed).toBe(true);
}); });
}); });
@@ -1,16 +1,18 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {firstValueFrom, of} from 'rxjs'; import {firstValueFrom, of} from 'rxjs';
import {vi} from 'vitest';
import {UserService} from '../../services/user/user.service'; import {UserService} from '../../services/user/user.service';
import {RoleGuard} from './role.guard'; import {RoleGuard} from './role.guard';
describe('RoleGuard', () => { describe('RoleGuard', () => {
let guard: RoleGuard; let guard: RoleGuard;
let routerSpy: jasmine.SpyObj<Router>; let createUrlTreeSpy: ReturnType<typeof vi.fn>;
let routerSpy: Pick<Router, 'createUrlTree'>;
beforeEach(async () => { beforeEach(async () => {
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']); createUrlTreeSpy = vi.fn().mockImplementation(commands => ({commands}) as never);
routerSpy.createUrlTree.and.callFake(commands => ({commands}) as never); routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
@@ -31,12 +33,13 @@ describe('RoleGuard', () => {
}); });
it('should deny access when there is no current user', async () => { it('should deny access when there is no current user', async () => {
await expectAsync(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).toBeResolvedTo({commands: ['brand', 'new-user']} as never); await expect(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).resolves.toEqual({commands: ['brand', 'new-user']} as never);
}); });
it('should allow admins regardless of requiredRoles', async () => { it('should allow admins regardless of requiredRoles', async () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']); createUrlTreeSpy = vi.fn();
routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{provide: Router, useValue: routerSpy}, {provide: Router, useValue: routerSpy},
@@ -45,12 +48,13 @@ describe('RoleGuard', () => {
}); });
guard = TestBed.inject(RoleGuard); guard = TestBed.inject(RoleGuard);
await expectAsync(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).toBeResolvedTo(true); await expect(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).resolves.toEqual(true);
}); });
it('should allow users with a matching required role', async () => { it('should allow users with a matching required role', async () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']); createUrlTreeSpy = vi.fn();
routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{provide: Router, useValue: routerSpy}, {provide: Router, useValue: routerSpy},
@@ -59,13 +63,13 @@ describe('RoleGuard', () => {
}); });
guard = TestBed.inject(RoleGuard); guard = TestBed.inject(RoleGuard);
await expectAsync(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).toBeResolvedTo(true); await expect(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).resolves.toEqual(true);
}); });
it('should redirect users without the required role to their role default route', async () => { it('should redirect users without the required role to their role default route', async () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']); createUrlTreeSpy = vi.fn().mockReturnValue({redirect: ['presentation']} as never);
routerSpy.createUrlTree.and.returnValue({redirect: ['presentation']} as never); routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{provide: Router, useValue: routerSpy}, {provide: Router, useValue: routerSpy},
@@ -75,14 +79,14 @@ describe('RoleGuard', () => {
guard = TestBed.inject(RoleGuard); guard = TestBed.inject(RoleGuard);
const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never)); const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never));
expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['presentation']); expect(createUrlTreeSpy).toHaveBeenCalledWith(['presentation']);
expect(result).toEqual({redirect: ['presentation']} as never); expect(result).toEqual({redirect: ['presentation']} as never);
}); });
it('should redirect members to shows instead of new-user', async () => { it('should redirect members to shows instead of new-user', async () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']); createUrlTreeSpy = vi.fn().mockReturnValue({redirect: ['shows']} as never);
routerSpy.createUrlTree.and.returnValue({redirect: ['shows']} as never); routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{provide: Router, useValue: routerSpy}, {provide: Router, useValue: routerSpy},
@@ -92,14 +96,14 @@ describe('RoleGuard', () => {
guard = TestBed.inject(RoleGuard); guard = TestBed.inject(RoleGuard);
const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['user']}} as never)); const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['user']}} as never));
expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['shows']); expect(createUrlTreeSpy).toHaveBeenCalledWith(['shows']);
expect(result).toEqual({redirect: ['shows']} as never); expect(result).toEqual({redirect: ['shows']} as never);
}); });
it('should choose a matching default route from all assigned roles', async () => { it('should choose a matching default route from all assigned roles', async () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']); createUrlTreeSpy = vi.fn().mockImplementation(commands => ({redirect: commands}) as never);
routerSpy.createUrlTree.and.callFake(commands => ({redirect: commands}) as never); routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{provide: Router, useValue: routerSpy}, {provide: Router, useValue: routerSpy},
@@ -109,7 +113,7 @@ describe('RoleGuard', () => {
guard = TestBed.inject(RoleGuard); guard = TestBed.inject(RoleGuard);
const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['presenter']}} as never)); const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['presenter']}} as never));
expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['shows']); expect(createUrlTreeSpy).toHaveBeenCalledWith(['shows']);
expect(result).toEqual({redirect: ['shows']} as never); expect(result).toEqual({redirect: ['shows']} as never);
}); });
}); });
+11 -4
View File
@@ -13,11 +13,12 @@ import {provideAuth} from '@angular/fire/auth';
import {initializeApp} from 'firebase/app'; import {initializeApp} from 'firebase/app';
import {getAuth} from 'firebase/auth'; import {getAuth} from 'firebase/auth';
import {initializeFirestore, persistentLocalCache, persistentMultipleTabManager} from 'firebase/firestore'; import {initializeFirestore, persistentLocalCache, persistentMultipleTabManager} from 'firebase/firestore';
import {UserService} from './app/services/user/user.service'; import {AdminService} from './app/services/admin/admin.service';
declare global { declare global {
interface Window { interface Window {
wgeneratorAdmin?: { wgeneratorAdmin?: {
downloadSongs(): Promise<unknown>;
rebuildSongUsage(): Promise<unknown>; rebuildSongUsage(): Promise<unknown>;
rebuildShowSongIds(): Promise<unknown>; rebuildShowSongIds(): Promise<unknown>;
}; };
@@ -50,12 +51,18 @@ bootstrapApplication(AppComponent, {
], ],
}) })
.then(appRef => { .then(appRef => {
const userService = appRef.injector.get(UserService); const adminService = appRef.injector.get(AdminService);
window.wgeneratorAdmin = { window.wgeneratorAdmin = {
rebuildSongUsage: () => userService.rebuildSongUsage(), downloadSongs: async () => {
console.info('[wgeneratorAdmin] downloadSongs started');
const result = await adminService.downloadSongs();
console.info('[wgeneratorAdmin] downloadSongs finished', result);
return result;
},
rebuildSongUsage: () => adminService.rebuildSongUsage(),
rebuildShowSongIds: async () => { rebuildShowSongIds: async () => {
console.info('[wgeneratorAdmin] rebuildShowSongIds started'); console.info('[wgeneratorAdmin] rebuildShowSongIds started');
const result = await userService.rebuildShowSongIds(progress => { const result = await adminService.rebuildShowSongIds(progress => {
console.info(`[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`, { console.info(`[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`, {
showId: progress.showId, showId: progress.showId,
showSongsProcessed: progress.showSongsProcessed, showSongsProcessed: progress.showSongsProcessed,
-143
View File
@@ -1,5 +1,3 @@
import {expect, vi} from 'vitest';
import 'zone.js/testing'; import 'zone.js/testing';
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {provideNoopAnimations} from '@angular/platform-browser/animations'; import {provideNoopAnimations} from '@angular/platform-browser/animations';
@@ -25,20 +23,6 @@ type DocumentStub = {
collection: () => CollectionStub; collection: () => CollectionStub;
}; };
type MockFunction = ReturnType<typeof vi.fn> & {
and: {
returnValue: (value: unknown) => MockFunction;
resolveTo: (value: unknown) => MockFunction;
rejectWith: (value: unknown) => MockFunction;
callFake: (fn: (...args: unknown[]) => unknown) => MockFunction;
callThrough: () => MockFunction;
};
calls: {
argsFor: (index: number) => unknown[];
mostRecent: () => {args: unknown[]};
};
};
const routeParams$ = new BehaviorSubject<Record<string, unknown>>({}); const routeParams$ = new BehaviorSubject<Record<string, unknown>>({});
const queryParams$ = new BehaviorSubject<Record<string, unknown>>({}); const queryParams$ = new BehaviorSubject<Record<string, unknown>>({});
@@ -92,130 +76,3 @@ const configureTestingModule: typeof TestBed.configureTestingModule = moduleDef
return originalConfigureTestingModule(mergedModuleDef); return originalConfigureTestingModule(mergedModuleDef);
}; };
TestBed.configureTestingModule = configureTestingModule; TestBed.configureTestingModule = configureTestingModule;
function decorateMock<T extends ReturnType<typeof vi.fn>>(mock: T): T & MockFunction {
const decorated = mock as T & MockFunction;
Object.defineProperty(decorated, 'and', {
configurable: true,
get: () => ({
returnValue(value: unknown) {
decorated.mockReturnValue(value);
return decorated;
},
resolveTo(value: unknown) {
decorated.mockResolvedValue(value);
return decorated;
},
rejectWith(value: unknown) {
decorated.mockRejectedValue(value);
return decorated;
},
callFake(fn: (...args: unknown[]) => unknown) {
decorated.mockImplementation(fn);
return decorated;
},
callThrough() {
return decorated;
},
}),
});
Object.defineProperty(decorated, 'calls', {
configurable: true,
get: () => ({
argsFor(index: number) {
const calls = decorated.mock.calls as unknown[][];
return calls[index] ?? [];
},
mostRecent() {
const args = decorated.mock.lastCall ?? [];
return {args};
},
}),
});
return decorated;
}
function createSpy(name?: string): MockFunction {
const spy = decorateMock(vi.fn());
if (name) {
spy.mockName(name);
}
return spy;
}
function createSpyObj<T>(baseName: string, methodNames: string[] | Record<string, unknown>, propertyValues?: Record<string, unknown>): T {
const result: Record<string, unknown> = {};
const methods = Array.isArray(methodNames) ? methodNames : Object.keys(methodNames);
for (const methodName of methods) {
result[methodName] = createSpy(`${baseName}.${methodName}`);
}
if (!Array.isArray(methodNames)) {
for (const [key, value] of Object.entries(methodNames)) {
(result[key] as MockFunction).and.returnValue(value);
}
}
if (propertyValues) {
for (const [key, value] of Object.entries(propertyValues)) {
result[key] = value;
}
}
return result as T;
}
function spyOnCompat<T extends object, K extends keyof T>(object: T, methodName: K): MockFunction {
const spy = vi.spyOn(object as Record<PropertyKey, (...args: unknown[]) => unknown>, methodName as PropertyKey);
return decorateMock(spy as unknown as ReturnType<typeof vi.fn>);
}
function expectAsyncCompat<T>(value: Promise<T>) {
return {
async toBeResolvedTo(expected: T) {
await expect(value).resolves.toEqual(expected);
},
async toBeRejectedWithError(expected?: string | RegExp | Error) {
if (expected instanceof Error) {
await expect(value).rejects.toThrowError(expected.message);
} else if (expected !== undefined) {
await expect(value).rejects.toThrowError(expected);
} else {
await expect(value).rejects.toThrowError();
}
},
};
}
expect.extend({
toBeTrue(received: unknown) {
return {
pass: received === true,
message: () => `expected ${String(received)} to be true`,
};
},
toBeFalse(received: unknown) {
return {
pass: received === false,
message: () => `expected ${String(received)} to be false`,
};
},
});
Object.assign(globalThis, {
spyOn: spyOnCompat,
expectAsync: expectAsyncCompat,
jasmine: {
createSpy,
createSpyObj,
any: expect.any,
anything: expect.anything,
objectContaining: expect.objectContaining,
stringMatching: expect.stringMatching,
},
});
-43
View File
@@ -1,43 +0,0 @@
type UnknownFunction = (...args: unknown[]) => unknown;
type SpyAnd = {
returnValue(value?: unknown): jasmine.Spy;
resolveTo(value?: unknown): jasmine.Spy;
rejectWith(value?: unknown): jasmine.Spy;
callFake(fn: UnknownFunction): jasmine.Spy;
callThrough(): jasmine.Spy;
};
type SpyCalls = {
argsFor(index: number): unknown[];
mostRecent(): {args: unknown[]};
};
declare global {
function spyOn<T extends object, K extends keyof T>(object: T, methodName: K): jasmine.Spy;
function expectAsync<T>(value: Promise<T>): {
toBeResolvedTo(expected: T): Promise<void>;
toBeRejectedWithError(expected?: string | RegExp | Error): Promise<void>;
};
namespace jasmine {
type Spy<T extends UnknownFunction = UnknownFunction> = T &
ReturnType<(typeof import('vitest'))['vi']['fn']> & {
and: SpyAnd;
calls: SpyCalls;
};
type SpyObj<T> = {
[K in keyof T]: T[K] extends UnknownFunction ? Spy<T[K]> : T[K];
};
function createSpy(name?: string): Spy;
function createSpyObj<T>(baseName: string, methodNames: string[] | Record<string, unknown>, propertyValues?: Record<string, unknown>): SpyObj<T>;
function any(expectedClass: unknown): unknown;
function anything(): unknown;
function objectContaining<T>(value: Partial<T>): unknown;
function stringMatching(value: string | RegExp): unknown;
}
}
export {};
-14
View File
@@ -1,14 +0,0 @@
import 'vitest';
declare module 'vitest' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Assertion<T = any> {
toBeTrue(): T;
toBeFalse(): T;
}
interface AsymmetricMatchersContaining {
toBeTrue(): void;
toBeFalse(): void;
}
}