From f9b8179e6f03ac16da560673d88c68659724183d Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Mon, 2 Mar 2026 09:05:33 -0700 Subject: [PATCH] test: add coverage for GitHubOAuthService and FrontmatterEditor Add 26 new tests covering two previously untested modules: - GitHubOAuthService (14 tests): localStorage restore/corrupt JSON, cancelFlow, signOut, full device flow success, access_denied, expired_token, 3-strike consecutive failures (HTTP + network), unknown error with error_description, non-fatal profile fetch failure - FrontmatterEditorComponent (12 tests): availableModules computed filtering (excludes current module + existing deps), addFile/addTable/ addDependency with empty-input guard, removeFile/removeDependency by index, onFieldChange via DOM input, immutability of original frontmatter Co-Authored-By: Claude Opus 4.6 --- .../frontmatter-editor.spec.ts | 246 ++++++++++++++ src/app/services/github-oauth.service.spec.ts | 309 ++++++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 src/app/components/frontmatter-editor/frontmatter-editor.spec.ts create mode 100644 src/app/services/github-oauth.service.spec.ts diff --git a/src/app/components/frontmatter-editor/frontmatter-editor.spec.ts b/src/app/components/frontmatter-editor/frontmatter-editor.spec.ts new file mode 100644 index 0000000..bdee73f --- /dev/null +++ b/src/app/components/frontmatter-editor/frontmatter-editor.spec.ts @@ -0,0 +1,246 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FrontmatterEditorComponent } from './frontmatter-editor'; +import { type SpecFrontmatter } from '../../models/spec.model'; + +@Component({ + standalone: true, + imports: [FrontmatterEditorComponent], + template: ``, +}) +class TestHostComponent { + frontmatter = signal({ + module: 'auth-service', + version: 1, + status: 'draft', + files: [], + db_tables: [], + depends_on: [], + }); + knownModules = signal([]); + lastEmitted: SpecFrontmatter | null = null; + onFrontmatterChange(fm: SpecFrontmatter): void { + this.lastEmitted = fm; + } +} + +describe('FrontmatterEditorComponent', () => { + let host: TestHostComponent; + let fixture: ReturnType>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(host).toBeTruthy(); + }); + + describe('availableModules computed', () => { + it('should exclude current module from suggestions', () => { + host.knownModules.set(['auth-service', 'user-service', 'db-service']); + fixture.detectChanges(); + + const datalist = fixture.nativeElement.querySelector('#dep-suggestions'); + const options = datalist.querySelectorAll('option'); + const values = Array.from(options).map((o: any) => o.value); + + expect(values).toEqual(['user-service', 'db-service']); + expect(values).not.toContain('auth-service'); + }); + + it('should exclude already-added dependencies from suggestions', () => { + host.knownModules.set(['auth-service', 'user-service', 'db-service', 'cache-service']); + host.frontmatter.set({ + ...host.frontmatter(), + depends_on: ['user-service'], + }); + fixture.detectChanges(); + + const datalist = fixture.nativeElement.querySelector('#dep-suggestions'); + const options = datalist.querySelectorAll('option'); + const values = Array.from(options).map((o: any) => o.value); + + expect(values).toEqual(['db-service', 'cache-service']); + expect(values).not.toContain('auth-service'); // current module + expect(values).not.toContain('user-service'); // already added + }); + }); + + describe('addFile', () => { + it('should emit updated frontmatter with new file added', () => { + const input = fixture.nativeElement.querySelector('#fm-new-file') as HTMLInputElement; + const addBtn = fixture.nativeElement.querySelector( + 'fieldset:nth-of-type(1) .add-row button', + ) as HTMLButtonElement; + + input.value = 'server/auth.ts'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + addBtn.click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeTruthy(); + expect(host.lastEmitted!.files).toEqual(['server/auth.ts']); + }); + + it('should not emit when input is empty or whitespace', () => { + const input = fixture.nativeElement.querySelector('#fm-new-file') as HTMLInputElement; + const addBtn = fixture.nativeElement.querySelector( + 'fieldset:nth-of-type(1) .add-row button', + ) as HTMLButtonElement; + + input.value = ' '; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + addBtn.click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeNull(); + }); + }); + + describe('removeFile', () => { + it('should emit frontmatter without the removed file', () => { + host.frontmatter.set({ + ...host.frontmatter(), + files: ['file-a.ts', 'file-b.ts', 'file-c.ts'], + }); + fixture.detectChanges(); + + // Click the remove button for the second file (index 1) + const removeButtons = fixture.nativeElement.querySelectorAll( + 'fieldset:nth-of-type(1) .list-item button', + ); + expect(removeButtons.length).toBe(3); + removeButtons[1].click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeTruthy(); + expect(host.lastEmitted!.files).toEqual(['file-a.ts', 'file-c.ts']); + }); + }); + + describe('addTable', () => { + it('should emit updated frontmatter with new table added', () => { + const input = fixture.nativeElement.querySelector('#fm-new-table') as HTMLInputElement; + const addBtn = fixture.nativeElement.querySelector( + 'fieldset:nth-of-type(2) .add-row button', + ) as HTMLButtonElement; + + input.value = 'sessions'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + addBtn.click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeTruthy(); + expect(host.lastEmitted!.db_tables).toEqual(['sessions']); + }); + }); + + describe('addDependency', () => { + it('should emit updated frontmatter with new dependency added', () => { + const input = fixture.nativeElement.querySelector('#fm-new-dep') as HTMLInputElement; + const addBtn = fixture.nativeElement.querySelector( + 'fieldset:nth-of-type(3) .add-row button', + ) as HTMLButtonElement; + + input.value = 'user-service'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + addBtn.click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeTruthy(); + expect(host.lastEmitted!.depends_on).toEqual(['user-service']); + }); + + it('should not emit when dependency input is empty', () => { + const input = fixture.nativeElement.querySelector('#fm-new-dep') as HTMLInputElement; + const addBtn = fixture.nativeElement.querySelector( + 'fieldset:nth-of-type(3) .add-row button', + ) as HTMLButtonElement; + + input.value = ''; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + addBtn.click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeNull(); + }); + }); + + describe('removeDependency', () => { + it('should emit frontmatter without the removed dependency', () => { + host.frontmatter.set({ + ...host.frontmatter(), + depends_on: ['dep-a', 'dep-b'], + }); + fixture.detectChanges(); + + const removeButtons = fixture.nativeElement.querySelectorAll( + 'fieldset:nth-of-type(3) .list-item button', + ); + expect(removeButtons.length).toBe(2); + removeButtons[0].click(); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeTruthy(); + expect(host.lastEmitted!.depends_on).toEqual(['dep-b']); + }); + }); + + describe('onFieldChange', () => { + it('should emit when module name is changed via input', () => { + const input = fixture.nativeElement.querySelector('#fm-module') as HTMLInputElement; + input.value = 'new-module-name'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(host.lastEmitted).toBeTruthy(); + expect(host.lastEmitted!.module).toBe('new-module-name'); + // Other fields should remain unchanged + expect(host.lastEmitted!.version).toBe(1); + expect(host.lastEmitted!.status).toBe('draft'); + }); + }); + + describe('immutability', () => { + it('should not mutate the original frontmatter when adding a file', () => { + const original = host.frontmatter(); + const originalFiles = [...original.files]; + + const input = fixture.nativeElement.querySelector('#fm-new-file') as HTMLInputElement; + const addBtn = fixture.nativeElement.querySelector( + 'fieldset:nth-of-type(1) .add-row button', + ) as HTMLButtonElement; + + input.value = 'new-file.ts'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + addBtn.click(); + fixture.detectChanges(); + + // Original frontmatter should be untouched + expect(host.frontmatter().files).toEqual(originalFiles); + expect(host.lastEmitted!.files).toEqual(['new-file.ts']); + }); + }); +}); diff --git a/src/app/services/github-oauth.service.spec.ts b/src/app/services/github-oauth.service.spec.ts new file mode 100644 index 0000000..8ab65d4 --- /dev/null +++ b/src/app/services/github-oauth.service.spec.ts @@ -0,0 +1,309 @@ +import { TestBed } from '@angular/core/testing'; +import { GitHubOAuthService } from './github-oauth.service'; +import { environment } from '../../environments/environment'; + +describe('GitHubOAuthService', () => { + let service: GitHubOAuthService; + let fetchSpy: ReturnType; + let store: Record; + + beforeEach(() => { + store = {}; + vi.stubGlobal('localStorage', { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { + store[key] = val; + }, + removeItem: (key: string) => { + delete store[key]; + }, + }); + + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + // Stub window.open to prevent actual navigation + vi.stubGlobal('open', vi.fn()); + + TestBed.configureTestingModule({ + providers: [GitHubOAuthService], + }); + + service = TestBed.inject(GitHubOAuthService); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should start idle when no token in localStorage', () => { + expect(service.state()).toBe('idle'); + expect(service.accessToken()).toBeNull(); + expect(service.authenticated()).toBe(false); + }); + + it('should restore authenticated state from localStorage', () => { + store['specl:github-oauth-token'] = 'saved-token'; + store['specl:github-oauth-user'] = JSON.stringify({ + login: 'octocat', + avatar_url: 'https://github.com/octocat.png', + }); + + // Re-create the service so constructor picks up stored values + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [GitHubOAuthService] }); + const restored = TestBed.inject(GitHubOAuthService); + + expect(restored.state()).toBe('authenticated'); + expect(restored.accessToken()).toBe('saved-token'); + expect(restored.username()).toBe('octocat'); + expect(restored.avatarUrl()).toBe('https://github.com/octocat.png'); + expect(restored.authenticated()).toBe(true); + }); + + it('should handle corrupt JSON in localStorage gracefully', () => { + store['specl:github-oauth-token'] = 'some-token'; + store['specl:github-oauth-user'] = '{broken json'; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [GitHubOAuthService] }); + const restored = TestBed.inject(GitHubOAuthService); + + // Token restores fine, but user parse fails gracefully + expect(restored.state()).toBe('authenticated'); + expect(restored.accessToken()).toBe('some-token'); + expect(restored.username()).toBeNull(); + expect(restored.avatarUrl()).toBeNull(); + }); + }); + + describe('cancelFlow', () => { + it('should reset all flow state to idle', () => { + service.cancelFlow(); + expect(service.state()).toBe('idle'); + expect(service.userCode()).toBeNull(); + expect(service.verificationUri()).toBeNull(); + expect(service.error()).toBeNull(); + }); + }); + + describe('signOut', () => { + it('should clear token and user info from signals and localStorage', () => { + store['specl:github-oauth-token'] = 'my-token'; + store['specl:github-oauth-user'] = JSON.stringify({ login: 'u', avatar_url: 'a' }); + + service.signOut(); + + expect(service.state()).toBe('idle'); + expect(service.accessToken()).toBeNull(); + expect(service.username()).toBeNull(); + expect(service.avatarUrl()).toBeNull(); + expect(store['specl:github-oauth-token']).toBeUndefined(); + expect(store['specl:github-oauth-user']).toBeUndefined(); + }); + }); + + describe('startDeviceFlow', () => { + it('should throw when GITHUB_CLIENT_ID is empty', async () => { + const original = environment.GITHUB_CLIENT_ID; + (environment as { GITHUB_CLIENT_ID: string }).GITHUB_CLIENT_ID = ''; + + await expect(service.startDeviceFlow()).rejects.toThrow( + 'GitHub OAuth Client ID not configured', + ); + + (environment as { GITHUB_CLIENT_ID: string }).GITHUB_CLIENT_ID = original; + }); + + it('should set error state when device code request fails', async () => { + fetchSpy.mockResolvedValueOnce({ ok: false, status: 500 }); + + await service.startDeviceFlow(); + + expect(service.state()).toBe('error'); + expect(service.error()).toContain('HTTP 500'); + }); + + it('should complete full flow with successful token', async () => { + // Step 1: device code response + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-123', + user_code: 'ABCD-1234', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, // 0 seconds for fast test + }), + }); + + // Step 2: token response — immediate success + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'gho_abc123' }), + }); + + // Step 3: user profile fetch + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ login: 'testuser', avatar_url: 'https://example.com/avatar.png' }), + }); + + await service.startDeviceFlow(); + + expect(service.state()).toBe('authenticated'); + expect(service.accessToken()).toBe('gho_abc123'); + expect(service.username()).toBe('testuser'); + expect(service.avatarUrl()).toBe('https://example.com/avatar.png'); + expect(service.userCode()).toBeNull(); // Cleared after success + expect(store['specl:github-oauth-token']).toBe('gho_abc123'); + }); + + it('should handle access_denied error from GitHub', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-456', + user_code: 'EFGH-5678', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, + }), + }); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ error: 'access_denied' }), + }); + + await service.startDeviceFlow(); + + expect(service.state()).toBe('error'); + expect(service.error()).toBe('Authorization was denied'); + }); + + it('should handle expired_token error from GitHub', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-789', + user_code: 'IJKL-9012', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, + }), + }); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ error: 'expired_token' }), + }); + + await service.startDeviceFlow(); + + expect(service.state()).toBe('error'); + expect(service.error()).toContain('expired'); + }); + + it('should set error after 3 consecutive HTTP failures', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-fail', + user_code: 'FAIL-0000', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, + }), + }); + + // 3 consecutive non-ok responses + for (let i = 0; i < 3; i++) { + fetchSpy.mockResolvedValueOnce({ ok: false, status: 502 }); + } + + await service.startDeviceFlow(); + + expect(service.state()).toBe('error'); + expect(service.error()).toBe('Token polling failed after multiple attempts'); + }); + + it('should set error after 3 consecutive network errors', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-net', + user_code: 'NET-0000', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, + }), + }); + + // 3 consecutive fetch throws + for (let i = 0; i < 3; i++) { + fetchSpy.mockRejectedValueOnce(new Error('Network error')); + } + + await service.startDeviceFlow(); + + expect(service.state()).toBe('error'); + expect(service.error()).toBe('Token polling failed after multiple attempts'); + }); + + it('should handle unknown OAuth error with error_description', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-unk', + user_code: 'UNK-0000', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, + }), + }); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + error: 'some_custom_error', + error_description: 'Something went wrong on GitHub', + }), + }); + + await service.startDeviceFlow(); + + expect(service.state()).toBe('error'); + expect(service.error()).toBe('Something went wrong on GitHub'); + }); + + it('should handle non-fatal user profile fetch failure', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc-nf', + user_code: 'NF-0000', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0, + }), + }); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'gho_noprofile' }), + }); + + // Profile fetch fails + fetchSpy.mockResolvedValueOnce({ ok: false, status: 403 }); + + await service.startDeviceFlow(); + + // Should still be authenticated — profile failure is non-fatal + expect(service.state()).toBe('authenticated'); + expect(service.accessToken()).toBe('gho_noprofile'); + expect(service.username()).toBeNull(); + }); + }); +});