diff --git a/package.json b/package.json index 1ccfd7e..cf1d01f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:unit:watch": "vitest" }, "devDependencies": { + "@iconify-json/mdi": "^1.2.0", "@playwright/test": "^1.28.1", "@skeletonlabs/skeleton": "2.10.2", "@skeletonlabs/tw-plugin": "0.4.0", @@ -42,6 +43,7 @@ "tailwindcss": "3.4.12", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", + "unplugin-icons": "^0.19.3", "vite": "^5.4.8", "vite-plugin-tailwind-purgecss": "0.3.3", "vitest": "^2.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7500c2f..13a3572 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: ^10.13.2 version: 10.13.2 devDependencies: + '@iconify-json/mdi': + specifier: ^1.2.0 + version: 1.2.0 '@playwright/test': specifier: ^1.28.1 version: 1.47.2 @@ -81,6 +84,9 @@ importers: typescript-eslint: specifier: ^8.0.0 version: 8.6.0(eslint@9.11.0(jiti@1.21.6))(typescript@5.6.2) + unplugin-icons: + specifier: ^0.19.3 + version: 0.19.3 vite: specifier: ^5.4.8 version: 5.4.8(@types/node@22.5.5) @@ -101,6 +107,12 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@antfu/install-pkg@0.4.1': + resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@babel/helper-string-parser@7.24.8': resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} engines: {node: '>=6.9.0'} @@ -495,6 +507,15 @@ packages: resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} engines: {node: '>=18.18'} + '@iconify-json/mdi@1.2.0': + resolution: {integrity: sha512-E9/3l5Syg3wfuarorFodhn4s8YorxhH3U3U20LaNBNiqw1kFNIDWhF6HymuzAD35k7RH0OBasJ+ZUyFtVVV6eg==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@2.1.33': + resolution: {integrity: sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -960,6 +981,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.7: + resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -1338,6 +1362,9 @@ packages: known-css-properties@0.34.0: resolution: {integrity: sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==} + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1353,6 +1380,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + local-pkg@0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} + engines: {node: '>=14'} + locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -1407,6 +1438,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.7.1: + resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1463,6 +1497,9 @@ packages: package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + package-manager-detector@0.2.0: + resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1513,6 +1550,9 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pkg-types@1.2.0: + resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + playwright-core@1.47.2: resolution: {integrity: sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==} engines: {node: '>=18'} @@ -1842,6 +1882,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -1849,6 +1892,35 @@ packages: resolution: {integrity: sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==} engines: {node: '>=18.17'} + unplugin-icons@0.19.3: + resolution: {integrity: sha512-EUegRmsAI6+rrYr0vXjFlIP+lg4fSC4zb62zAZKx8FGXlWAGgEGBCa3JDe27aRAXhistObLPbBPhwa/0jYLFkQ==} + peerDependencies: + '@svgr/core': '>=7.0.0' + '@svgx/core': ^1.0.1 + '@vue/compiler-sfc': ^3.0.2 || ^2.7.0 + vue-template-compiler: ^2.6.12 + vue-template-es2015-compiler: ^1.9.0 + peerDependenciesMeta: + '@svgr/core': + optional: true + '@svgx/core': + optional: true + '@vue/compiler-sfc': + optional: true + vue-template-compiler: + optional: true + vue-template-es2015-compiler: + optional: true + + unplugin@1.14.1: + resolution: {integrity: sha512-lBlHbfSFPToDYp9pjXlUEFVxYLaue9f9T1HC+4OHlmj+HnMDdz9oZY+erXfoCe/5V/7gKUSY2jpXPb9S7f0f/w==} + engines: {node: '>=14.0.0'} + peerDependencies: + webpack-sources: ^3 + peerDependenciesMeta: + webpack-sources: + optional: true + update-browserslist-db@1.1.0: resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true @@ -1936,6 +2008,9 @@ packages: jsdom: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} engines: {node: '>=0.8.0'} @@ -2000,6 +2075,13 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@antfu/install-pkg@0.4.1': + dependencies: + package-manager-detector: 0.2.0 + tinyexec: 0.3.0 + + '@antfu/utils@0.7.10': {} + '@babel/helper-string-parser@7.24.8': {} '@babel/helper-validator-identifier@7.24.7': {} @@ -2451,6 +2533,24 @@ snapshots: '@humanwhocodes/retry@0.3.0': {} + '@iconify-json/mdi@1.2.0': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@2.1.33': + dependencies: + '@antfu/install-pkg': 0.4.1 + '@antfu/utils': 0.7.10 + '@iconify/types': 2.0.0 + debug: 4.3.7 + kolorist: 1.8.0 + local-pkg: 0.5.0 + mlly: 1.7.1 + transitivePeerDependencies: + - supports-color + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2927,6 +3027,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.7: {} + cookie@0.6.0: {} cross-spawn@7.0.3: @@ -3335,6 +3437,8 @@ snapshots: known-css-properties@0.34.0: {} + kolorist@1.8.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3346,6 +3450,11 @@ snapshots: lines-and-columns@1.2.4: {} + local-pkg@0.5.0: + dependencies: + mlly: 1.7.1 + pkg-types: 1.2.0 + locate-character@3.0.0: {} locate-path@6.0.0: @@ -3397,6 +3506,13 @@ snapshots: minipass@7.1.2: {} + mlly@1.7.1: + dependencies: + acorn: 8.12.1 + pathe: 1.1.2 + pkg-types: 1.2.0 + ufo: 1.5.4 + mri@1.2.0: {} mrmime@2.0.0: {} @@ -3442,6 +3558,8 @@ snapshots: package-json-from-dist@1.0.0: {} + package-manager-detector@0.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3484,6 +3602,12 @@ snapshots: pirates@4.0.6: {} + pkg-types@1.2.0: + dependencies: + confbox: 0.1.7 + mlly: 1.7.1 + pathe: 1.1.2 + playwright-core@1.47.2: {} playwright@1.47.2: @@ -3836,10 +3960,30 @@ snapshots: typescript@5.6.2: {} + ufo@1.5.4: {} + undici-types@6.19.8: {} undici@6.19.7: {} + unplugin-icons@0.19.3: + dependencies: + '@antfu/install-pkg': 0.4.1 + '@antfu/utils': 0.7.10 + '@iconify/utils': 2.1.33 + debug: 4.3.7 + kolorist: 1.8.0 + local-pkg: 0.5.0 + unplugin: 1.14.1 + transitivePeerDependencies: + - supports-color + - webpack-sources + + unplugin@1.14.1: + dependencies: + acorn: 8.12.1 + webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: browserslist: 4.23.3 @@ -3926,6 +4070,8 @@ snapshots: - supports-color - terser + webpack-virtual-modules@0.6.2: {} + websocket-driver@0.7.4: dependencies: http-parser-js: 0.5.8 diff --git a/src/app.html b/src/app.html index 9278217..dde3175 100644 --- a/src/app.html +++ b/src/app.html @@ -1,12 +1,12 @@ - + %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/lib/assets/medieval-sheet-music.png b/src/lib/assets/medieval-sheet-music.png new file mode 100644 index 0000000..58464e9 Binary files /dev/null and b/src/lib/assets/medieval-sheet-music.png differ diff --git a/src/lib/components/auth/LoginForm.svelte b/src/lib/components/auth/LoginForm.svelte new file mode 100644 index 0000000..cb4c89d --- /dev/null +++ b/src/lib/components/auth/LoginForm.svelte @@ -0,0 +1,91 @@ + + +
+
+ +
+

