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();
+ });
+ });
+});