Compare commits
1 Commits
develop
...
ea28a87860
| Author | SHA1 | Date | |
|---|---|---|---|
|
ea28a87860
|
@@ -1 +1 @@
|
||||
NUXT_POCKETBASE_URL='http://localhost:127.0.0.1:8090/'
|
||||
NUXT_POCKETBASE_URL='http://127.0.0.1:8090/'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<UUser name="name" description="email" />
|
||||
<UUser v-if="user" :name="user.name" :description="user.email" :avatar="{ src: user.avatar }" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
const { user } = useAuth();
|
||||
</script>
|
||||
|
||||
17
app/components/auth/OAuthProvider.vue
Normal file
17
app/components/auth/OAuthProvider.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<UButton
|
||||
color="neutral"
|
||||
size="xl"
|
||||
class="flex cursor-pointer items-center justify-center gap-3 overflow-hidden"
|
||||
@click="login(provider.name)"
|
||||
>
|
||||
Continue with {{ provider.displayName }}
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { AuthProviderInfo } from 'pocketbase';
|
||||
|
||||
const { login } = useAuth();
|
||||
const { provider } = defineProps<{ provider: AuthProviderInfo }>();
|
||||
</script>
|
||||
@@ -1,3 +1,18 @@
|
||||
<template>
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-log-out" size="xl"> Log Out </UButton>
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-log-out" size="xl" @click="onLogout"> Log Out </UButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { logout } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const onLogout = () => {
|
||||
logout();
|
||||
navigateTo('/');
|
||||
toast.add({
|
||||
title: 'Successfully logged out!',
|
||||
description: 'You successfully logged out of your account and have been taken back to the website’s welcome page.',
|
||||
color: 'success',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
444
app/composables/__tests__/useAuth.test.ts
Normal file
444
app/composables/__tests__/useAuth.test.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
||||
import type { AuthProviderInfo, AuthModel, RecordModel } from 'pocketbase';
|
||||
|
||||
/**
|
||||
* Comprehensive tests for useAuth composable
|
||||
* Based on specs from private/specs.md section 3.1 (Authentication API)
|
||||
*
|
||||
* These tests verify actual behavior, not just API existence.
|
||||
*/
|
||||
|
||||
// Mock PocketBase
|
||||
const mockAuthStore = {
|
||||
isValid: false,
|
||||
record: null as AuthModel | null,
|
||||
clear: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCollection = vi.fn();
|
||||
const mockAuthWithOAuth2 = vi.fn();
|
||||
const mockListAuthMethods = vi.fn();
|
||||
const mockAuthRefresh = vi.fn();
|
||||
|
||||
vi.mock('../usePocketbase', () => ({
|
||||
usePocketbase: () => ({
|
||||
authStore: mockAuthStore,
|
||||
collection: mockCollection,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock router using Nuxt's test utils
|
||||
const mockRouterPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockRouterPush,
|
||||
};
|
||||
|
||||
mockNuxtImport('useRouter', () => {
|
||||
return () => mockRouter;
|
||||
});
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = null;
|
||||
|
||||
// Setup default mock implementations
|
||||
mockCollection.mockReturnValue({
|
||||
authWithOAuth2: mockAuthWithOAuth2,
|
||||
listAuthMethods: mockListAuthMethods,
|
||||
authRefresh: mockAuthRefresh,
|
||||
});
|
||||
|
||||
// Clear module cache to get fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('Composable Export', () => {
|
||||
it('should be exported as a function', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
expect(useAuth).toBeDefined();
|
||||
expect(typeof useAuth).toBe('function');
|
||||
});
|
||||
|
||||
it('should return an object with auth methods and state', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth).toBeDefined();
|
||||
expect(typeof auth).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with user as null', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.user.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should initialize with isAuthenticated as false', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with loading as false', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with error as null', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
expect(auth.error.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initAuth', () => {
|
||||
it('should sync user from authStore', async () => {
|
||||
const mockUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = mockUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.user.value).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should register onChange listener', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
expect(mockAuthStore.onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update user when authStore changes', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.initAuth();
|
||||
|
||||
// Get the onChange callback
|
||||
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
|
||||
|
||||
// Simulate auth change
|
||||
const newUser = {
|
||||
id: 'newUser456',
|
||||
email: 'new@example.com',
|
||||
};
|
||||
|
||||
onChangeCallback('token123', newUser);
|
||||
|
||||
expect(auth.user.value).toEqual(newUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
const mockProviders: AuthProviderInfo[] = [
|
||||
{
|
||||
name: 'google',
|
||||
displayName: 'Google',
|
||||
state: 'state123',
|
||||
codeVerifier: 'verifier',
|
||||
codeChallenge: 'challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
authURL: 'https://google.com/oauth',
|
||||
},
|
||||
{
|
||||
name: 'microsoft',
|
||||
displayName: 'Microsoft',
|
||||
state: 'state456',
|
||||
codeVerifier: 'verifier2',
|
||||
codeChallenge: 'challenge2',
|
||||
codeChallengeMethod: 'S256',
|
||||
authURL: 'https://microsoft.com/oauth',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: mockProviders,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set loading to true when login starts', async () => {
|
||||
mockAuthWithOAuth2.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
const loginPromise = auth.login('google');
|
||||
|
||||
expect(auth.loading.value).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await Promise.race([loginPromise, new Promise((resolve) => setTimeout(resolve, 10))]);
|
||||
});
|
||||
|
||||
it('should clear previous errors when starting new login', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// Set an error first
|
||||
auth.error.value = new Error('Previous error');
|
||||
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.error.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should call authWithOAuth2 with correct provider', async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'google' });
|
||||
});
|
||||
|
||||
it('should set loading to false after login completes', async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if provider is not configured', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('github'); // Not in mockProviders
|
||||
|
||||
expect(auth.error.value).toBeDefined();
|
||||
expect(auth.error.value?.message).toContain('github');
|
||||
expect(auth.error.value?.message).toContain('not configured');
|
||||
});
|
||||
|
||||
it('should handle OAuth errors gracefully', async () => {
|
||||
const mockError = new Error('OAuth failed');
|
||||
mockAuthWithOAuth2.mockRejectedValue(mockError);
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.error.value).toEqual(mockError);
|
||||
expect(auth.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should support multiple OAuth providers', async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValue({});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'google' });
|
||||
|
||||
await auth.login('microsoft');
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'microsoft' });
|
||||
});
|
||||
|
||||
it('should return empty array when OAuth is disabled', async () => {
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: false,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.login('google');
|
||||
|
||||
expect(auth.error.value).toBeDefined();
|
||||
expect(auth.error.value?.message).toContain('not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOAuthCallback', () => {
|
||||
it('should sync user from authStore', async () => {
|
||||
const mockUser: AuthModel = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as AuthModel;
|
||||
|
||||
mockAuthStore.record = mockUser;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.handleOAuthCallback();
|
||||
|
||||
expect(auth.user.value).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should redirect to dashboard when authenticated', async () => {
|
||||
mockAuthStore.record = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as RecordModel;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.handleOAuthCallback();
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
|
||||
it('should redirect to home when not authenticated', async () => {
|
||||
mockAuthStore.record = null;
|
||||
mockAuthStore.isValid = false;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.handleOAuthCallback();
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAuth', () => {
|
||||
it('should call authRefresh on users collection', async () => {
|
||||
mockAuthRefresh.mockResolvedValue({
|
||||
token: 'newToken',
|
||||
record: { id: 'user123', email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
await auth.refreshAuth();
|
||||
|
||||
expect(mockCollection).toHaveBeenCalledWith('users');
|
||||
expect(mockAuthRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the refresh result', async () => {
|
||||
const mockResult = {
|
||||
token: 'newToken',
|
||||
record: { id: 'user123', email: 'test@example.com' },
|
||||
};
|
||||
|
||||
mockAuthRefresh.mockResolvedValue(mockResult);
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
const result = await auth.refreshAuth();
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear authStore', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
auth.logout();
|
||||
|
||||
expect(mockAuthStore.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear authStore even when user is authenticated', async () => {
|
||||
mockAuthStore.record = {
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
} as unknown as RecordModel;
|
||||
mockAuthStore.isValid = true;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
auth.logout();
|
||||
|
||||
expect(mockAuthStore.clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated computed', () => {
|
||||
it('should be false when authStore is invalid', async () => {
|
||||
mockAuthStore.isValid = false;
|
||||
mockAuthStore.record = { id: 'user123', email: 'test@example.com' } as unknown as RecordModel;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false when user is null', async () => {
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = null;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when authStore is valid and user exists', async () => {
|
||||
mockAuthStore.isValid = true;
|
||||
mockAuthStore.record = { id: 'user123', email: 'test@example.com' } as unknown as RecordModel;
|
||||
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
await auth.initAuth();
|
||||
|
||||
expect(auth.isAuthenticated.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exposed API', () => {
|
||||
it('should expose all required methods and properties', async () => {
|
||||
const { useAuth } = await import('../useAuth');
|
||||
const auth = useAuth();
|
||||
|
||||
// State
|
||||
expect(auth.user).toBeDefined();
|
||||
expect(auth.isAuthenticated).toBeDefined();
|
||||
expect(auth.loading).toBeDefined();
|
||||
expect(auth.error).toBeDefined();
|
||||
|
||||
// Methods
|
||||
expect(typeof auth.initAuth).toBe('function');
|
||||
expect(typeof auth.login).toBe('function');
|
||||
expect(typeof auth.logout).toBe('function');
|
||||
expect(typeof auth.handleOAuthCallback).toBe('function');
|
||||
expect(typeof auth.refreshAuth).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
158
app/composables/__tests__/usePageTitle.test.ts
Normal file
158
app/composables/__tests__/usePageTitle.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { usePageTitle } from '../usePageTitle';
|
||||
|
||||
/**
|
||||
* Unit tests for usePageTitle composable
|
||||
*
|
||||
* This composable manages page titles throughout the application.
|
||||
*/
|
||||
|
||||
describe('usePageTitle', () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules to ensure clean state
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with default title "Tímmál"', () => {
|
||||
const { title } = usePageTitle();
|
||||
expect(title.value).toBe('Tímmál');
|
||||
});
|
||||
|
||||
it('should initialize with empty page name', () => {
|
||||
const { pageName } = usePageTitle();
|
||||
expect(pageName.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Setting Page Name', () => {
|
||||
it('should update page name when setPageName is called', () => {
|
||||
const { pageName, setPageName } = usePageTitle();
|
||||
|
||||
setPageName('Dashboard');
|
||||
|
||||
expect(pageName.value).toBe('Dashboard');
|
||||
});
|
||||
|
||||
it('should update title to include page name', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
|
||||
setPageName('Dashboard');
|
||||
|
||||
expect(title.value).toBe('Dashboard - Tímmál');
|
||||
});
|
||||
|
||||
it('should handle empty string gracefully', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
|
||||
// First set a name
|
||||
setPageName('Dashboard');
|
||||
expect(title.value).toBe('Dashboard - Tímmál');
|
||||
|
||||
// Then clear it
|
||||
setPageName('');
|
||||
expect(title.value).toBe('Tímmál');
|
||||
});
|
||||
|
||||
it('should handle multiple page name changes', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
|
||||
setPageName('Dashboard');
|
||||
expect(title.value).toBe('Dashboard - Tímmál');
|
||||
|
||||
setPageName('Projects');
|
||||
expect(title.value).toBe('Projects - Tímmál');
|
||||
|
||||
setPageName('Settings');
|
||||
expect(title.value).toBe('Settings - Tímmál');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Title Formatting', () => {
|
||||
it('should format title as "PageName - Tímmál" when page name is set', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
|
||||
setPageName('Reports');
|
||||
|
||||
expect(title.value).toBe('Reports - Tímmál');
|
||||
});
|
||||
|
||||
it('should format title as "Tímmál" when page name is empty', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
setPageName(null);
|
||||
|
||||
expect(title.value).toBe('Tímmál');
|
||||
});
|
||||
|
||||
it('should preserve special characters in page name', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
|
||||
setPageName('Reports & Analytics');
|
||||
|
||||
expect(title.value).toBe('Reports & Analytics - Tímmál');
|
||||
});
|
||||
|
||||
it('should preserve unicode characters', () => {
|
||||
const { title, setPageName } = usePageTitle();
|
||||
|
||||
setPageName('Paramètres');
|
||||
|
||||
expect(title.value).toBe('Paramètres - Tímmál');
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Sharing', () => {
|
||||
it('should share state across multiple calls', () => {
|
||||
const instance1 = usePageTitle();
|
||||
const instance2 = usePageTitle();
|
||||
|
||||
// Set via first instance
|
||||
instance1.setPageName('Dashboard');
|
||||
|
||||
// Should be visible in second instance
|
||||
expect(instance2.title.value).toBe('Dashboard - Tímmál');
|
||||
expect(instance2.pageName.value).toBe('Dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exposed API', () => {
|
||||
it('should expose title as computed', () => {
|
||||
const { title } = usePageTitle();
|
||||
|
||||
expect(title).toBeDefined();
|
||||
expect(title.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should expose pageName as readonly', () => {
|
||||
const { pageName } = usePageTitle();
|
||||
|
||||
expect(pageName).toBeDefined();
|
||||
expect(pageName.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should expose setPageName method', () => {
|
||||
const { setPageName } = usePageTitle();
|
||||
|
||||
expect(setPageName).toBeDefined();
|
||||
expect(typeof setPageName).toBe('function');
|
||||
});
|
||||
|
||||
it('title should be readonly (TypeScript enforced)', () => {
|
||||
const { title } = usePageTitle();
|
||||
|
||||
// readonly() is enforced by TypeScript, not at runtime
|
||||
// This test just verifies the property exists
|
||||
expect(title).toBeDefined();
|
||||
expect(title.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('pageName should be readonly (TypeScript enforced)', () => {
|
||||
const { pageName } = usePageTitle();
|
||||
|
||||
// readonly() is enforced by TypeScript, not at runtime
|
||||
// This test just verifies the property exists
|
||||
expect(pageName).toBeDefined();
|
||||
expect(pageName.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
85
app/composables/__tests__/usePocketbase.test.ts
Normal file
85
app/composables/__tests__/usePocketbase.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { usePocketbase } from '../usePocketbase';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
/**
|
||||
* Tests for usePocketbase composable
|
||||
*
|
||||
* This composable provides a singleton PocketBase client instance.
|
||||
*/
|
||||
|
||||
describe('usePocketbase', () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules to clear singleton between test suites
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('Instance Creation', () => {
|
||||
it('should return a PocketBase instance', () => {
|
||||
const pb = usePocketbase();
|
||||
expect(pb).toBeInstanceOf(PocketBase);
|
||||
});
|
||||
|
||||
it('should return the same instance on multiple calls (singleton)', () => {
|
||||
const pb1 = usePocketbase();
|
||||
const pb2 = usePocketbase();
|
||||
expect(pb1).toBe(pb2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should initialize with URL from runtime config or fallback to default', () => {
|
||||
const pb = usePocketbase();
|
||||
|
||||
// Should have a baseURL set (either from config or default)
|
||||
expect(pb.baseURL).toBeDefined();
|
||||
expect(typeof pb.baseURL).toBe('string');
|
||||
expect(pb.baseURL).toMatch(/^https?:\/\//); // Valid URL format
|
||||
});
|
||||
|
||||
it('should use a valid URL format', () => {
|
||||
const pb = usePocketbase();
|
||||
|
||||
// URL should be a valid HTTP/HTTPS URL
|
||||
expect(pb.baseURL).toMatch(/^https?:\/\/[\w\d.:]+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton Behavior', () => {
|
||||
it('should maintain singleton across multiple imports', () => {
|
||||
const pb1 = usePocketbase();
|
||||
const pb2 = usePocketbase();
|
||||
const pb3 = usePocketbase();
|
||||
|
||||
expect(pb1).toBe(pb2);
|
||||
expect(pb2).toBe(pb3);
|
||||
});
|
||||
|
||||
it('should share auth state across all consumers', () => {
|
||||
const pb1 = usePocketbase();
|
||||
const pb2 = usePocketbase();
|
||||
|
||||
// Both should share the same authStore
|
||||
expect(pb1.authStore).toBe(pb2.authStore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PocketBase Features', () => {
|
||||
it('should have authStore available', () => {
|
||||
const pb = usePocketbase();
|
||||
expect(pb.authStore).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have collection method available', () => {
|
||||
const pb = usePocketbase();
|
||||
expect(pb.collection).toBeDefined();
|
||||
expect(typeof pb.collection).toBe('function');
|
||||
});
|
||||
|
||||
it('should be able to access collections', () => {
|
||||
const pb = usePocketbase();
|
||||
const usersCollection = pb.collection('users');
|
||||
expect(usersCollection).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
84
app/composables/useAuth.ts
Normal file
84
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { AuthProviderInfo, RecordModel } from 'pocketbase';
|
||||
import { usePocketbase } from './usePocketbase';
|
||||
|
||||
export interface LoggedInUser extends RecordModel {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVisibility: boolean;
|
||||
verified: boolean;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
created: Date;
|
||||
updated: Date;
|
||||
}
|
||||
|
||||
const user = ref<LoggedInUser | null>(null);
|
||||
const loading = ref<boolean>(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
export const useAuth = () => {
|
||||
const pb = usePocketbase();
|
||||
const router = useRouter();
|
||||
|
||||
const userCollection = 'users';
|
||||
|
||||
const isAuthenticated = computed<boolean>(() => pb.authStore.isValid && !!user.value);
|
||||
|
||||
const initAuth = async () => {
|
||||
user.value = pb.authStore.record as LoggedInUser;
|
||||
pb.authStore.onChange((_token, model) => (user.value = model as LoggedInUser));
|
||||
};
|
||||
|
||||
const authProviders = async (): Promise<AuthProviderInfo[]> => {
|
||||
const authMethods = await pb.collection(userCollection).listAuthMethods();
|
||||
return authMethods.oauth2.enabled ? authMethods.oauth2.providers : [];
|
||||
};
|
||||
|
||||
const login = async (provider: string) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const providers = await authProviders();
|
||||
const providerData = providers.find((p) => p.name === provider);
|
||||
if (!providerData) {
|
||||
throw new Error(`${provider} OAuth is not configured`);
|
||||
}
|
||||
const response = await pb.collection(userCollection).authWithOAuth2({ provider });
|
||||
user.value = response.record as LoggedInUser;
|
||||
} catch (pbError) {
|
||||
error.value = pbError as Error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAuth = async () => await pb.collection(userCollection).authRefresh();
|
||||
|
||||
const handleOAuthCallback = async () => {
|
||||
user.value = pb.authStore.record as LoggedInUser;
|
||||
if (isAuthenticated.value) {
|
||||
await router.push('/dashboard');
|
||||
} else {
|
||||
await router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
pb.authStore.clear();
|
||||
user.value = null;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
initAuth,
|
||||
refreshAuth,
|
||||
handleOAuthCallback,
|
||||
authProviders,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
export const usePageTitle = () => {
|
||||
const pageName = useState<string>('pageName', () => '');
|
||||
const title = computed<string>(() => (pageName.value.length > 0 ? `${pageName.value} - Tímmál` : 'Tímmál'));
|
||||
const pageName = useState<string | null>('pageName', () => null);
|
||||
const title = computed<string>(() => ((pageName.value ?? '').length > 0 ? `${pageName.value} - Tímmál` : 'Tímmál'));
|
||||
|
||||
const setPageName = (newName: string) => {
|
||||
const setPageName = (newName: string | null) => {
|
||||
pageName.value = newName;
|
||||
useHead({ title: title.value });
|
||||
};
|
||||
|
||||
15
app/composables/usePocketbase.ts
Normal file
15
app/composables/usePocketbase.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
let pbInstance: PocketBase | null = null;
|
||||
|
||||
export const usePocketbase = () => {
|
||||
if (!pbInstance) {
|
||||
const config = useRuntimeConfig();
|
||||
pbInstance = new PocketBase(config.pocketbaseUrl || 'http://localhost:8090');
|
||||
if (import.meta.server) {
|
||||
pbInstance.autoCancellation(false);
|
||||
}
|
||||
}
|
||||
|
||||
return pbInstance;
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<UDashboardGroup>
|
||||
<UiSidebar />
|
||||
<UiSidebar class="min-w-60" />
|
||||
<UDashboardPanel>
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="pageName">
|
||||
<UDashboardNavbar :title="pageName ?? ''">
|
||||
<template #right>
|
||||
<UColorModeButton />
|
||||
</template>
|
||||
|
||||
156
app/middleware/__tests__/auth.test.ts
Normal file
156
app/middleware/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
/**
|
||||
* Tests for auth middleware
|
||||
* Based on specs from private/specs.md:
|
||||
*
|
||||
* Scenario: Access protected page without auth
|
||||
* Given I am not logged in
|
||||
* When I try to access "/dashboard"
|
||||
* Then I should be redirected to "/login"
|
||||
*/
|
||||
|
||||
// Create mocks at module level to avoid hoisting issues
|
||||
const mockState = {
|
||||
isAuthenticated: false,
|
||||
navigateToSpy: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock useAuth
|
||||
mockNuxtImport('useAuth', () => {
|
||||
return () => ({
|
||||
isAuthenticated: {
|
||||
get value() {
|
||||
return mockState.isAuthenticated;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock navigateTo
|
||||
mockNuxtImport('navigateTo', () => {
|
||||
return (path: string) => mockState.navigateToSpy(path);
|
||||
});
|
||||
|
||||
describe('auth middleware', () => {
|
||||
beforeEach(async () => {
|
||||
// Reset state
|
||||
mockState.isAuthenticated = false;
|
||||
mockState.navigateToSpy.mockClear();
|
||||
});
|
||||
|
||||
it('should redirect to /login when user is not authenticated', async () => {
|
||||
mockState.isAuthenticated = false;
|
||||
|
||||
const { default: authMiddleware } = await import('../auth.global');
|
||||
|
||||
const to = {
|
||||
path: '/dashboard',
|
||||
fullPath: '/dashboard',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const from = {
|
||||
path: '/',
|
||||
fullPath: '/',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
await authMiddleware(to, from);
|
||||
|
||||
expect(mockState.navigateToSpy).toHaveBeenCalledWith({
|
||||
path: '/login',
|
||||
query: {
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow access when user is authenticated', async () => {
|
||||
mockState.isAuthenticated = true;
|
||||
|
||||
const { default: authMiddleware } = await import('../auth.global');
|
||||
|
||||
const to = {
|
||||
path: '/dashboard',
|
||||
fullPath: '/dashboard',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const from = {
|
||||
path: '/login',
|
||||
fullPath: '/login',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const result = await authMiddleware(to, from);
|
||||
|
||||
expect(mockState.navigateToSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined(); // No redirect = allow access
|
||||
});
|
||||
|
||||
it('should not redirect if already on login page', async () => {
|
||||
mockState.isAuthenticated = false;
|
||||
|
||||
const { default: authMiddleware } = await import('../auth.global');
|
||||
|
||||
const to = {
|
||||
path: '/login',
|
||||
fullPath: '/login',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const from = {
|
||||
path: '/dashboard',
|
||||
fullPath: '/dashboard',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const result = await authMiddleware(to, from);
|
||||
|
||||
expect(mockState.navigateToSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow access to home page without authentication', async () => {
|
||||
mockState.isAuthenticated = false;
|
||||
|
||||
const { default: authMiddleware } = await import('../auth.global');
|
||||
|
||||
const to = {
|
||||
path: '/',
|
||||
fullPath: '/',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const from = {
|
||||
path: '/somewhere',
|
||||
fullPath: '/somewhere',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const result = await authMiddleware(to, from);
|
||||
|
||||
expect(mockState.navigateToSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should redirect to /login for any protected route', async () => {
|
||||
mockState.isAuthenticated = false;
|
||||
|
||||
const { default: authMiddleware } = await import('../auth.global');
|
||||
|
||||
const to = {
|
||||
path: '/projects',
|
||||
fullPath: '/projects',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
const from = {
|
||||
path: '/',
|
||||
fullPath: '/',
|
||||
} as RouteLocationNormalized;
|
||||
|
||||
await authMiddleware(to, from);
|
||||
|
||||
expect(mockState.navigateToSpy).toHaveBeenCalledWith({
|
||||
path: '/login',
|
||||
query: {
|
||||
redirect: '/projects',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
14
app/middleware/auth.global.ts
Normal file
14
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default defineNuxtRouteMiddleware((to, _from) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const allowedUnauthenticatedPaths: string[] = ['/', '/login'];
|
||||
if (allowedUnauthenticatedPaths.find((p) => p === to.path)) {
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath },
|
||||
});
|
||||
}
|
||||
return;
|
||||
});
|
||||
37
app/pages/login.vue
Normal file
37
app/pages/login.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div>
|
||||
<UPageHero title="Tímmál" />
|
||||
<UPageSection id="login" title="Log in to your account" description="Welcome back to your workspace">
|
||||
<div class="full-w flex justify-center">
|
||||
<div class="flex flex-1 gap-3 max-w-200 flex-col items-stretch px-4 py-3 justify-center">
|
||||
<UAlert
|
||||
v-if="error"
|
||||
title="Something went wrong!"
|
||||
description="We couldn't log you in due to an error. Try again later. If the issue persists, try contacting the website's administrator."
|
||||
color="error"
|
||||
icon="i-lucide-circle-alert"
|
||||
/>
|
||||
<AuthOAuthProvider v-for="provider of providers" :key="provider.name" :provider="provider" />
|
||||
</div>
|
||||
</div>
|
||||
</UPageSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: 'unauthenticated',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const redirectPath = (route.query.redirect as string) || '/dashboard';
|
||||
const { authProviders, error, isAuthenticated } = useAuth();
|
||||
|
||||
const providers = await authProviders();
|
||||
|
||||
watch(isAuthenticated, (authenticated) => {
|
||||
if (authenticated) {
|
||||
navigateTo(redirectPath);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<UPage>
|
||||
<span> Signin </span>
|
||||
</UPage>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: 'unauthenticated',
|
||||
});
|
||||
</script>
|
||||
57
features/page-title.feature
Normal file
57
features/page-title.feature
Normal file
@@ -0,0 +1,57 @@
|
||||
Feature: Page Title Management
|
||||
As a user navigating the Tímmál application
|
||||
I want page titles to update based on the current page
|
||||
So that I can identify which page I'm on from the browser tab
|
||||
|
||||
Background:
|
||||
Given the page title system is initialized
|
||||
|
||||
Scenario: Default page title on application load
|
||||
When I first load the application
|
||||
Then the page title should be "Tímmál"
|
||||
|
||||
Scenario: Setting a page name updates the title
|
||||
When I navigate to the "Dashboard" page
|
||||
Then the page title should be "Dashboard - Tímmál"
|
||||
|
||||
Scenario: Changing between different pages
|
||||
When I navigate to the "Dashboard" page
|
||||
Then the page title should be "Dashboard - Tímmál"
|
||||
When I navigate to the "Projects" page
|
||||
Then the page title should be "Projects - Tímmál"
|
||||
When I navigate to the "Reports" page
|
||||
Then the page title should be "Reports - Tímmál"
|
||||
|
||||
Scenario: Clearing page name returns to default
|
||||
Given I am on the "Settings" page
|
||||
And the page title is "Settings - Tímmál"
|
||||
When I clear the page name
|
||||
Then the page title should be "Tímmál"
|
||||
|
||||
Scenario: Page title with special characters
|
||||
When I navigate to the "Reports & Analytics" page
|
||||
Then the page title should be "Reports & Analytics - Tímmál"
|
||||
|
||||
Scenario: Page title with unicode characters
|
||||
When I navigate to the "Paramètres" page
|
||||
Then the page title should be "Paramètres - Tímmál"
|
||||
|
||||
Scenario: Multiple users share the same page title state
|
||||
Given user "Alice" sets the page name to "Dashboard"
|
||||
When user "Bob" checks the page title
|
||||
Then user "Bob" should see "Dashboard - Tímmál"
|
||||
|
||||
Scenario Outline: Various page names
|
||||
When I navigate to the "<page_name>" page
|
||||
Then the page title should be "<expected_title>"
|
||||
|
||||
Examples:
|
||||
| page_name | expected_title |
|
||||
| Dashboard | Dashboard - Tímmál |
|
||||
| Projects | Projects - Tímmál |
|
||||
| Tasks | Tasks - Tímmál |
|
||||
| Reports | Reports - Tímmál |
|
||||
| Settings | Settings - Tímmál |
|
||||
| Profile | Profile - Tímmál |
|
||||
| Time Tracking | Time Tracking - Tímmál |
|
||||
| User Management | User Management - Tímmál |
|
||||
98
features/step_definitions/page-title.steps.mjs
Normal file
98
features/step_definitions/page-title.steps.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Given, When, Then, Before } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
|
||||
// Shared state (simulating useState behavior)
|
||||
const sharedState = { pageName: '' };
|
||||
|
||||
// Simple mock of usePageTitle behavior for Cucumber tests
|
||||
// This simulates the useState() sharing behavior
|
||||
const createPageTitleMock = () => {
|
||||
return {
|
||||
get title() {
|
||||
return {
|
||||
get value() {
|
||||
return sharedState.pageName.length > 0 ? `${sharedState.pageName} - Tímmál` : 'Tímmál';
|
||||
},
|
||||
};
|
||||
},
|
||||
get pageName() {
|
||||
return {
|
||||
get value() {
|
||||
return sharedState.pageName;
|
||||
},
|
||||
};
|
||||
},
|
||||
setPageName(newName) {
|
||||
sharedState.pageName = newName;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Instance for step definitions
|
||||
let pageTitleInstance;
|
||||
const userInstances = new Map();
|
||||
|
||||
Before(function () {
|
||||
// Reset shared state before each scenario
|
||||
sharedState.pageName = '';
|
||||
userInstances.clear();
|
||||
pageTitleInstance = null;
|
||||
});
|
||||
|
||||
Given('the page title system is initialized', function () {
|
||||
pageTitleInstance = createPageTitleMock();
|
||||
pageTitleInstance.setPageName('');
|
||||
});
|
||||
|
||||
When('I first load the application', function () {
|
||||
pageTitleInstance = createPageTitleMock();
|
||||
});
|
||||
|
||||
When('I navigate to the {string} page', function (pageName) {
|
||||
if (!pageTitleInstance) {
|
||||
pageTitleInstance = createPageTitleMock();
|
||||
}
|
||||
pageTitleInstance.setPageName(pageName);
|
||||
});
|
||||
|
||||
When('I clear the page name', function () {
|
||||
pageTitleInstance.setPageName('');
|
||||
});
|
||||
|
||||
Given('I am on the {string} page', function (pageName) {
|
||||
if (!pageTitleInstance) {
|
||||
pageTitleInstance = createPageTitleMock();
|
||||
}
|
||||
pageTitleInstance.setPageName(pageName);
|
||||
});
|
||||
|
||||
Given('the page title is {string}', function (expectedTitle) {
|
||||
expect(pageTitleInstance.title.value).to.equal(expectedTitle);
|
||||
});
|
||||
|
||||
Given('user {string} sets the page name to {string}', function (userName, pageName) {
|
||||
let userInstance = userInstances.get(userName);
|
||||
if (!userInstance) {
|
||||
userInstance = createPageTitleMock();
|
||||
userInstances.set(userName, userInstance);
|
||||
}
|
||||
userInstance.setPageName(pageName);
|
||||
});
|
||||
|
||||
When('user {string} checks the page title', function (userName) {
|
||||
let userInstance = userInstances.get(userName);
|
||||
if (!userInstance) {
|
||||
userInstance = createPageTitleMock();
|
||||
userInstances.set(userName, userInstance);
|
||||
}
|
||||
});
|
||||
|
||||
Then('the page title should be {string}', function (expectedTitle) {
|
||||
expect(pageTitleInstance.title.value).to.equal(expectedTitle);
|
||||
});
|
||||
|
||||
Then('user {string} should see {string}', function (userName, expectedTitle) {
|
||||
const userInstance = userInstances.get(userName);
|
||||
expect(userInstance).to.not.be.undefined;
|
||||
expect(userInstance.title.value).to.equal(expectedTitle);
|
||||
});
|
||||
72
features/step_definitions/page-title.steps.ts
Normal file
72
features/step_definitions/page-title.steps.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Given, When, Then, Before } from '@cucumber/cucumber';
|
||||
import { expect } from 'vitest';
|
||||
import { usePageTitle } from '../../app/composables/usePageTitle';
|
||||
|
||||
// Store the composable instance
|
||||
let pageTitleInstance: ReturnType<typeof usePageTitle>;
|
||||
let userInstances: Map<string, ReturnType<typeof usePageTitle>>;
|
||||
|
||||
Before(function () {
|
||||
// Reset before each scenario
|
||||
userInstances = new Map();
|
||||
});
|
||||
|
||||
Given('the page title system is initialized', function () {
|
||||
pageTitleInstance = usePageTitle();
|
||||
// Reset to default state
|
||||
pageTitleInstance.setPageName('');
|
||||
});
|
||||
|
||||
When('I first load the application', function () {
|
||||
pageTitleInstance = usePageTitle();
|
||||
});
|
||||
|
||||
When('I navigate to the {string} page', function (pageName: string) {
|
||||
if (!pageTitleInstance) {
|
||||
pageTitleInstance = usePageTitle();
|
||||
}
|
||||
pageTitleInstance.setPageName(pageName);
|
||||
});
|
||||
|
||||
When('I clear the page name', function () {
|
||||
pageTitleInstance.setPageName('');
|
||||
});
|
||||
|
||||
Given('I am on the {string} page', function (pageName: string) {
|
||||
if (!pageTitleInstance) {
|
||||
pageTitleInstance = usePageTitle();
|
||||
}
|
||||
pageTitleInstance.setPageName(pageName);
|
||||
});
|
||||
|
||||
Given('the page title is {string}', function (expectedTitle: string) {
|
||||
expect(pageTitleInstance.title.value).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
Given('user {string} sets the page name to {string}', function (userName: string, pageName: string) {
|
||||
let userInstance = userInstances.get(userName);
|
||||
if (!userInstance) {
|
||||
userInstance = usePageTitle();
|
||||
userInstances.set(userName, userInstance);
|
||||
}
|
||||
userInstance.setPageName(pageName);
|
||||
});
|
||||
|
||||
When('user {string} checks the page title', function (userName: string) {
|
||||
let userInstance = userInstances.get(userName);
|
||||
if (!userInstance) {
|
||||
userInstance = usePageTitle();
|
||||
userInstances.set(userName, userInstance);
|
||||
}
|
||||
// Just store the instance, we'll check in Then step
|
||||
});
|
||||
|
||||
Then('the page title should be {string}', function (expectedTitle: string) {
|
||||
expect(pageTitleInstance.title.value).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
Then('user {string} should see {string}', function (userName: string, expectedTitle: string) {
|
||||
const userInstance = userInstances.get(userName);
|
||||
expect(userInstance).toBeDefined();
|
||||
expect(userInstance!.title.value).toBe(expectedTitle);
|
||||
});
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -68,11 +68,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1764927628,
|
||||
"narHash": "sha256-AH2H5O9i7k3oarg3MooAnQtZxo44qxrUTUuvGOy/OEc=",
|
||||
"lastModified": 1765320738,
|
||||
"narHash": "sha256-tYYtF9NRZRzVHJpism+Tv4E5/dVJZ92ECCKz1hF1BMA=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "247d7027f91368054fb0eefbd755a73d42b66fee",
|
||||
"rev": "43682032927f3f5cfa5af539634985aba5c3fee3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
20
flake.nix
20
flake.nix
@@ -28,14 +28,14 @@
|
||||
flake-utils,
|
||||
...
|
||||
} @ inputs:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
formatter = alejandra.defaultPackage.${system};
|
||||
devShell = import ./nix/shell.nix {
|
||||
inherit inputs pkgs self;
|
||||
};
|
||||
}
|
||||
);
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
formatter = alejandra.defaultPackage.${system};
|
||||
devShell = import ./nix/shell.nix {
|
||||
inherit inputs pkgs self;
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
inputs,
|
||||
pkgs,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
inputs.devenv.lib.mkShell {
|
||||
inputs.devenv.lib.mkShell
|
||||
{
|
||||
inherit inputs pkgs;
|
||||
modules = [
|
||||
({pkgs, config, ...}: {
|
||||
({
|
||||
pkgs,
|
||||
config,
|
||||
...
|
||||
}: {
|
||||
packages = with pkgs; [
|
||||
rustywind
|
||||
nodePackages.prettier
|
||||
@@ -16,6 +20,7 @@ inputs.devenv.lib.mkShell {
|
||||
# Node
|
||||
nodejs_24
|
||||
nodePackages.pnpm
|
||||
playwright-driver.browsers
|
||||
|
||||
pocketbase
|
||||
];
|
||||
@@ -32,6 +37,31 @@ inputs.devenv.lib.mkShell {
|
||||
dev.exec = "${nodePackages.pnpm}/bin/pnpm dev";
|
||||
};
|
||||
|
||||
env = let
|
||||
browsers = (builtins.fromJSON (builtins.readFile "${pkgs.playwright-driver}/browsers.json")).browsers;
|
||||
chromium-rev = (builtins.head (builtins.filter (x: x.name == "chromium") browsers)).revision;
|
||||
in {
|
||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright.browsers}";
|
||||
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true;
|
||||
PLAYWRIGHT_NODEJS_PATH = "${pkgs.nodejs}/bin/node";
|
||||
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH = "${pkgs.playwright.browsers}/chromium-${chromium-rev}/chrome-linux/chrome";
|
||||
};
|
||||
|
||||
scripts.intro.exec = ''
|
||||
playwrightNpmVersion="$(pnpm show @playwright/test version)"
|
||||
echo "❄️ Playwright nix version: ${pkgs.playwright.version}"
|
||||
echo "📦 Playwright pnpm version: $playwrightNpmVersion"
|
||||
|
||||
if [ "${pkgs.playwright.version}" != "$playwrightNpmVersion" ]; then
|
||||
echo "❌ Playwright versions in nix (in devenv.yaml) and pnpm (in package.json) are not the same! Please adapt the configuration."
|
||||
else
|
||||
echo "✅ Playwright versions in nix and npm are the same"
|
||||
fi
|
||||
|
||||
echo
|
||||
env | grep ^PLAYWRIGHT
|
||||
'';
|
||||
|
||||
enterShell = ''
|
||||
echo "🚀 Nuxt.js development environment loaded!"
|
||||
echo "📦 Node.js version: $(node --version)"
|
||||
@@ -39,6 +69,7 @@ inputs.devenv.lib.mkShell {
|
||||
echo ""
|
||||
echo "Run 'pnpm install' to install dependencies"
|
||||
echo "Run 'pnpm dev' to start the development server"
|
||||
intro
|
||||
'';
|
||||
})
|
||||
];
|
||||
|
||||
11
package.json
11
package.json
@@ -13,11 +13,8 @@
|
||||
"typecheck": "nuxt typecheck",
|
||||
"format": "prettier --write app/",
|
||||
"format-check": "prettier --check app/",
|
||||
"test": "pnpm test:cucumber && pnpm test:vitest",
|
||||
"test:vitest": "vitest",
|
||||
"test:cucumber": "cucumber-js",
|
||||
"test:local": "pnpm test:cucumber && pnpm test:local:vitest",
|
||||
"test:local:vitest": "vitest -c vitest.config.local.ts"
|
||||
"test": "vitest",
|
||||
"test:local": "vitest -c vitest.config.local.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.74",
|
||||
@@ -29,7 +26,6 @@
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^12.3.0",
|
||||
"@nuxt/eslint": "^1.10.0",
|
||||
"@nuxt/test-utils": "3.21.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
@@ -37,9 +33,10 @@
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"chai": "^6.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"happy-dom": "^20.0.11",
|
||||
"playwright-core": "^1.57.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.15",
|
||||
"vue-tsc": "^3.1.5"
|
||||
|
||||
36
pb_migrations/1765135365_updated_users.js
Normal file
36
pb_migrations/1765135365_updated_users.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
|
||||
|
||||
// update collection data
|
||||
unmarshal({
|
||||
oauth2: {
|
||||
enabled: true
|
||||
},
|
||||
otp: {
|
||||
enabled: true
|
||||
},
|
||||
passwordAuth: {
|
||||
enabled: false
|
||||
}
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
|
||||
|
||||
// update collection data
|
||||
unmarshal({
|
||||
oauth2: {
|
||||
enabled: false
|
||||
},
|
||||
otp: {
|
||||
enabled: false
|
||||
},
|
||||
passwordAuth: {
|
||||
enabled: true
|
||||
}
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
3217
pnpm-lock.yaml
generated
3217
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,4 +6,5 @@ ignoredBuiltDependencies:
|
||||
- vue-demi
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- msw
|
||||
- sharp
|
||||
|
||||
Reference in New Issue
Block a user