Ars Antiqua Online

+

+ Explore the rich history of medieval music. +

+ + + + + {#if error} +

{error}

+ {/if} + +
+
    +
  • 📝 Access a collection of medieval manuscripts
  • +
  • 🎶 Create and explore custom editions of historical works
  • +
  • 📚 Compare multiple interpretations of polyphonic music
  • +
+
+ +

+ Your privacy is important. We use secure Google authentication. +

+ +

+ Learn more about the project here. +

+
+
+ + diff --git a/src/lib/firebase/app.test.ts b/src/lib/firebase/app.test.ts index a38dd6e..329546a 100644 --- a/src/lib/firebase/app.test.ts +++ b/src/lib/firebase/app.test.ts @@ -1,39 +1,43 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { App } from './app'; +import { initializeApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth'; -const mockInitializeApp = vi.fn(() => ({ name: 'mocked-app' })); - -vi.mock('./config', () => ({ - firebaseConfig: { - apiKey: 'mock-api-key', - authDomain: 'mock-auth-domain', - projectId: "mock-project-id", - storageBucket: "mock-storage-bucket", - messagingSenderId: "mock-messaging-sender-id", - appId: "mock-app-id", - measurementId: "mock-measurement-id" - } +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn() })); -vi.mock('firebase/app', () => ({ - initializeApp: mockInitializeApp +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn() })); -describe('Firebase app initialization', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it('initializes Firebase with the correct config', async () => { - const { app } = await import('./app'); - expect(mockInitializeApp).toHaveBeenCalledTimes(1); - expect(mockInitializeApp).toHaveBeenCalledWith(expect.objectContaining({ - apiKey: 'mock-api-key', - authDomain: 'mock-auth-domain', - projectId: "mock-project-id", - storageBucket: "mock-storage-bucket", - messagingSenderId: "mock-messaging-sender-id", - appId: "mock-app-id", - measurementId: "mock-measurement-id" - })); - expect(app).toEqual({ name: 'mocked-app' }); - }); -}); \ No newline at end of file +describe('App', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset the singleton instance to test it properly + (App as any).instance = undefined; + }); + + it('should initialize Firebase app only once', () => { + const mockAuth = { auth: 'mockAuthInstance' }; + (getAuth as any).mockReturnValue(mockAuth); + + const app1 = App.getInstance(); + const app2 = App.getInstance(); + + expect(initializeApp).toHaveBeenCalledTimes(1); + expect(getAuth).toHaveBeenCalledTimes(1); + expect(app1).toBe(app2); // Both instances should be the same (singleton pattern) + }); + + it('should return the Firebase auth instance', () => { + const mockAuth = { auth: 'mockAuthInstance' }; + (getAuth as any).mockReturnValue(mockAuth); + + const app = App.getInstance(); + const authInstance = app.getAuthInstance(); + + expect(authInstance).toEqual(mockAuth); + expect(getAuth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/firebase/app.ts b/src/lib/firebase/app.ts index 158a469..eb9be58 100644 --- a/src/lib/firebase/app.ts +++ b/src/lib/firebase/app.ts @@ -1,4 +1,25 @@ import { initializeApp } from 'firebase/app'; import { firebaseConfig } from './config'; +import { getAuth, type Auth } from 'firebase/auth'; -export const app = initializeApp(firebaseConfig); \ No newline at end of file +export class App { + private static instance: App; + private auth: Auth; + + private constructor() { + const app = initializeApp(firebaseConfig); + this.auth = getAuth(app); + } + + // Ensure App is a singleton so it initializes only once + public static getInstance(): App { + if (!App.instance) { + App.instance = new App(); + } + return App.instance; + } + + public getAuthInstance(): Auth { + return this.auth; + } +} diff --git a/src/lib/firebase/auth.test.ts b/src/lib/firebase/auth.test.ts index 2d94649..ee624f8 100644 --- a/src/lib/firebase/auth.test.ts +++ b/src/lib/firebase/auth.test.ts @@ -1,14 +1,86 @@ -import { describe, it, expect, vi } from 'vitest'; -import { getAuth } from 'firebase/auth'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FirebaseAuthModule } from './auth'; +import { signInWithPopup, signOut, onAuthStateChanged } from 'firebase/auth'; vi.mock('firebase/auth', () => ({ - getAuth: vi.fn(() => ({})), + GoogleAuthProvider: vi.fn(), + signInWithPopup: vi.fn(), + signOut: vi.fn(), + onAuthStateChanged: vi.fn() })); -describe('Firebase Auth Initialization', () => { - it('should call getAuth and initialize auth', async () => { - const { auth } = await import('./auth'); - expect(getAuth).toHaveBeenCalled(); - expect(auth).toBeDefined(); - }); +vi.mock('./app', () => ({ + App: { + getInstance: vi.fn(() => ({ + getAuthInstance: vi.fn(() => ({})) + })) + } +})); + +describe('FirebaseAuthModule', () => { + let authModule: FirebaseAuthModule; + + beforeEach(() => { + authModule = new FirebaseAuthModule(); + vi.clearAllMocks(); // Reset mocks before each test + }); + + it('should sign in with Google successfully', async () => { + const mockUser = { uid: '123', email: 'test@example.com' }; + const mockUserCredential = { user: mockUser }; + (signInWithPopup as any).mockResolvedValueOnce(mockUserCredential); + + const user = await authModule.signInWithGoogle(); + expect(signInWithPopup).toHaveBeenCalledTimes(1); + expect(user).toEqual(mockUser); + }); + + it('should handle error during sign in with Google', async () => { + const mockError = new Error('Google sign-in failed'); + (signInWithPopup as any).mockRejectedValueOnce(mockError); + + await expect(authModule.signInWithGoogle()).rejects.toThrow('Google sign-in failed'); + expect(signInWithPopup).toHaveBeenCalledTimes(1); + }); + + it('should sign out successfully', async () => { + (signOut as any).mockResolvedValueOnce(undefined); + + await authModule.signOut(); + expect(signOut).toHaveBeenCalledTimes(1); + }); + + it('should handle error during sign out', async () => { + const mockError = new Error('Sign out failed'); + (signOut as any).mockRejectedValueOnce(mockError); + + await expect(authModule.signOut()).rejects.toThrow('Sign out failed'); + expect(signOut).toHaveBeenCalledTimes(1); + }); + + it('should return the current user', () => { + const mockUser = { uid: '123', email: 'test@example.com' }; + Object.defineProperty(authModule['auth'], 'currentUser', { + get: () => mockUser + }); + + const currentUser = authModule.getCurrentUser(); + expect(currentUser).toEqual(mockUser); + }); + + it('should listen to auth state changes', () => { + const callback = vi.fn(); + const unsubscribe = vi.fn(); + (onAuthStateChanged as any).mockImplementationOnce( + (auth: any, cb: (user: { uid: string }) => void) => { + cb({ uid: '123' }); // Simulate a user being passed to callback + return unsubscribe; + } + ); + + const returnedUnsubscribe = authModule.onAuthStateChanged(callback); + expect(onAuthStateChanged).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ uid: '123' }); + expect(returnedUnsubscribe).toBe(unsubscribe); + }); }); diff --git a/src/lib/firebase/auth.ts b/src/lib/firebase/auth.ts index 8825ebb..944d9ab 100644 --- a/src/lib/firebase/auth.ts +++ b/src/lib/firebase/auth.ts @@ -1,3 +1,49 @@ -import { getAuth } from "firebase/auth"; +import { + GoogleAuthProvider, + onAuthStateChanged, + signInWithPopup, + signOut, + type Auth, + type User, + type UserCredential +} from 'firebase/auth'; +import { App } from './app'; -export const auth = getAuth(); \ No newline at end of file +export class FirebaseAuthModule { + private auth: Auth; + + constructor() { + const app = App.getInstance(); + this.auth = app.getAuthInstance(); + } + + public signInWithGoogle = async () => { + const provider: GoogleAuthProvider = new GoogleAuthProvider(); + try { + const result: UserCredential = await signInWithPopup(this.auth, provider); + return result.user; + } catch (error) { + console.error('Error signing in with Google', error); + throw error; + } + }; + + public signOut = async () => { + try { + await signOut(this.auth); + } catch (error) { + console.error('Error signing out', error); + throw error; + } + }; + + public getCurrentUser = (): User | null => { + return this.auth.currentUser; + }; + + public onAuthStateChanged = (callback: (user: User | null) => void): (() => void) => { + return onAuthStateChanged(this.auth, callback); + }; +} + +export const auth: FirebaseAuthModule = new FirebaseAuthModule(); diff --git a/src/lib/firebase/index.ts b/src/lib/firebase/index.ts index 9e54ca2..ccb20a6 100644 --- a/src/lib/firebase/index.ts +++ b/src/lib/firebase/index.ts @@ -1 +1 @@ -export { app } from './app'; \ No newline at end of file +export { auth } from './auth'; \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index c5877b7..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './firebase/app'; -export * from './firebase/auth'; -export * from './firebase/config'; -export * from './firebase/db'; -export * from './firebase/storage'; \ No newline at end of file diff --git a/src/lib/stores/user.ts b/src/lib/stores/user.ts new file mode 100644 index 0000000..df128d6 --- /dev/null +++ b/src/lib/stores/user.ts @@ -0,0 +1,24 @@ +import { writable } from 'svelte/store'; +import type { User } from 'firebase/auth'; +import { auth } from '$lib/firebase'; + +function createUserStore() { + let unsubscribe: (() => void) | undefined; + + const { subscribe, set, update } = writable<{ user: User | null; loading: boolean }>({ + user: null, + loading: true, // Add a loading state to wait for auth to resolve + }); + + if (typeof window !== 'undefined') { + unsubscribe = auth.onAuthStateChanged((user) => { + set({ user, loading: false }); // Once Firebase resolves, update user and loading + }); + } + + return { + subscribe, + }; +} + +export const userStore = createUserStore(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 35bc088..22a0b30 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,56 @@ - +
+ {#if loading} +
+
+ +
+

Loading...

+
+ {:else if authenticated} + + {:else} + + {/if} +
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b9dd138..17e0735 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,11 @@ +
-

Hello from Firebase (Preview 3)!

+

Hello {$userStore.user?.displayName ?? "User"}!

Start by exploring:

  • /src/routes/+layout.svelte - barebones layout
  • diff --git a/tailwind.config.ts b/tailwind.config.ts index 633b35c..b7c3f75 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -16,7 +16,7 @@ export default { themes: { preset: [ { - name: 'skeleton', + name: 'hamlindigo', enhancements: true } ] diff --git a/tests/test.ts b/tests/test.ts index 9985ce1..b24a74d 100644 --- a/tests/test.ts +++ b/tests/test.ts @@ -1,6 +1,116 @@ -import { expect, test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import type { User } from 'firebase/auth'; -test('home page has expected h1', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1')).toBeVisible(); +test.describe('Ars Antiqua Online Login Page', () => { + test('should render correctly and handle Google Sign-In success', async ({ page }) => { + // Mock the FirebaseAuthModule for successful sign-in + await page.addInitScript(() => { + class MockFirebaseAuthModule { + private mockUser: User | null = null; + public signInWithGoogle = async () => { + this.mockUser = { uid: '123', email: 'test@example.com' } as User; + return this.mockUser; + }; + public getCurrentUser = () => { + return this.mockUser; + }; + } + (window as any).auth = new MockFirebaseAuthModule(); + }); + + // Navigate to the page + await page.goto('/'); + + // Check main heading + await expect(page.locator('h1')).toHaveText('Ars Antiqua Online'); + + // Check description + await expect(page.getByText('Explore the rich history of medieval music.')).toBeVisible(); + + // Check Google Sign-In button + const signInButton = page.getByRole('button', { name: 'Sign in with Google' }); + await expect(signInButton).toBeVisible(); + + // Check feature list + const featureList = page.locator('ul'); + await expect(featureList).toBeVisible(); + const featureItems = await featureList.locator('li').all(); + expect(featureItems).toHaveLength(3); + await expect(featureItems[0]).toContainText('Access a collection of medieval manuscripts'); + await expect(featureItems[1]).toContainText( + 'Create and explore custom editions of historical works' + ); + await expect(featureItems[2]).toContainText( + 'Compare multiple interpretations of polyphonic music' + ); + + // Check privacy notice + await expect(page.getByText('Your privacy is important.')).toBeVisible(); + + // Check "Learn more" link + const learnMoreLink = page.getByRole('link', { name: 'here' }); + await expect(learnMoreLink).toHaveAttribute('href', '/about'); + + // Check background image + const backgroundImage = page.locator('.background-image'); + await expect(backgroundImage).toBeVisible(); + const backgroundImageStyle = await backgroundImage.evaluate( + (el) => window.getComputedStyle(el).backgroundImage + ); + expect(backgroundImageStyle).toContain('medieval-sheet-music'); + + // Test successful Google Sign-In + const signInPromise = page.evaluate(() => (window as any).auth.signInWithGoogle()); + await signInButton.click(); + await signInPromise; + + // Verify that the user is signed in + const currentUser = await page.evaluate(() => (window as any).auth.getCurrentUser()); + expect(currentUser).toEqual( + expect.objectContaining({ + uid: '123', + email: 'test@example.com' + }) + ); + }); + + test('should handle Google Sign-In error', async ({ page }) => { + // Mock the FirebaseAuthModule to simulate an error + await page.addInitScript(() => { + class MockFirebaseAuthModule { + public signInWithGoogle = async () => { + throw new Error('Error signing in with Google'); + }; + + public getCurrentUser = () => { + return null; + }; + } + + (window as any).auth = new MockFirebaseAuthModule(); + }); + + await page.goto('/'); + + const signInButton = page.getByRole('button', { name: 'Sign in with Google' }); + await expect(signInButton).toBeVisible(); + + let errorMessage = ''; + try { + await page.evaluate(() => (window as any).auth.signInWithGoogle()); + } catch (error) { + errorMessage = (error as Error).message; + } + + expect(errorMessage).toContain('Error signing in with Google'); + + const currentUser = await page.evaluate(() => (window as any).auth.getCurrentUser()); + expect(currentUser).toBeNull(); + + // Check if the error is displayed on the page (adjust selector as needed) + const errorElement = page.locator('.error-message'); + if (await errorElement.isVisible()) { + await expect(errorElement).toContainText('Error signing in with Google'); + } + }); }); diff --git a/vite.config.ts b/vite.config.ts index 59e7cb6..d78cfdb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,13 @@ import { purgeCss } from 'vite-plugin-tailwind-purgecss'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import Icons from 'unplugin-icons/vite'; export default defineConfig({ - plugins: [sveltekit(), purgeCss()], + plugins: [sveltekit(), purgeCss(), Icons({ + compiler: 'svelte', + autoInstall: true, + })], test: { include: ['src/**/*.{test,spec}.{js,ts}'], exclude: ['node_modules', '.svelte-kit', 'static'], @@ -20,6 +24,7 @@ export default defineConfig({ '**/types/**', '**/*.svelte', '**/index.ts', + '**/lib/stores/**', // TODO: Fix coverage for stores ], include: ['src/**/*.ts', 'src/**/*.js'], },