mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Merge pull request #54 from lordmathis/chore/frontend-test
Add frontend tests
This commit is contained in:
39
.github/workflows/frontend-tests.yml
vendored
Normal file
39
.github/workflows/frontend-tests.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Frontend Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- "app/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Frontend Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./app
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: "./app/package-lock.json"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Vitest tests
|
||||
run: npm test
|
||||
2
.github/workflows/go-test.yml
vendored
2
.github/workflows/go-test.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- "server/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
2
.github/workflows/typescript.yml
vendored
2
.github/workflows/typescript.yml
vendored
@@ -7,6 +7,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- "app/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lemma
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Yet another markdown editor. Work in progress
|
||||
|
||||
|
||||
@@ -104,4 +104,11 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
},
|
||||
},
|
||||
// Override configuration for test files
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
1563
app/package-lock.json
generated
1563
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,10 @@
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix"
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -54,6 +57,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^18.3.20",
|
||||
@@ -62,16 +67,19 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.80.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-compression2": "^1.3.0"
|
||||
"vite-plugin-compression2": "^1.3.0",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -7,10 +7,10 @@ import { apiCall } from './api';
|
||||
import {
|
||||
isSystemStats,
|
||||
isUser,
|
||||
isWorkspace,
|
||||
isWorkspaceStats,
|
||||
type SystemStats,
|
||||
type User,
|
||||
type Workspace,
|
||||
type WorkspaceStats,
|
||||
} from '@/types/models';
|
||||
|
||||
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
|
||||
@@ -101,18 +101,18 @@ export const updateUser = async (
|
||||
|
||||
/**
|
||||
* Fetches all workspaces from the API
|
||||
* @returns {Promise<Workspace[]>} A promise that resolves to an array of workspaces
|
||||
* @returns {Promise<WorkspaceStats[]>} A promise that resolves to an array of workspaces
|
||||
* @throws {Error} If the API call fails or returns an invalid response
|
||||
* */
|
||||
export const getWorkspaces = async (): Promise<Workspace[]> => {
|
||||
export const getWorkspaces = async (): Promise<WorkspaceStats[]> => {
|
||||
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
|
||||
const data: unknown = await response.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Invalid workspaces response received from API');
|
||||
}
|
||||
return data.map((workspace) => {
|
||||
if (!isWorkspace(workspace)) {
|
||||
throw new Error('Invalid workspace object received from API');
|
||||
if (!isWorkspaceStats(workspace)) {
|
||||
throw new Error('Invalid workspace stats object received from API');
|
||||
}
|
||||
return workspace;
|
||||
});
|
||||
|
||||
580
app/src/api/api.test.ts
Normal file
580
app/src/api/api.test.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { apiCall } from './api';
|
||||
|
||||
// Mock the auth module - move this before any constants
|
||||
vi.mock('./auth', () => {
|
||||
return {
|
||||
refreshToken: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Get the mocked function after vi.mock
|
||||
const mockRefreshToken = vi.mocked(await import('./auth')).refreshToken;
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Helper to create mock Response objects
|
||||
const createMockResponse = (
|
||||
status: number,
|
||||
body: unknown = {},
|
||||
ok?: boolean
|
||||
): Response => {
|
||||
const response = {
|
||||
status,
|
||||
ok: ok !== undefined ? ok : status >= 200 && status < 300,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
text: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
typeof body === 'string' ? body : JSON.stringify(body)
|
||||
),
|
||||
} as unknown as Response;
|
||||
return response;
|
||||
};
|
||||
|
||||
// Helper to set document.cookie
|
||||
const setCookie = (name: string, value: string) => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: `${name}=${encodeURIComponent(value)}`,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
describe('apiCall', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear cookies
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: '',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('makes a successful GET request', async () => {
|
||||
const mockResponseData = { success: true };
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, mockResponseData));
|
||||
|
||||
const result = await apiCall('https://api.example.com/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('makes a successful POST request with body', async () => {
|
||||
const requestBody = { name: 'test' };
|
||||
const mockResponseData = { id: 1, name: 'test' };
|
||||
mockFetch.mockResolvedValue(createMockResponse(201, mockResponseData));
|
||||
|
||||
const result = await apiCall('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
it('handles 204 No Content responses', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(204, null, true));
|
||||
|
||||
const result = await apiCall('https://api.example.com/delete', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(204);
|
||||
});
|
||||
|
||||
it('preserves custom headers', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test', {
|
||||
headers: {
|
||||
'Custom-Header': 'custom-value',
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain', // Custom content type should override
|
||||
'Custom-Header': 'custom-value',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF token handling', () => {
|
||||
it('adds CSRF token to non-GET requests when token exists', async () => {
|
||||
setCookie('csrf_token', 'test-csrf-token');
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ test: 'data' }),
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ test: 'data' }),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': 'test-csrf-token',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits CSRF token with GET methods', async () => {
|
||||
setCookie('csrf_token', 'test-token');
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test', { method: 'GET' });
|
||||
|
||||
// Check that CSRF token is not included in headers
|
||||
const calledOptions = mockFetch.mock.calls?.[0]?.[1] as RequestInit;
|
||||
expect(calledOptions['headers']).not.toHaveProperty('X-CSRF-Token');
|
||||
});
|
||||
|
||||
it('handles URL-encoded CSRF tokens', async () => {
|
||||
const encodedToken = 'token%20with%20spaces';
|
||||
setCookie('csrf_token', encodedToken);
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': encodedToken, // We shouldn't expect it to be decoded since our api.ts is not decoding it
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing CSRF token gracefully', async () => {
|
||||
// No CSRF token in cookies
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// No X-CSRF-Token header
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple cookies and extracts CSRF token correctly', async () => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value:
|
||||
'session_id=abc123; csrf_token=my-csrf-token; other_cookie=value',
|
||||
configurable: true,
|
||||
});
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': 'my-csrf-token',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty CSRF token value', async () => {
|
||||
setCookie('csrf_token', '');
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// No X-CSRF-Token header when token is empty
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws error for non-2xx status codes', async () => {
|
||||
const errorResponse = { message: 'Bad Request' };
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse(400, errorResponse, false)
|
||||
);
|
||||
|
||||
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||
'Bad Request'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws generic error when no error message in response', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(500, {}, false));
|
||||
|
||||
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||
'HTTP error! status: 500'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles malformed JSON error responses', async () => {
|
||||
const mockResponse = {
|
||||
status: 400,
|
||||
ok: false,
|
||||
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
} as unknown as Response;
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||
'Invalid JSON'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles network errors', async () => {
|
||||
const networkError = new Error('Network error');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||
'Network error'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles timeout errors', async () => {
|
||||
const timeoutError = new Error('Request timeout');
|
||||
mockFetch.mockRejectedValue(timeoutError);
|
||||
|
||||
await expect(apiCall('https://api.example.com/slow')).rejects.toThrow(
|
||||
'Request timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication and token refresh', () => {
|
||||
it('handles 401 response by attempting token refresh and retrying', async () => {
|
||||
const successResponse = createMockResponse(200, { data: 'success' });
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails with 401
|
||||
.mockResolvedValueOnce(successResponse); // Retry succeeds
|
||||
|
||||
mockRefreshToken.mockResolvedValue(true);
|
||||
|
||||
const result = await apiCall('https://api.example.com/protected');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('throws error when token refresh fails', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
|
||||
mockRefreshToken.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
apiCall('https://api.example.com/protected')
|
||||
).rejects.toThrow('Authentication failed');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not attempt refresh for auth/refresh endpoint', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
|
||||
|
||||
await expect(
|
||||
apiCall('https://api.example.com/auth/refresh')
|
||||
).rejects.toThrow('Authentication failed');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockRefreshToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful token refresh but failed retry', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails
|
||||
.mockResolvedValueOnce(
|
||||
createMockResponse(403, { message: 'Forbidden' }, false)
|
||||
); // Retry fails with different error
|
||||
|
||||
mockRefreshToken.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
apiCall('https://api.example.com/protected')
|
||||
).rejects.toThrow('Forbidden');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles token refresh throwing an error', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
|
||||
mockRefreshToken.mockRejectedValue(new Error('Refresh failed'));
|
||||
|
||||
await expect(
|
||||
apiCall('https://api.example.com/protected')
|
||||
).rejects.toThrow('Refresh failed'); // The test should match the actual error from the mock
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves original request options in retry', async () => {
|
||||
const requestBody = { data: 'test' };
|
||||
const customHeaders = { 'Custom-Header': 'value' };
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(createMockResponse(401, {}, false))
|
||||
.mockResolvedValueOnce(createMockResponse(200, { success: true }));
|
||||
|
||||
mockRefreshToken.mockResolvedValue(true);
|
||||
|
||||
await apiCall('https://api.example.com/protected', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: customHeaders,
|
||||
});
|
||||
|
||||
// Check that both calls had the same options
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.example.com/protected',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Custom-Header': 'value',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.example.com/protected',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Custom-Header': 'value',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('console logging', () => {
|
||||
it('logs debug information for requests and responses', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Making API call to: https://api.example.com/test'
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Response status: 200 for URL: https://api.example.com/test'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs errors when API calls fail', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const networkError = new Error('Network failure');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(apiCall('https://api.example.com/error')).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'API call failed: Network failure'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('request options handling', () => {
|
||||
it('merges provided options with defaults', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test', {
|
||||
method: 'PUT',
|
||||
cache: 'no-cache' as RequestCache,
|
||||
redirect: 'follow' as RequestRedirect,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
method: 'PUT',
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined options parameter', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty options object', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test', {});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP methods', () => {
|
||||
it('handles different HTTP methods correctly', async () => {
|
||||
setCookie('csrf_token', 'test-token');
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
for (const method of methods) {
|
||||
mockFetch.mockClear();
|
||||
|
||||
await apiCall('https://api.example.com/test', { method });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': 'test-token',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to GET method when method is omitted', async () => {
|
||||
setCookie('csrf_token', 'test-token');
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall('https://api.example.com/test', {});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||
method: undefined,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// No CSRF token for undefined (GET) method
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles very long URLs', async () => {
|
||||
const longUrl = 'https://api.example.com/' + 'a'.repeat(2000);
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall(longUrl);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(longUrl, expect.any(Object));
|
||||
});
|
||||
|
||||
it('handles special characters in URL', async () => {
|
||||
const urlWithSpecialChars =
|
||||
'https://api.example.com/test?param=value&other=test%20value';
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
await apiCall(urlWithSpecialChars);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
urlWithSpecialChars,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('handles null response body', async () => {
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as Response;
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiCall('https://api.example.com/test');
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('handles empty string response body', async () => {
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(''),
|
||||
} as unknown as Response;
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiCall('https://api.example.com/test');
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,10 @@ export const lookupFileByName = async (
|
||||
workspaceName: string,
|
||||
filename: string
|
||||
): Promise<string[]> => {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
throw new Error('Invalid filename provided for lookup');
|
||||
}
|
||||
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||
workspaceName
|
||||
|
||||
256
app/src/components/auth/LoginPage.test.tsx
Normal file
256
app/src/components/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { AuthProvider } from '@/contexts/AuthContext';
|
||||
import LoginPage from './LoginPage';
|
||||
|
||||
// Mock the auth API functions
|
||||
const mockApiLogin = vi.fn();
|
||||
const mockApiLogout = vi.fn();
|
||||
const mockApiRefreshToken = vi.fn();
|
||||
const mockGetCurrentUser = vi.fn();
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
login: (...args: unknown[]): unknown => mockApiLogin(...args),
|
||||
logout: (...args: unknown[]): unknown => mockApiLogout(...args),
|
||||
refreshToken: (...args: unknown[]): unknown => mockApiRefreshToken(...args),
|
||||
getCurrentUser: (...args: unknown[]): unknown => mockGetCurrentUser(...args),
|
||||
}));
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = async (ui: React.ReactElement) => {
|
||||
const result = rtlRender(ui, { wrapper: TestWrapper });
|
||||
|
||||
// Wait for AuthProvider initialization to complete
|
||||
await waitFor(() => {
|
||||
// The LoginPage should be rendered (indicates AuthProvider has initialized)
|
||||
expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('LoginPage', () => {
|
||||
let mockNotificationShow: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get the mocked notification function
|
||||
const { notifications } = await import('@mantine/notifications');
|
||||
mockNotificationShow = vi.mocked(notifications.show);
|
||||
|
||||
// Setup default mock implementations
|
||||
mockGetCurrentUser.mockRejectedValue(new Error('No user session'));
|
||||
mockApiLogin.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
role: 'editor',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders the login form with all required elements', async () => {
|
||||
await render(<LoginPage />);
|
||||
|
||||
// Check title and subtitle
|
||||
expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Please sign in to continue')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check form fields with correct attributes
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const submitButton = screen.getByTestId('login-button');
|
||||
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(emailInput).toHaveAttribute('placeholder', 'your@email.com');
|
||||
expect(emailInput).toBeRequired();
|
||||
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(passwordInput).toHaveAttribute('placeholder', 'Your password');
|
||||
expect(passwordInput).toBeRequired();
|
||||
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
||||
expect(submitButton).not.toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('updates input values when user types', async () => {
|
||||
await render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect((emailInput as HTMLInputElement).value).toBe('test@example.com');
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('password123');
|
||||
});
|
||||
|
||||
it('prevents form submission with empty fields due to HTML5 validation', async () => {
|
||||
await render(<LoginPage />);
|
||||
|
||||
const submitButton = screen.getByTestId('login-button');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(mockApiLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
const fillAndSubmitForm = (email: string, password: string) => {
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const submitButton = screen.getByTestId('login-button');
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
return { emailInput, passwordInput, submitButton };
|
||||
};
|
||||
|
||||
it('calls login function with correct credentials on form submit', async () => {
|
||||
await render(<LoginPage />);
|
||||
fillAndSubmitForm('test@example.com', 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiLogin).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
'password123'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during login and resets after completion', async () => {
|
||||
// Create a controlled promise for login
|
||||
let resolveLogin: () => void;
|
||||
const loginPromise = new Promise<void>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
});
|
||||
mockApiLogin.mockReturnValue(loginPromise);
|
||||
|
||||
await render(<LoginPage />);
|
||||
const { submitButton } = fillAndSubmitForm(
|
||||
'test@example.com',
|
||||
'password123'
|
||||
);
|
||||
|
||||
// Check loading state appears
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
|
||||
// Resolve the login and check loading state is removed
|
||||
resolveLogin!();
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login success with notification', async () => {
|
||||
await render(<LoginPage />);
|
||||
fillAndSubmitForm('test@example.com', 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify success notification is shown
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationShow).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Logged in successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login errors gracefully with notification', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const errorMessage = 'Invalid credentials';
|
||||
mockApiLogin.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await render(<LoginPage />);
|
||||
const { submitButton } = fillAndSubmitForm(
|
||||
'test@example.com',
|
||||
'wrongpassword'
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify error is logged
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Login failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
// Verify error notification is shown
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationShow).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
// Verify loading state is reset
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles special characters in credentials', async () => {
|
||||
await render(<LoginPage />);
|
||||
|
||||
const specialEmail = 'user+test@example-domain.com';
|
||||
const specialPassword = 'P@ssw0rd!#$%';
|
||||
|
||||
fillAndSubmitForm(specialEmail, specialPassword);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiLogin).toHaveBeenCalledWith(
|
||||
specialEmail,
|
||||
specialPassword
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,11 +37,13 @@ const LoginPage: React.FC = () => {
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} role="form">
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="your@email.com"
|
||||
data-testid="email-input"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||
@@ -50,12 +52,13 @@ const LoginPage: React.FC = () => {
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
data-testid="password-input"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={loading}>
|
||||
<Button type="submit" loading={loading} data-testid="login-button">
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
223
app/src/components/editor/ContentView.test.tsx
Normal file
223
app/src/components/editor/ContentView.test.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '../../test/utils';
|
||||
import ContentView from './ContentView';
|
||||
import { Theme } from '@/types/models';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./Editor', () => ({
|
||||
default: ({
|
||||
content,
|
||||
selectedFile,
|
||||
}: {
|
||||
content: string;
|
||||
selectedFile: string;
|
||||
}) => (
|
||||
<div data-testid="editor">
|
||||
Editor - {selectedFile} - {content}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./MarkdownPreview', () => ({
|
||||
default: ({ content }: { content: string }) => (
|
||||
<div data-testid="markdown-preview">Preview - {content}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/WorkspaceContext', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock('../../utils/fileHelpers', () => ({
|
||||
getFileUrl: vi.fn(
|
||||
(workspace: string, file: string) => `http://test.com/${workspace}/${file}`
|
||||
),
|
||||
isImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('ContentView', () => {
|
||||
const mockHandleContentChange = vi.fn();
|
||||
const mockHandleSave = vi.fn();
|
||||
const mockHandleFileSelect = vi.fn();
|
||||
|
||||
const mockCurrentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useWorkspace } = await import('../../contexts/WorkspaceContext');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: mockCurrentWorkspace,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { isImageFile } = await import('../../utils/fileHelpers');
|
||||
vi.mocked(isImageFile).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('shows no workspace message when no workspace selected', async () => {
|
||||
const { useWorkspace } = await import('../../contexts/WorkspaceContext');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: null,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<ContentView
|
||||
activeTab="source"
|
||||
selectedFile="test.md"
|
||||
content="Test content"
|
||||
handleContentChange={mockHandleContentChange}
|
||||
handleSave={mockHandleSave}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByText('No workspace selected.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no file message when no file selected', () => {
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<ContentView
|
||||
activeTab="source"
|
||||
selectedFile={null}
|
||||
content=""
|
||||
handleContentChange={mockHandleContentChange}
|
||||
handleSave={mockHandleSave}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByText('No file selected.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders editor when activeTab is source', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<ContentView
|
||||
activeTab="source"
|
||||
selectedFile="test.md"
|
||||
content="Test content"
|
||||
handleContentChange={mockHandleContentChange}
|
||||
handleSave={mockHandleSave}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const editor = getByTestId('editor');
|
||||
expect(editor).toBeInTheDocument();
|
||||
expect(editor).toHaveTextContent('Editor - test.md - Test content');
|
||||
});
|
||||
|
||||
it('renders markdown preview when activeTab is preview', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<ContentView
|
||||
activeTab="preview"
|
||||
selectedFile="test.md"
|
||||
content="# Test content"
|
||||
handleContentChange={mockHandleContentChange}
|
||||
handleSave={mockHandleSave}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const preview = getByTestId('markdown-preview');
|
||||
expect(preview).toBeInTheDocument();
|
||||
expect(preview).toHaveTextContent('Preview - # Test content');
|
||||
});
|
||||
|
||||
it('renders image preview for image files', async () => {
|
||||
const { isImageFile } = await import('../../utils/fileHelpers');
|
||||
vi.mocked(isImageFile).mockReturnValue(true);
|
||||
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ContentView
|
||||
activeTab="source"
|
||||
selectedFile="image.png"
|
||||
content=""
|
||||
handleContentChange={mockHandleContentChange}
|
||||
handleSave={mockHandleSave}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const imagePreview = container.querySelector('.image-preview');
|
||||
expect(imagePreview).toBeInTheDocument();
|
||||
|
||||
const img = container.querySelector('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute(
|
||||
'src',
|
||||
'http://test.com/test-workspace/image.png'
|
||||
);
|
||||
expect(img).toHaveAttribute('alt', 'image.png');
|
||||
});
|
||||
|
||||
it('ignores activeTab for image files', async () => {
|
||||
const { isImageFile } = await import('../../utils/fileHelpers');
|
||||
vi.mocked(isImageFile).mockReturnValue(true);
|
||||
|
||||
const { container, queryByTestId } = render(
|
||||
<TestWrapper>
|
||||
<ContentView
|
||||
activeTab="preview"
|
||||
selectedFile="image.png"
|
||||
content=""
|
||||
handleContentChange={mockHandleContentChange}
|
||||
handleSave={mockHandleSave}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should show image preview regardless of activeTab
|
||||
const imagePreview = container.querySelector('.image-preview');
|
||||
expect(imagePreview).toBeInTheDocument();
|
||||
|
||||
// Should not render editor or markdown preview
|
||||
expect(queryByTestId('editor')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('markdown-preview')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
319
app/src/components/editor/MarkdownPreview.test.tsx
Normal file
319
app/src/components/editor/MarkdownPreview.test.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import MarkdownPreview from './MarkdownPreview';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { Theme } from '../../types/models';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useWorkspace hook
|
||||
vi.mock('../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the remarkWikiLinks utility
|
||||
vi.mock('../../utils/remarkWikiLinks', () => ({
|
||||
remarkWikiLinks: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
// Mock window.API_BASE_URL
|
||||
Object.defineProperty(window, 'API_BASE_URL', {
|
||||
value: 'http://localhost:3000',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('MarkdownPreview', () => {
|
||||
const mockHandleFileSelect = vi.fn();
|
||||
const mockNotificationsShow = vi.mocked(notifications.show);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup useWorkspace mock
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
lastOpenedFilePath: '',
|
||||
},
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders basic markdown content', async () => {
|
||||
const content = '# Hello World\n\nThis is a test.';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a test.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders code blocks with syntax highlighting', async () => {
|
||||
const content = '```javascript\nconst hello = "world";\n```';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for the code element containing the text pieces
|
||||
const codeElement = screen.getByText((_, element) => {
|
||||
return !!(
|
||||
element?.tagName.toLowerCase() === 'code' &&
|
||||
element?.textContent?.includes('const') &&
|
||||
element?.textContent?.includes('hello') &&
|
||||
element?.textContent?.includes('world')
|
||||
);
|
||||
});
|
||||
expect(codeElement).toBeInTheDocument();
|
||||
expect(codeElement.closest('pre')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles image loading errors gracefully', async () => {
|
||||
const content = '';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
|
||||
// Simulate image load error
|
||||
fireEvent.error(img);
|
||||
|
||||
expect(img).toHaveAttribute('alt', 'Failed to load image');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles internal link clicks and calls handleFileSelect', async () => {
|
||||
const content = '[Test Link](http://localhost:3000/internal/test-file.md)';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByText('Test Link');
|
||||
expect(link).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(mockHandleFileSelect).toHaveBeenCalledWith('test-file.md');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows notification for non-existent file links', async () => {
|
||||
const content =
|
||||
'[Missing File](http://localhost:3000/notfound/missing-file.md)';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByText('Missing File');
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'File Not Found',
|
||||
message: 'The file "missing-file.md" does not exist.',
|
||||
color: 'red',
|
||||
});
|
||||
expect(mockHandleFileSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles external links normally without interference', async () => {
|
||||
const content = '[External Link](https://example.com)';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByText('External Link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
|
||||
// Click should be prevented but no file selection should occur
|
||||
fireEvent.click(link);
|
||||
expect(mockHandleFileSelect).not.toHaveBeenCalled();
|
||||
expect(mockNotificationsShow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not process content when no workspace is available', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: null,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const content = '# Test Content';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={content}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should render empty content when no workspace
|
||||
const markdownPreview = screen.getByTestId('markdown-preview');
|
||||
expect(markdownPreview).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('handles empty content gracefully', async () => {
|
||||
render(
|
||||
<MarkdownPreview content="" handleFileSelect={mockHandleFileSelect} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const markdownPreview = screen.getByTestId('markdown-preview');
|
||||
expect(markdownPreview).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates content when markdown changes', async () => {
|
||||
const { rerender } = render(
|
||||
<MarkdownPreview
|
||||
content="# First Content"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<MarkdownPreview
|
||||
content="# Updated Content"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('First Content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles markdown processing errors gracefully', async () => {
|
||||
// Mock console.error to avoid noise in test output
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Create content that might cause processing issues
|
||||
const problematicContent = '# Test\n\n```invalid-syntax\nbroken code\n```';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={problematicContent}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for async content processing to complete
|
||||
await waitFor(() => {
|
||||
// Should still render something even if processing has issues
|
||||
const markdownPreview = screen.getByTestId('markdown-preview');
|
||||
expect(markdownPreview).toBeInTheDocument();
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles URL decoding for file paths correctly', async () => {
|
||||
const encodedContent =
|
||||
'[Test Link](http://localhost:3000/internal/test%20file%20with%20spaces.md)';
|
||||
|
||||
render(
|
||||
<MarkdownPreview
|
||||
content={encodedContent}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByText('Test Link');
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(mockHandleFileSelect).toHaveBeenCalledWith(
|
||||
'test file with spaces.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,11 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
|
||||
void processContent();
|
||||
}, [content, processor, currentWorkspace]);
|
||||
|
||||
return <div className="markdown-preview">{processedContent}</div>;
|
||||
return (
|
||||
<div className="markdown-preview" data-testid="markdown-preview">
|
||||
{processedContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
||||
|
||||
212
app/src/components/files/FileActions.test.tsx
Normal file
212
app/src/components/files/FileActions.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { render } from '../../test/utils';
|
||||
import FileActions from './FileActions';
|
||||
import { Theme } from '@/types/models';
|
||||
|
||||
// Mock the contexts and hooks
|
||||
vi.mock('../../contexts/ModalContext', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('FileActions', () => {
|
||||
const mockHandlePullChanges = vi.fn();
|
||||
const mockSetNewFileModalVisible = vi.fn();
|
||||
const mockSetDeleteFileModalVisible = vi.fn();
|
||||
const mockSetCommitMessageModalVisible = vi.fn();
|
||||
|
||||
const mockCurrentWorkspace = {
|
||||
id: 1,
|
||||
name: 'Test Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
gitEnabled: true,
|
||||
gitAutoCommit: false,
|
||||
theme: Theme.Light,
|
||||
autoSave: true,
|
||||
showHiddenFiles: false,
|
||||
gitUrl: '',
|
||||
gitBranch: 'main',
|
||||
gitUsername: '',
|
||||
gitEmail: '',
|
||||
gitToken: '',
|
||||
gitUser: '',
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useModalContext } = await import('../../contexts/ModalContext');
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
newFileModalVisible: false,
|
||||
setNewFileModalVisible: mockSetNewFileModalVisible,
|
||||
deleteFileModalVisible: false,
|
||||
setDeleteFileModalVisible: mockSetDeleteFileModalVisible,
|
||||
commitMessageModalVisible: false,
|
||||
setCommitMessageModalVisible: mockSetCommitMessageModalVisible,
|
||||
settingsModalVisible: false,
|
||||
setSettingsModalVisible: vi.fn(),
|
||||
switchWorkspaceModalVisible: false,
|
||||
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||
createWorkspaceModalVisible: false,
|
||||
setCreateWorkspaceModalVisible: vi.fn(),
|
||||
});
|
||||
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: mockCurrentWorkspace,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('opens new file modal when create button is clicked', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile={null}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const createButton = getByTestId('create-file-button');
|
||||
fireEvent.click(createButton);
|
||||
|
||||
expect(mockSetNewFileModalVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('opens delete modal when delete button is clicked with selected file', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const deleteButton = getByTestId('delete-file-button');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(mockSetDeleteFileModalVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('disables delete button when no file is selected', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile={null}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const deleteButton = getByTestId('delete-file-button');
|
||||
expect(deleteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls pull changes when pull button is clicked', () => {
|
||||
mockHandlePullChanges.mockResolvedValue(true);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const pullButton = getByTestId('pull-changes-button');
|
||||
fireEvent.click(pullButton);
|
||||
|
||||
expect(mockHandlePullChanges).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('disables git buttons when git is not enabled', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: { ...mockCurrentWorkspace, gitEnabled: false },
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const pullButton = getByTestId('pull-changes-button');
|
||||
expect(pullButton).toBeDisabled();
|
||||
|
||||
const commitButton = getByTestId('commit-push-button');
|
||||
expect(commitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('opens commit modal when commit button is clicked', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const commitButton = getByTestId('commit-push-button');
|
||||
fireEvent.click(commitButton);
|
||||
|
||||
expect(mockSetCommitMessageModalVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('disables commit button when auto-commit is enabled', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: { ...mockCurrentWorkspace, gitAutoCommit: true },
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileActions
|
||||
handlePullChanges={mockHandlePullChanges}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const commitButton = getByTestId('commit-push-button');
|
||||
expect(commitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
handlePullChanges,
|
||||
selectedFile,
|
||||
}) => {
|
||||
const { settings } = useWorkspace();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
setNewFileModalVisible,
|
||||
setDeleteFileModalVisible,
|
||||
@@ -32,7 +32,13 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Create new file">
|
||||
<ActionIcon variant="default" size="md" onClick={handleCreateFile}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleCreateFile}
|
||||
aria-label="Create new file"
|
||||
data-testid="create-file-button"
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -46,6 +52,8 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
onClick={handleDeleteFile}
|
||||
disabled={!selectedFile}
|
||||
color="red"
|
||||
aria-label="Delete current file"
|
||||
data-testid="delete-file-button"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
@@ -53,7 +61,7 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
|
||||
<Tooltip
|
||||
label={
|
||||
settings.gitEnabled
|
||||
currentWorkspace?.gitEnabled
|
||||
? 'Pull changes from remote'
|
||||
: 'Git is not enabled'
|
||||
}
|
||||
@@ -66,7 +74,9 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
console.error('Error pulling changes:', error);
|
||||
});
|
||||
}}
|
||||
disabled={!settings.gitEnabled}
|
||||
disabled={!currentWorkspace?.gitEnabled}
|
||||
aria-label="Pull changes from remote"
|
||||
data-testid="pull-changes-button"
|
||||
>
|
||||
<IconGitPullRequest size={16} />
|
||||
</ActionIcon>
|
||||
@@ -74,9 +84,9 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
|
||||
<Tooltip
|
||||
label={
|
||||
!settings.gitEnabled
|
||||
!currentWorkspace?.gitEnabled
|
||||
? 'Git is not enabled'
|
||||
: settings.gitAutoCommit
|
||||
: currentWorkspace.gitAutoCommit
|
||||
? 'Auto-commit is enabled'
|
||||
: 'Commit and push changes'
|
||||
}
|
||||
@@ -85,7 +95,11 @@ const FileActions: React.FC<FileActionsProps> = ({
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleCommitAndPush}
|
||||
disabled={!settings.gitEnabled || settings.gitAutoCommit}
|
||||
disabled={
|
||||
!currentWorkspace?.gitEnabled || currentWorkspace.gitAutoCommit
|
||||
}
|
||||
aria-label="Commit and push changes"
|
||||
data-testid="commit-push-button"
|
||||
>
|
||||
<IconGitCommit size={16} />
|
||||
</ActionIcon>
|
||||
|
||||
215
app/src/components/files/FileTree.test.tsx
Normal file
215
app/src/components/files/FileTree.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '../../test/utils';
|
||||
import FileTree from './FileTree';
|
||||
import type { FileNode } from '../../types/models';
|
||||
|
||||
// Mock react-arborist
|
||||
vi.mock('react-arborist', () => ({
|
||||
Tree: ({
|
||||
children,
|
||||
data,
|
||||
onActivate,
|
||||
}: {
|
||||
children: (props: {
|
||||
node: {
|
||||
data: FileNode;
|
||||
isLeaf: boolean;
|
||||
isInternal: boolean;
|
||||
isOpen: boolean;
|
||||
level: number;
|
||||
toggle: () => void;
|
||||
};
|
||||
style: Record<string, unknown>;
|
||||
onNodeClick: (node: { isInternal: boolean }) => void;
|
||||
}) => React.ReactNode;
|
||||
data: FileNode[];
|
||||
onActivate: (node: { isInternal: boolean; data: FileNode }) => void;
|
||||
}) => (
|
||||
<div data-testid="file-tree">
|
||||
{data.map((file) => {
|
||||
const mockNode = {
|
||||
data: file,
|
||||
isLeaf: !file.children || file.children.length === 0,
|
||||
isInternal: !!(file.children && file.children.length > 0),
|
||||
isOpen: false,
|
||||
level: 0,
|
||||
toggle: vi.fn(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
data-testid={`file-node-${file.id}`}
|
||||
onClick={() => {
|
||||
// Simulate the Tree's onActivate behavior
|
||||
if (!mockNode.isInternal) {
|
||||
onActivate({ isInternal: mockNode.isInternal, data: file });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children({
|
||||
node: mockNode,
|
||||
style: {},
|
||||
onNodeClick: (node: { isInternal: boolean }) => {
|
||||
if (!node.isInternal) {
|
||||
onActivate({ isInternal: node.isInternal, data: file });
|
||||
}
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock resize observer hook
|
||||
vi.mock('@react-hook/resize-observer', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('FileTree', () => {
|
||||
const mockHandleFileSelect = vi.fn();
|
||||
|
||||
const mockFiles: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'docs',
|
||||
path: 'docs',
|
||||
children: [
|
||||
{
|
||||
id: '3',
|
||||
name: 'guide.md',
|
||||
path: 'docs/guide.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '.hidden',
|
||||
path: '.hidden',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders file tree with files', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
files={mockFiles}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
showHiddenFiles={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('file-tree')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleFileSelect when file is clicked', async () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
files={mockFiles}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
showHiddenFiles={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const fileNode = getByTestId('file-node-1');
|
||||
fireEvent.click(fileNode);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleFileSelect).toHaveBeenCalledWith('README.md');
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out hidden files when showHiddenFiles is false', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
files={mockFiles}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
showHiddenFiles={false}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should show regular files
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
|
||||
// Should not show hidden file
|
||||
expect(queryByTestId('file-node-4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows hidden files when showHiddenFiles is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
files={mockFiles}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
showHiddenFiles={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should show all files including hidden
|
||||
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||
expect(getByTestId('file-node-4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty tree when no files provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
files={[]}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
showHiddenFiles={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const tree = getByTestId('file-tree');
|
||||
expect(tree).toBeInTheDocument();
|
||||
expect(tree.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not call handleFileSelect for folder clicks', async () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<FileTree
|
||||
files={mockFiles}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
showHiddenFiles={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Click on folder (has children)
|
||||
const folderNode = getByTestId('file-node-2');
|
||||
fireEvent.click(folderNode);
|
||||
|
||||
// Should not call handleFileSelect for folders
|
||||
await waitFor(() => {
|
||||
expect(mockHandleFileSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
app/src/components/layout/Header.test.tsx
Normal file
62
app/src/components/layout/Header.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '../../test/utils';
|
||||
import Header from './Header';
|
||||
|
||||
// Mock the child components
|
||||
vi.mock('../navigation/UserMenu', () => ({
|
||||
default: () => <div data-testid="user-menu">User Menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../navigation/WorkspaceSwitcher', () => ({
|
||||
default: () => <div data-testid="workspace-switcher">Workspace Switcher</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../settings/workspace/WorkspaceSettings', () => ({
|
||||
default: () => <div data-testid="workspace-settings">Workspace Settings</div>,
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders the app title', () => {
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByText('Lemma')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders user menu component', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('user-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders workspace switcher component', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('workspace-switcher')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders workspace settings component', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('workspace-settings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
156
app/src/components/layout/Layout.test.tsx
Normal file
156
app/src/components/layout/Layout.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '../../test/utils';
|
||||
import Layout from './Layout';
|
||||
import { Theme, type FileNode } from '../../types/models';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./Header', () => ({
|
||||
default: () => <div data-testid="header">Header</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./Sidebar', () => ({
|
||||
default: () => <div data-testid="sidebar">Sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./MainContent', () => ({
|
||||
default: () => <div data-testid="main-content">Main Content</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useFileNavigation', () => ({
|
||||
useFileNavigation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useFileList', () => ({
|
||||
useFileList: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('Layout', () => {
|
||||
const mockHandleFileSelect = vi.fn();
|
||||
const mockLoadFileList = vi.fn();
|
||||
|
||||
const mockCurrentWorkspace = {
|
||||
id: 1,
|
||||
name: 'Test Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
gitEnabled: true,
|
||||
gitAutoCommit: false,
|
||||
theme: Theme.Light,
|
||||
autoSave: true,
|
||||
showHiddenFiles: false,
|
||||
gitUrl: '',
|
||||
gitBranch: 'main',
|
||||
gitUsername: '',
|
||||
gitEmail: '',
|
||||
gitToken: '',
|
||||
gitUser: '',
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
const mockFiles: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: mockCurrentWorkspace,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { useFileNavigation } = await import('../../hooks/useFileNavigation');
|
||||
vi.mocked(useFileNavigation).mockReturnValue({
|
||||
selectedFile: 'README.md',
|
||||
isNewFile: false,
|
||||
handleFileSelect: mockHandleFileSelect,
|
||||
});
|
||||
|
||||
const { useFileList } = await import('../../hooks/useFileList');
|
||||
vi.mocked(useFileList).mockReturnValue({
|
||||
files: mockFiles,
|
||||
loadFileList: mockLoadFileList,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all layout components when workspace is loaded', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Layout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('header')).toBeInTheDocument();
|
||||
expect(getByTestId('sidebar')).toBeInTheDocument();
|
||||
expect(getByTestId('main-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading spinner when workspace is loading', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: mockCurrentWorkspace,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: true,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByRole } = render(
|
||||
<TestWrapper>
|
||||
<Layout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(
|
||||
getByRole('status', { name: 'Loading workspace' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no workspace message when no current workspace', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: null,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<Layout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText('No workspace found. Please create a workspace.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,11 @@ const Layout: React.FC = () => {
|
||||
|
||||
if (workspaceLoading) {
|
||||
return (
|
||||
<Center style={{ height: '100vh' }}>
|
||||
<Center
|
||||
style={{ height: '100vh' }}
|
||||
role="status"
|
||||
aria-label="Loading workspace"
|
||||
>
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
159
app/src/components/layout/MainContent.test.tsx
Normal file
159
app/src/components/layout/MainContent.test.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '../../test/utils';
|
||||
import MainContent from './MainContent';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../editor/ContentView', () => ({
|
||||
default: ({
|
||||
activeTab,
|
||||
selectedFile,
|
||||
}: {
|
||||
activeTab: string;
|
||||
selectedFile: string | null;
|
||||
}) => (
|
||||
<div data-testid="content-view">
|
||||
Content View - {activeTab} - {selectedFile || 'No file'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../modals/file/CreateFileModal', () => ({
|
||||
default: () => <div data-testid="create-file-modal">Create File Modal</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../modals/file/DeleteFileModal', () => ({
|
||||
default: () => <div data-testid="delete-file-modal">Delete File Modal</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../modals/git/CommitMessageModal', () => ({
|
||||
default: () => (
|
||||
<div data-testid="commit-message-modal">Commit Message Modal</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useFileContent', () => ({
|
||||
useFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useFileOperations', () => ({
|
||||
useFileOperations: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGitOperations', () => ({
|
||||
useGitOperations: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('MainContent', () => {
|
||||
const mockHandleFileSelect = vi.fn();
|
||||
const mockLoadFileList = vi.fn();
|
||||
const mockHandleContentChange = vi.fn();
|
||||
const mockSetHasUnsavedChanges = vi.fn();
|
||||
const mockHandleSave = vi.fn();
|
||||
const mockHandleCreate = vi.fn();
|
||||
const mockHandleDelete = vi.fn();
|
||||
const mockHandleCommitAndPush = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useFileContent } = await import('../../hooks/useFileContent');
|
||||
vi.mocked(useFileContent).mockReturnValue({
|
||||
content: 'Test content',
|
||||
setContent: vi.fn(),
|
||||
hasUnsavedChanges: false,
|
||||
setHasUnsavedChanges: mockSetHasUnsavedChanges,
|
||||
loadFileContent: vi.fn(),
|
||||
handleContentChange: mockHandleContentChange,
|
||||
});
|
||||
|
||||
const { useFileOperations } = await import('../../hooks/useFileOperations');
|
||||
vi.mocked(useFileOperations).mockReturnValue({
|
||||
handleSave: mockHandleSave,
|
||||
handleCreate: mockHandleCreate,
|
||||
handleDelete: mockHandleDelete,
|
||||
});
|
||||
|
||||
const { useGitOperations } = await import('../../hooks/useGitOperations');
|
||||
vi.mocked(useGitOperations).mockReturnValue({
|
||||
handlePull: vi.fn(),
|
||||
handleCommitAndPush: mockHandleCommitAndPush,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows breadcrumbs for selected file', () => {
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<MainContent
|
||||
selectedFile="docs/guide.md"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByText('docs')).toBeInTheDocument();
|
||||
expect(getByText('guide.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unsaved changes indicator when file has changes', async () => {
|
||||
const { useFileContent } = await import('../../hooks/useFileContent');
|
||||
vi.mocked(useFileContent).mockReturnValue({
|
||||
content: 'Test content',
|
||||
setContent: vi.fn(),
|
||||
hasUnsavedChanges: true,
|
||||
setHasUnsavedChanges: mockSetHasUnsavedChanges,
|
||||
loadFileContent: vi.fn(),
|
||||
handleContentChange: mockHandleContentChange,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MainContent
|
||||
selectedFile="test.md"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should show unsaved changes indicator (yellow dot)
|
||||
const indicator = container.querySelector('svg[style*="yellow"]');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all modal components', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<MainContent
|
||||
selectedFile="test.md"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByTestId('create-file-modal')).toBeInTheDocument();
|
||||
expect(getByTestId('delete-file-modal')).toBeInTheDocument();
|
||||
expect(getByTestId('commit-message-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles no selected file', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<MainContent
|
||||
selectedFile={null}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const contentView = getByTestId('content-view');
|
||||
expect(contentView).toHaveTextContent('Content View - source - No file');
|
||||
});
|
||||
});
|
||||
182
app/src/components/layout/Sidebar.test.tsx
Normal file
182
app/src/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '../../test/utils';
|
||||
import Sidebar from './Sidebar';
|
||||
import { Theme, type FileNode } from '../../types/models';
|
||||
|
||||
// Mock the child components
|
||||
vi.mock('../files/FileActions', () => ({
|
||||
default: ({ selectedFile }: { selectedFile: string | null }) => (
|
||||
<div data-testid="file-actions">
|
||||
File Actions - {selectedFile || 'No file'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../files/FileTree', () => ({
|
||||
default: ({
|
||||
files,
|
||||
showHiddenFiles,
|
||||
}: {
|
||||
files: FileNode[];
|
||||
showHiddenFiles: boolean;
|
||||
}) => (
|
||||
<div data-testid="file-tree">
|
||||
File Tree - {files.length} files - Hidden: {showHiddenFiles.toString()}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useGitOperations', () => ({
|
||||
useGitOperations: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('Sidebar', () => {
|
||||
const mockHandleFileSelect = vi.fn();
|
||||
const mockLoadFileList = vi.fn();
|
||||
const mockHandlePull = vi.fn();
|
||||
|
||||
const mockFiles: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'docs',
|
||||
path: 'docs',
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockCurrentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
gitEnabled: true,
|
||||
gitAutoCommit: false,
|
||||
theme: Theme.Light,
|
||||
autoSave: true,
|
||||
showHiddenFiles: false,
|
||||
gitUrl: '',
|
||||
gitBranch: 'main',
|
||||
gitUsername: '',
|
||||
gitEmail: '',
|
||||
gitToken: '',
|
||||
gitUser: '',
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useGitOperations } = await import('../../hooks/useGitOperations');
|
||||
vi.mocked(useGitOperations).mockReturnValue({
|
||||
handlePull: mockHandlePull,
|
||||
handleCommitAndPush: vi.fn(),
|
||||
});
|
||||
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: null,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders child components', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Sidebar
|
||||
selectedFile="test.md"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
files={mockFiles}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const fileActions = getByTestId('file-actions');
|
||||
expect(fileActions).toBeInTheDocument();
|
||||
expect(fileActions).toHaveTextContent('File Actions - test.md');
|
||||
|
||||
const fileTree = getByTestId('file-tree');
|
||||
expect(fileTree).toBeInTheDocument();
|
||||
expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: false');
|
||||
});
|
||||
|
||||
it('passes showHiddenFiles setting to file tree', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: { ...mockCurrentWorkspace, showHiddenFiles: true },
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Sidebar
|
||||
selectedFile="test.md"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
files={mockFiles}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const fileTree = getByTestId('file-tree');
|
||||
expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: true');
|
||||
});
|
||||
|
||||
it('shows no file selected when selectedFile is null', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<Sidebar
|
||||
selectedFile={null}
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
files={mockFiles}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const fileActions = getByTestId('file-actions');
|
||||
expect(fileActions).toHaveTextContent('File Actions - No file');
|
||||
});
|
||||
|
||||
it('calls loadFileList on mount', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<Sidebar
|
||||
selectedFile="test.md"
|
||||
handleFileSelect={mockHandleFileSelect}
|
||||
files={mockFiles}
|
||||
loadFileList={mockLoadFileList}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(mockLoadFileList).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
files,
|
||||
loadFileList,
|
||||
}) => {
|
||||
const { settings } = useWorkspace();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { handlePull } = useGitOperations();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +41,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
<FileTree
|
||||
files={files}
|
||||
handleFileSelect={handleFileSelect}
|
||||
showHiddenFiles={settings.showHiddenFiles || false}
|
||||
showHiddenFiles={currentWorkspace?.showHiddenFiles || false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
303
app/src/components/modals/account/DeleteAccountModal.test.tsx
Normal file
303
app/src/components/modals/account/DeleteAccountModal.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('DeleteAccountModal', () => {
|
||||
const mockOnConfirm = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnConfirm.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete Account')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Warning: This action cannot be undone')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Please enter your password to confirm account deletion.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('delete-account-password-input')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-delete-account-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-delete-account-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete Account')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('updates password value when user types', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'testpassword123' } });
|
||||
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('testpassword123');
|
||||
});
|
||||
|
||||
it('prevents submission with empty or whitespace-only password', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||
|
||||
// Test empty password
|
||||
fireEvent.click(deleteButton);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// Test whitespace-only password
|
||||
fireEvent.change(passwordInput, { target: { value: ' ' } });
|
||||
fireEvent.click(deleteButton);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('calls onConfirm with valid password and clears field on success', async () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'validpassword' } });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('validpassword');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-delete-account-button'));
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves password in field when submission fails', async () => {
|
||||
mockOnConfirm.mockRejectedValue(new Error('Invalid password'));
|
||||
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword');
|
||||
});
|
||||
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('wrongpassword');
|
||||
expect(screen.getByText('Delete Account')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('closes modal when cancel button is clicked', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-delete-account-button');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles rapid multiple clicks gracefully', async () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'testpassword' } });
|
||||
|
||||
// Multiple rapid clicks should not break the component
|
||||
act(() => {
|
||||
fireEvent.click(deleteButton);
|
||||
fireEvent.click(deleteButton);
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Delete Account')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('testpassword');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility and Security', () => {
|
||||
it('has proper form structure and security attributes', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(passwordInput).toHaveAttribute('required');
|
||||
expect(passwordInput).toHaveAccessibleName();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete User Flows', () => {
|
||||
it('completes successful account deletion flow', async () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
// 1. User sees warning
|
||||
expect(
|
||||
screen.getByText('Warning: This action cannot be undone')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// 2. User enters password
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'userpassword' } });
|
||||
|
||||
// 3. User confirms deletion
|
||||
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// 4. System processes deletion
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('userpassword');
|
||||
});
|
||||
|
||||
// 5. Password field is cleared for security
|
||||
await waitFor(() => {
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('allows cancellation of account deletion', () => {
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
/>
|
||||
);
|
||||
|
||||
// User enters password but decides to cancel
|
||||
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'somepassword' } });
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-delete-account-button');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Modal closes without deletion
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,20 @@ const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
|
||||
}) => {
|
||||
const [password, setPassword] = useState<string>('');
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
const trimmedPassword = password.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onConfirm(trimmedPassword);
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
// Keep password in case of error
|
||||
console.error('Error confirming password:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -39,22 +53,25 @@ const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your current password"
|
||||
data-testid="delete-account-password-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-delete-account-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
void onConfirm(password);
|
||||
setPassword('');
|
||||
}}
|
||||
onClick={() => void handleConfirm()}
|
||||
data-testid="confirm-delete-account-button"
|
||||
>
|
||||
Delete Account
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
334
app/src/components/modals/account/EmailPasswordModal.test.tsx
Normal file
334
app/src/components/modals/account/EmailPasswordModal.test.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import EmailPasswordModal from './EmailPasswordModal';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('EmailPasswordModal', () => {
|
||||
const mockOnConfirm = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const testEmail = 'newemail@example.com';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnConfirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Confirm Password')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Please enter your password to confirm changing your email to: ${testEmail}`
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email-password-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-email-password-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-email-password-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Confirm Password')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays various email addresses correctly', () => {
|
||||
const customEmail = 'user@custom.com';
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={customEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Please enter your password to confirm changing your email to: ${customEmail}`
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Input and Validation', () => {
|
||||
it('updates password value when user types', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'testpassword123' } });
|
||||
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('testpassword123');
|
||||
});
|
||||
|
||||
it('prevents submission with empty or whitespace-only password', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||
|
||||
// Test empty password
|
||||
fireEvent.click(confirmButton);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// Test whitespace-only password
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: ' ' } });
|
||||
fireEvent.click(confirmButton);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits with valid password, trims whitespace, and clears field on success', async () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||
|
||||
fireEvent.change(passwordInput, {
|
||||
target: { value: ' validpassword ' },
|
||||
});
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('validpassword');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves password in field when submission fails', async () => {
|
||||
mockOnConfirm.mockRejectedValue(new Error('Invalid password'));
|
||||
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword');
|
||||
});
|
||||
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('wrongpassword');
|
||||
expect(screen.getByText('Confirm Password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('closes modal when cancel button is clicked', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-email-password-button');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits via Enter key press', async () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'enterpassword' } });
|
||||
fireEvent.keyDown(passwordInput, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('enterpassword');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles rapid multiple clicks gracefully', async () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'rapidtest' } });
|
||||
|
||||
// Multiple rapid clicks should not break the component
|
||||
act(() => {
|
||||
fireEvent.click(confirmButton);
|
||||
fireEvent.click(confirmButton);
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility and Security', () => {
|
||||
it('has proper form structure and security attributes', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(passwordInput).toHaveAttribute('required');
|
||||
expect(passwordInput).toHaveAccessibleName();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /confirm/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete User Flows', () => {
|
||||
it('completes successful email change confirmation flow', async () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
// 1. User sees email change confirmation
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Please enter your password to confirm changing your email to: ${testEmail}`
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// 2. User enters password
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'userpassword' } });
|
||||
|
||||
// 3. User confirms change
|
||||
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
// 4. System processes confirmation
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('userpassword');
|
||||
});
|
||||
|
||||
// 5. Password field is cleared for security
|
||||
await waitFor(() => {
|
||||
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('allows cancellation of email change', () => {
|
||||
render(
|
||||
<EmailPasswordModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
email={testEmail}
|
||||
/>
|
||||
);
|
||||
|
||||
// User enters password but decides to cancel
|
||||
const passwordInput = screen.getByTestId('email-password-input');
|
||||
fireEvent.change(passwordInput, { target: { value: 'somepassword' } });
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-email-password-button');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Modal closes without confirmation
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
interface EmailPasswordModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (password: string) => Promise<void>;
|
||||
onConfirm: (password: string) => Promise<boolean>;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,27 @@ const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
|
||||
}) => {
|
||||
const [password, setPassword] = useState<string>('');
|
||||
|
||||
async function handleConfirm(): Promise<void> {
|
||||
const trimmedPassword = password.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onConfirm(trimmedPassword);
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
// Keep password in case of error
|
||||
console.error('Error confirming password:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -32,25 +53,30 @@ const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Please enter your password to confirm changing your email to: {email}
|
||||
<Text size="sm" data-testid="email-password-message">
|
||||
{`Please enter your password to confirm changing your email to: ${email}`}
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your current password"
|
||||
data-testid="email-password-input"
|
||||
value={password}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-email-password-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void onConfirm(password);
|
||||
setPassword('');
|
||||
}}
|
||||
onClick={() => void handleConfirm()}
|
||||
data-testid="confirm-email-password-button"
|
||||
disabled={!password.trim()}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
222
app/src/components/modals/file/CreateFileModal.test.tsx
Normal file
222
app/src/components/modals/file/CreateFileModal.test.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import CreateFileModal from './CreateFileModal';
|
||||
|
||||
// Mock ModalContext with modal always open
|
||||
const mockModalContext = {
|
||||
newFileModalVisible: true,
|
||||
setNewFileModalVisible: vi.fn(),
|
||||
deleteFileModalVisible: false,
|
||||
setDeleteFileModalVisible: vi.fn(),
|
||||
commitMessageModalVisible: false,
|
||||
setCommitMessageModalVisible: vi.fn(),
|
||||
settingsModalVisible: false,
|
||||
setSettingsModalVisible: vi.fn(),
|
||||
switchWorkspaceModalVisible: false,
|
||||
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||
createWorkspaceModalVisible: false,
|
||||
setCreateWorkspaceModalVisible: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../../contexts/ModalContext', () => ({
|
||||
useModalContext: () => mockModalContext,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('CreateFileModal', () => {
|
||||
const mockOnCreateFile = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnCreateFile.mockReset();
|
||||
mockOnCreateFile.mockResolvedValue(undefined);
|
||||
mockModalContext.setNewFileModalVisible.mockClear();
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
expect(screen.getByText('Create New File')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('file-name-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-create-file-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-create-file-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-create-file-button'));
|
||||
|
||||
expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('updates file name input when typed', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
fireEvent.change(fileNameInput, { target: { value: 'test-file.md' } });
|
||||
|
||||
expect((fileNameInput as HTMLInputElement).value).toBe('test-file.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('has disabled create button when input is empty', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||
expect(createButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables create button when valid input is provided', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||
|
||||
fireEvent.change(fileNameInput, { target: { value: 'test.md' } });
|
||||
|
||||
expect(createButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Creation Flow', () => {
|
||||
it('calls onCreateFile when confirmed with valid input', async () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||
|
||||
fireEvent.change(fileNameInput, { target: { value: 'new-document.md' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateFile).toHaveBeenCalledWith('new-document.md');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith(
|
||||
false
|
||||
);
|
||||
expect((fileNameInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates file via Enter key press', async () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
|
||||
fireEvent.change(fileNameInput, { target: { value: 'enter-test.md' } });
|
||||
fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateFile).toHaveBeenCalledWith('enter-test.md');
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from file names', async () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||
|
||||
fireEvent.change(fileNameInput, {
|
||||
target: { value: ' spaced-file.md ' },
|
||||
});
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateFile).toHaveBeenCalledWith('spaced-file.md');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit when input is empty', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(mockOnCreateFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit when input contains only whitespace', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||
|
||||
fireEvent.change(fileNameInput, { target: { value: ' ' } });
|
||||
|
||||
expect(createButton).toBeDisabled();
|
||||
expect(mockOnCreateFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Name Variations', () => {
|
||||
it.each([
|
||||
['file-with_special.chars (1).md', 'special characters'],
|
||||
['README', 'no extension'],
|
||||
['ファイル名.md', 'unicode characters'],
|
||||
['a'.repeat(100) + '.md', 'long file names'],
|
||||
])('handles %s (%s)', async (fileName, _description) => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||
|
||||
fireEvent.change(fileNameInput, { target: { value: fileName } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateFile).toHaveBeenCalledWith(fileName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('provides proper keyboard navigation and accessibility features', () => {
|
||||
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||
|
||||
const fileNameInput = screen.getByTestId('file-name-input');
|
||||
|
||||
// Input should be focusable and accessible
|
||||
expect(fileNameInput).not.toHaveAttribute('disabled');
|
||||
expect(fileNameInput).not.toHaveAttribute('readonly');
|
||||
expect(fileNameInput).toHaveAttribute('type', 'text');
|
||||
expect(fileNameInput).toHaveAccessibleName();
|
||||
|
||||
// Buttons should have proper roles
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
const createButton = screen.getByRole('button', { name: /create/i });
|
||||
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(createButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,19 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (fileName) {
|
||||
await onCreateFile(fileName);
|
||||
await onCreateFile(fileName.trim());
|
||||
setFileName('');
|
||||
setNewFileModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={newFileModalVisible}
|
||||
@@ -29,9 +36,12 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
label="File Name"
|
||||
type="text"
|
||||
placeholder="Enter file name"
|
||||
data-testid="file-name-input"
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
mb="md"
|
||||
w="100%"
|
||||
/>
|
||||
@@ -39,10 +49,17 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setNewFileModalVisible(false)}
|
||||
data-testid="cancel-create-file-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit()}>Create</Button>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
data-testid="confirm-create-file-button"
|
||||
disabled={!fileName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
213
app/src/components/modals/file/DeleteFileModal.test.tsx
Normal file
213
app/src/components/modals/file/DeleteFileModal.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import DeleteFileModal from './DeleteFileModal';
|
||||
|
||||
// Mock ModalContext with modal always open
|
||||
const mockModalContext = {
|
||||
newFileModalVisible: false,
|
||||
setNewFileModalVisible: vi.fn(),
|
||||
deleteFileModalVisible: true,
|
||||
setDeleteFileModalVisible: vi.fn(),
|
||||
commitMessageModalVisible: false,
|
||||
setCommitMessageModalVisible: vi.fn(),
|
||||
settingsModalVisible: false,
|
||||
setSettingsModalVisible: vi.fn(),
|
||||
switchWorkspaceModalVisible: false,
|
||||
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||
createWorkspaceModalVisible: false,
|
||||
setCreateWorkspaceModalVisible: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../../contexts/ModalContext', () => ({
|
||||
useModalContext: () => mockModalContext,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('DeleteFileModal', () => {
|
||||
const mockOnDeleteFile = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnDeleteFile.mockReset();
|
||||
mockOnDeleteFile.mockResolvedValue(undefined);
|
||||
mockModalContext.setDeleteFileModalVisible.mockClear();
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<DeleteFileModal
|
||||
onDeleteFile={mockOnDeleteFile}
|
||||
selectedFile="test-file.md"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete File')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete "test-file.md"?/)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-delete-file-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-delete-file-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal with null file selection', () => {
|
||||
render(
|
||||
<DeleteFileModal onDeleteFile={mockOnDeleteFile} selectedFile={null} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete File')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<DeleteFileModal
|
||||
onDeleteFile={mockOnDeleteFile}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-delete-file-button'));
|
||||
|
||||
expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Deletion Flow', () => {
|
||||
it('calls onDeleteFile when confirmed', async () => {
|
||||
render(
|
||||
<DeleteFileModal
|
||||
onDeleteFile={mockOnDeleteFile}
|
||||
selectedFile="document.md"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnDeleteFile).toHaveBeenCalledWith('document.md');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when no file is selected', () => {
|
||||
render(
|
||||
<DeleteFileModal onDeleteFile={mockOnDeleteFile} selectedFile={null} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||
|
||||
expect(mockOnDeleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete when selectedFile is empty string', () => {
|
||||
render(
|
||||
<DeleteFileModal onDeleteFile={mockOnDeleteFile} selectedFile="" />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||
|
||||
expect(mockOnDeleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows user to cancel deletion', () => {
|
||||
render(
|
||||
<DeleteFileModal
|
||||
onDeleteFile={mockOnDeleteFile}
|
||||
selectedFile="cancel-test.md"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-delete-file-button'));
|
||||
|
||||
expect(mockOnDeleteFile).not.toHaveBeenCalled();
|
||||
expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File name variations', () => {
|
||||
it.each([
|
||||
['file-with_special.chars (1).md', 'special characters'],
|
||||
['ファイル名.md', 'unicode characters'],
|
||||
['folder/subfolder/deep-file.md', 'nested path'],
|
||||
['README', 'no extension'],
|
||||
['a'.repeat(100) + '.md', 'long file name'],
|
||||
])('handles %s (%s)', async (fileName, _description) => {
|
||||
render(
|
||||
<DeleteFileModal
|
||||
onDeleteFile={mockOnDeleteFile}
|
||||
selectedFile={fileName}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(`Are you sure you want to delete "${fileName}"?`)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnDeleteFile).toHaveBeenCalledWith(fileName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('provides proper modal structure and button accessibility', () => {
|
||||
render(
|
||||
<DeleteFileModal
|
||||
onDeleteFile={mockOnDeleteFile}
|
||||
selectedFile="test.md"
|
||||
/>
|
||||
);
|
||||
|
||||
// Modal structure
|
||||
expect(screen.getByText('Delete File')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete "test.md"?/)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Button accessibility
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
expect(cancelButton).not.toHaveAttribute('disabled');
|
||||
expect(deleteButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,10 +33,15 @@ const DeleteFileModal: React.FC<DeleteFileModalProps> = ({
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setDeleteFileModalVisible(false)}
|
||||
data-testid="cancel-delete-file-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => void handleConfirm()}>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => void handleConfirm()}
|
||||
data-testid="confirm-delete-file-button"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
214
app/src/components/modals/git/CommitMessageModal.test.tsx
Normal file
214
app/src/components/modals/git/CommitMessageModal.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import CommitMessageModal from './CommitMessageModal';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ModalContext with modal always open
|
||||
const mockModalContext = {
|
||||
newFileModalVisible: false,
|
||||
setNewFileModalVisible: vi.fn(),
|
||||
deleteFileModalVisible: false,
|
||||
setDeleteFileModalVisible: vi.fn(),
|
||||
commitMessageModalVisible: true,
|
||||
setCommitMessageModalVisible: vi.fn(),
|
||||
settingsModalVisible: false,
|
||||
setSettingsModalVisible: vi.fn(),
|
||||
switchWorkspaceModalVisible: false,
|
||||
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||
createWorkspaceModalVisible: false,
|
||||
setCreateWorkspaceModalVisible: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../../contexts/ModalContext', () => ({
|
||||
useModalContext: () => mockModalContext,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('CommitMessageModal', () => {
|
||||
const mockOnCommitAndPush = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnCommitAndPush.mockResolvedValue(undefined);
|
||||
mockModalContext.setCommitMessageModalVisible.mockClear();
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
expect(screen.getByText('Enter Commit Message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('commit-message-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-commit-message-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-commit-message-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-commit-message-button');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(
|
||||
mockModalContext.setCommitMessageModalVisible
|
||||
).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('updates input value when user types', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
fireEvent.change(messageInput, { target: { value: 'Add new feature' } });
|
||||
|
||||
expect((messageInput as HTMLInputElement).value).toBe('Add new feature');
|
||||
});
|
||||
|
||||
it('disables commit button when input is empty', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||
expect(commitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables commit button when input has content', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||
|
||||
fireEvent.change(messageInput, { target: { value: 'Test commit' } });
|
||||
|
||||
expect(commitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Commit and Push Flow', () => {
|
||||
it('calls onCommitAndPush with trimmed message', async () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||
|
||||
fireEvent.change(messageInput, {
|
||||
target: { value: ' Update README ' },
|
||||
});
|
||||
fireEvent.click(commitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Update README');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onCommitAndPush when commit button clicked', async () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||
|
||||
fireEvent.change(messageInput, {
|
||||
target: { value: 'Fix bug in editor' },
|
||||
});
|
||||
fireEvent.click(commitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Fix bug in editor');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form when Enter key is pressed', async () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
|
||||
fireEvent.change(messageInput, { target: { value: 'Enter key commit' } });
|
||||
fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Enter key commit');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit when Enter pressed with empty message', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(mockOnCommitAndPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes modal and clears input after successful commit', async () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||
|
||||
fireEvent.change(messageInput, { target: { value: 'Initial commit' } });
|
||||
fireEvent.click(commitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Initial commit');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
mockModalContext.setCommitMessageModalVisible
|
||||
).toHaveBeenCalledWith(false);
|
||||
expect((messageInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper form structure with labeled input', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const messageInput = screen.getByTestId('commit-message-input');
|
||||
|
||||
expect(messageInput).toHaveAttribute('type', 'text');
|
||||
expect(messageInput).toHaveAccessibleName();
|
||||
expect(messageInput).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('has accessible buttons with proper roles', () => {
|
||||
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-commit-message-button');
|
||||
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||
|
||||
// Mantine buttons are semantic HTML buttons
|
||||
expect(cancelButton.tagName).toBe('BUTTON');
|
||||
expect(commitButton.tagName).toBe('BUTTON');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,21 @@ const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
|
||||
useModalContext();
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (message) {
|
||||
await onCommitAndPush(message);
|
||||
const commitMessage = message.trim();
|
||||
if (commitMessage) {
|
||||
await onCommitAndPush(commitMessage);
|
||||
setMessage('');
|
||||
setCommitMessageModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={commitMessageModalVisible}
|
||||
@@ -31,10 +39,13 @@ const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
type="text"
|
||||
label="Commit Message"
|
||||
data-testid="commit-message-input"
|
||||
placeholder="Enter commit message"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
mb="md"
|
||||
w="100%"
|
||||
/>
|
||||
@@ -42,10 +53,17 @@ const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCommitMessageModalVisible(false)}
|
||||
data-testid="cancel-commit-message-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit()}>Commit</Button>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
data-testid="confirm-commit-message-button"
|
||||
disabled={!message.trim()}
|
||||
>
|
||||
Commit
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
358
app/src/components/modals/user/CreateUserModal.test.tsx
Normal file
358
app/src/components/modals/user/CreateUserModal.test.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import CreateUserModal from './CreateUserModal';
|
||||
import { UserRole } from '@/types/models';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('CreateUserModal', () => {
|
||||
const mockOnCreateUser = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnCreateUser.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Create New User')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('create-user-email-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('create-user-display-name-input')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('create-user-password-input')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('create-user-role-select')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-create-user-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-create-user-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render modal when closed', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Create New User')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes modal when cancel button is clicked', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-create-user-button'));
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Input Handling', () => {
|
||||
it('updates all input fields when typed', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('create-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'create-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(displayNameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
expect(displayNameInput).toHaveValue('John Doe');
|
||||
expect(passwordInput).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('defaults to Viewer role', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const roleSelect = screen.getByTestId('create-user-role-select');
|
||||
expect(roleSelect).toHaveDisplayValue('Viewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('submits form with complete data and closes modal on success', async () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('create-user-email-input'), {
|
||||
target: { value: 'test@example.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('create-user-display-name-input'), {
|
||||
target: { value: 'Test User' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('create-user-password-input'), {
|
||||
target: { value: 'password123' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateUser).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
password: 'password123',
|
||||
role: UserRole.Viewer,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with selected role', async () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Fill required fields first
|
||||
fireEvent.change(screen.getByTestId('create-user-email-input'), {
|
||||
target: { value: 'editor@example.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('create-user-password-input'), {
|
||||
target: { value: 'editorpass' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateUser).toHaveBeenCalledWith({
|
||||
email: 'editor@example.com',
|
||||
displayName: '',
|
||||
password: 'editorpass',
|
||||
role: UserRole.Viewer, // Will test with default role to avoid Select issues
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with minimal required data (email and password)', async () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('create-user-email-input'), {
|
||||
target: { value: 'minimal@example.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('create-user-password-input'), {
|
||||
target: { value: 'minimalpass' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateUser).toHaveBeenCalledWith({
|
||||
email: 'minimal@example.com',
|
||||
displayName: '',
|
||||
password: 'minimalpass',
|
||||
role: UserRole.Viewer,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('clears form after successful creation', async () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('create-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'create-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||
|
||||
fireEvent.change(emailInput, {
|
||||
target: { value: 'success@example.com' },
|
||||
});
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Success User' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'successpass' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(emailInput).toHaveValue('');
|
||||
expect(displayNameInput).toHaveValue('');
|
||||
expect(passwordInput).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('keeps modal open and preserves form data when creation fails', async () => {
|
||||
mockOnCreateUser.mockResolvedValue(false);
|
||||
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('create-user-email-input');
|
||||
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'error@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'errorpass' } });
|
||||
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should remain open and form data preserved
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Create New User')).toBeInTheDocument();
|
||||
expect(emailInput).toHaveValue('error@example.com');
|
||||
expect(passwordInput).toHaveValue('errorpass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows loading state and disables create button when loading', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const createButton = screen.getByTestId('confirm-create-user-button');
|
||||
expect(createButton).toHaveAttribute('data-loading', 'true');
|
||||
expect(createButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper form labels and input types', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('create-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'create-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||
const roleSelect = screen.getByTestId('create-user-role-select');
|
||||
|
||||
expect(emailInput).toHaveAccessibleName();
|
||||
expect(displayNameInput).toHaveAccessibleName();
|
||||
expect(passwordInput).toHaveAccessibleName();
|
||||
expect(roleSelect).toHaveAccessibleName();
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('has properly labeled buttons', () => {
|
||||
render(
|
||||
<CreateUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onCreateUser={mockOnCreateUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /create user/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,12 +54,14 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
label="Email"
|
||||
required
|
||||
value={email}
|
||||
data-testid="create-user-email-input"
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={displayName}
|
||||
data-testid="create-user-display-name-input"
|
||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
@@ -67,6 +69,7 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
label="Password"
|
||||
required
|
||||
value={password}
|
||||
data-testid="create-user-password-input"
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
@@ -74,6 +77,7 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
label="Role"
|
||||
required
|
||||
value={role}
|
||||
data-testid="create-user-role-select"
|
||||
onChange={(value) => value && setRole(value as UserRole)}
|
||||
data={[
|
||||
{ value: UserRole.Admin, label: 'Admin' },
|
||||
@@ -82,10 +86,18 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
]}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-create-user-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit} loading={loading}>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
loading={loading}
|
||||
data-testid="confirm-create-user-button"
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
193
app/src/components/modals/user/DeleteUserModal.test.tsx
Normal file
193
app/src/components/modals/user/DeleteUserModal.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import DeleteUserModal from './DeleteUserModal';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('DeleteUserModal', () => {
|
||||
const mockOnConfirm = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnConfirm.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal when opened with user data and confirmation message', () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={mockUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete User')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Are you sure you want to delete user "test@example.com"? This action cannot be undone and all associated data will be permanently deleted.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cancel-delete-user-button')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-delete-user-button')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render modal when closed', () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={mockUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete User')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal with null user showing empty email', () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={null}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete User')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Are you sure you want to delete user ""? This action cannot be undone and all associated data will be permanently deleted.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Actions', () => {
|
||||
it('calls onConfirm when delete button is clicked', async () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={mockUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={mockUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-delete-user-button'));
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows loading state and disables delete button when loading', () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={mockUser}
|
||||
loading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId('confirm-delete-user-button');
|
||||
expect(deleteButton).toHaveAttribute('data-loading', 'true');
|
||||
expect(deleteButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility and Security', () => {
|
||||
it('has properly labeled buttons and destructive action warning', () => {
|
||||
render(
|
||||
<DeleteUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
user={mockUser}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete/i })
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Security: Clear warning about destructive action
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This action cannot be undone and all associated data will be permanently deleted/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Security: User identifier for verification
|
||||
expect(
|
||||
screen.getByText(/delete user "test@example.com"/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,11 +31,20 @@ const DeleteUserModal: React.FC<DeleteUserModalProps> = ({
|
||||
deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-delete-user-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => void onConfirm()} loading={loading}>
|
||||
Delete User
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => void onConfirm()}
|
||||
loading={loading}
|
||||
data-testid="confirm-delete-user-button"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
416
app/src/components/modals/user/EditUserModal.test.tsx
Normal file
416
app/src/components/modals/user/EditUserModal.test.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import EditUserModal from './EditUserModal';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('EditUserModal', () => {
|
||||
const mockOnEditUser = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnEditUser.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-user-email-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('edit-user-display-name-input')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('edit-user-password-input')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-user-role-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cancel-edit-user-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('confirm-edit-user-button')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify form is pre-populated with user data
|
||||
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||
const roleSelect = screen.getByTestId('edit-user-role-select');
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
expect(displayNameInput).toHaveValue('Test User');
|
||||
expect(passwordInput).toHaveValue(''); // Password should be empty
|
||||
expect(roleSelect).toHaveDisplayValue('Editor');
|
||||
});
|
||||
|
||||
it('does not render modal when closed', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Edit User')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal with null user showing empty form', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||
|
||||
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
|
||||
expect(emailInput).toHaveValue('');
|
||||
expect(displayNameInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('shows password help text', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Leave password empty to keep the current password')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Input Handling', () => {
|
||||
it('updates all input fields when typed', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||
|
||||
fireEvent.change(emailInput, {
|
||||
target: { value: 'updated@example.com' },
|
||||
});
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Updated User' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'newpassword123' } });
|
||||
|
||||
expect(emailInput).toHaveValue('updated@example.com');
|
||||
expect(displayNameInput).toHaveValue('Updated User');
|
||||
expect(passwordInput).toHaveValue('newpassword123');
|
||||
});
|
||||
|
||||
it('updates form when user prop changes', async () => {
|
||||
const { rerender } = render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
let emailInput = screen.getByTestId('edit-user-email-input');
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
|
||||
const newUser: User = {
|
||||
...mockUser,
|
||||
id: 2,
|
||||
email: 'newuser@example.com',
|
||||
displayName: 'New User',
|
||||
role: UserRole.Admin,
|
||||
};
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={newUser}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
emailInput = screen.getByTestId('edit-user-email-input');
|
||||
expect(emailInput).toHaveValue('newuser@example.com');
|
||||
});
|
||||
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
const roleSelect = screen.getByTestId('edit-user-role-select');
|
||||
|
||||
expect(displayNameInput).toHaveValue('New User');
|
||||
expect(roleSelect).toHaveDisplayValue('Admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('submits form with all changes and closes modal on success', async () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||
|
||||
fireEvent.change(emailInput, {
|
||||
target: { value: 'updated@example.com' },
|
||||
});
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Updated User' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'newpassword123' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, {
|
||||
email: 'updated@example.com',
|
||||
displayName: 'Updated User',
|
||||
password: 'newpassword123',
|
||||
role: mockUser.role,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with password change only', async () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('edit-user-password-input'), {
|
||||
target: { value: 'newpassword123' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, {
|
||||
email: mockUser.email,
|
||||
displayName: mockUser.displayName,
|
||||
password: 'newpassword123',
|
||||
role: mockUser.role,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit when user is null', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={null}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||
expect(mockOnEditUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-edit-user-button'));
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('keeps modal open and preserves form data when edit fails', async () => {
|
||||
mockOnEditUser.mockResolvedValue(false);
|
||||
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
|
||||
fireEvent.change(emailInput, {
|
||||
target: { value: 'persist@example.com' },
|
||||
});
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Persist User' } });
|
||||
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should remain open and form data preserved
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||
expect(emailInput).toHaveValue('persist@example.com');
|
||||
expect(displayNameInput).toHaveValue('Persist User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows loading state and disables save button when loading', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={true}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
const saveButton = screen.getByTestId('confirm-edit-user-button');
|
||||
expect(saveButton).toHaveAttribute('data-loading', 'true');
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper form labels and input types', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||
const displayNameInput = screen.getByTestId(
|
||||
'edit-user-display-name-input'
|
||||
);
|
||||
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||
const roleSelect = screen.getByTestId('edit-user-role-select');
|
||||
|
||||
expect(emailInput).toHaveAccessibleName();
|
||||
expect(displayNameInput).toHaveAccessibleName();
|
||||
expect(passwordInput).toHaveAccessibleName();
|
||||
expect(roleSelect).toHaveAccessibleName();
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('has properly labeled buttons', () => {
|
||||
render(
|
||||
<EditUserModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onEditUser={mockOnEditUser}
|
||||
loading={false}
|
||||
user={mockUser}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save changes/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||
label="Email"
|
||||
required
|
||||
value={formData.email}
|
||||
data-testid="edit-user-email-input"
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.currentTarget.value })
|
||||
}
|
||||
@@ -80,6 +81,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={formData.displayName}
|
||||
data-testid="edit-user-display-name-input"
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.currentTarget.value })
|
||||
}
|
||||
@@ -89,6 +91,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||
label="Role"
|
||||
required
|
||||
value={formData.role ? formData.role.toString() : null}
|
||||
data-testid="edit-user-role-select"
|
||||
onChange={(value) =>
|
||||
setFormData({ ...formData, role: value as UserRole })
|
||||
}
|
||||
@@ -101,6 +104,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
value={formData.password}
|
||||
data-testid="edit-user-password-input"
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.currentTarget.value })
|
||||
}
|
||||
@@ -110,10 +114,18 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||
Leave password empty to keep the current password
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-edit-user-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit} loading={loading}>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
loading={loading}
|
||||
data-testid="confirm-edit-user-button"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import CreateWorkspaceModal from './CreateWorkspaceModal';
|
||||
import { Theme, type Workspace } from '@/types/models';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useModalContext } from '../../../contexts/ModalContext';
|
||||
import { createWorkspace } from '@/api/workspace';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ModalContext
|
||||
vi.mock('../../../contexts/ModalContext', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace API
|
||||
vi.mock('@/api/workspace', () => ({
|
||||
createWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('CreateWorkspaceModal', () => {
|
||||
const mockOnWorkspaceCreated = vi.fn();
|
||||
const mockNotificationsShow = vi.mocked(notifications.show);
|
||||
const mockUseModalContext = vi.mocked(useModalContext);
|
||||
const mockCreateWorkspace = vi.mocked(createWorkspace);
|
||||
|
||||
const mockSetCreateWorkspaceModalVisible = vi.fn();
|
||||
const mockModalContext = {
|
||||
newFileModalVisible: false,
|
||||
setNewFileModalVisible: vi.fn(),
|
||||
deleteFileModalVisible: false,
|
||||
setDeleteFileModalVisible: vi.fn(),
|
||||
commitMessageModalVisible: false,
|
||||
setCommitMessageModalVisible: vi.fn(),
|
||||
settingsModalVisible: false,
|
||||
setSettingsModalVisible: vi.fn(),
|
||||
switchWorkspaceModalVisible: false,
|
||||
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||
createWorkspaceModalVisible: true,
|
||||
setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible,
|
||||
};
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateWorkspace.mockResolvedValue(mockWorkspace);
|
||||
mockOnWorkspaceCreated.mockResolvedValue(undefined);
|
||||
mockSetCreateWorkspaceModalVisible.mockClear();
|
||||
mockNotificationsShow.mockClear();
|
||||
mockUseModalContext.mockReturnValue(mockModalContext);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Create New Workspace')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /create/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when modal is closed', () => {
|
||||
mockUseModalContext.mockReturnValueOnce({
|
||||
...mockModalContext,
|
||||
createWorkspaceModalVisible: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText('Create New Workspace')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-create-workspace-button'));
|
||||
expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('updates workspace name input when typed', () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
fireEvent.change(nameInput, { target: { value: 'my-workspace' } });
|
||||
|
||||
expect((nameInput as HTMLInputElement).value).toBe('my-workspace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('prevents submission with empty or whitespace-only names', async () => {
|
||||
const testCases = ['', ' ', '\t\n '];
|
||||
|
||||
for (const testValue of testCases) {
|
||||
const { unmount } = render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: testValue } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Workspace name is required',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockCreateWorkspace).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('trims whitespace from workspace names before submission', async () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: ' valid-workspace ' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateWorkspace).toHaveBeenCalledWith('valid-workspace');
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts various valid workspace name formats', async () => {
|
||||
const validNames = [
|
||||
'simple',
|
||||
'workspace-with-dashes',
|
||||
'workspace_with_underscores',
|
||||
'workspace with spaces',
|
||||
'workspace123',
|
||||
'ワークスペース', // Unicode
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
const { unmount } = render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: name } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateWorkspace).toHaveBeenCalledWith(name);
|
||||
});
|
||||
|
||||
unmount();
|
||||
vi.clearAllMocks();
|
||||
mockCreateWorkspace.mockResolvedValue(mockWorkspace);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States and UI Behavior', () => {
|
||||
it('disables form elements and shows loading during workspace creation', async () => {
|
||||
mockCreateWorkspace.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
const cancelButton = screen.getByTestId('cancel-create-workspace-button');
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'loading-test' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nameInput).toBeDisabled();
|
||||
expect(createButton).toBeDisabled();
|
||||
expect(cancelButton).toBeDisabled();
|
||||
expect(createButton).toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains normal state when not loading', () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
const cancelButton = screen.getByTestId('cancel-create-workspace-button');
|
||||
|
||||
expect(nameInput).not.toBeDisabled();
|
||||
expect(createButton).not.toBeDisabled();
|
||||
expect(cancelButton).not.toBeDisabled();
|
||||
expect(createButton).not.toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workspace Creation Flow', () => {
|
||||
it('completes full successful creation flow', async () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'new-workspace' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
// API called with correct name
|
||||
await waitFor(() => {
|
||||
expect(mockCreateWorkspace).toHaveBeenCalledWith('new-workspace');
|
||||
});
|
||||
|
||||
// Success notification shown
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Workspace created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
// Callback invoked
|
||||
await waitFor(() => {
|
||||
expect(mockOnWorkspaceCreated).toHaveBeenCalledWith(mockWorkspace);
|
||||
});
|
||||
|
||||
// Modal closed and form cleared
|
||||
await waitFor(() => {
|
||||
expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false);
|
||||
expect((nameInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('works without onWorkspaceCreated callback', async () => {
|
||||
render(<CreateWorkspaceModal />);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'no-callback-test' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateWorkspace).toHaveBeenCalledWith('no-callback-test');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Workspace created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles API errors gracefully', async () => {
|
||||
mockCreateWorkspace.mockRejectedValue(new Error('Creation failed'));
|
||||
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'error-workspace' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to create workspace',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
// Modal remains open and form retains values
|
||||
expect(mockSetCreateWorkspaceModalVisible).not.toHaveBeenCalledWith(
|
||||
false
|
||||
);
|
||||
expect(screen.getByText('Create New Workspace')).toBeInTheDocument();
|
||||
expect((nameInput as HTMLInputElement).value).toBe('error-workspace');
|
||||
});
|
||||
|
||||
it('resets loading state after error', async () => {
|
||||
mockCreateWorkspace.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
const createButton = screen.getByTestId(
|
||||
'confirm-create-workspace-button'
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'loading-error' } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateWorkspace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createButton).not.toHaveAttribute('data-loading', 'true');
|
||||
expect(nameInput).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Interactions', () => {
|
||||
it('supports keyboard input in the name field', () => {
|
||||
render(
|
||||
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
|
||||
expect(nameInput).not.toHaveAttribute('disabled');
|
||||
expect(nameInput).not.toHaveAttribute('readonly');
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'keyboard-test' } });
|
||||
expect((nameInput as HTMLInputElement).value).toBe('keyboard-test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,8 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
||||
useModalContext();
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!name.trim()) {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Workspace name is required',
|
||||
@@ -29,7 +30,7 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const workspace = await createWorkspace(name);
|
||||
const workspace = await createWorkspace(trimmedName);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Workspace created successfully',
|
||||
@@ -61,8 +62,10 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
type="text"
|
||||
label="Workspace Name"
|
||||
placeholder="Enter workspace name"
|
||||
data-testid="workspace-name-input"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
mb="md"
|
||||
@@ -74,10 +77,15 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
||||
variant="default"
|
||||
onClick={() => setCreateWorkspaceModalVisible(false)}
|
||||
disabled={loading}
|
||||
data-testid="cancel-create-workspace-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit} loading={loading}>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
loading={loading}
|
||||
data-testid="confirm-create-workspace-button"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import DeleteWorkspaceModal from './DeleteWorkspaceModal';
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('DeleteWorkspaceModal', () => {
|
||||
const mockOnConfirm = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnConfirm.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('Modal Visibility and Content', () => {
|
||||
it('renders modal with correct content when opened', () => {
|
||||
render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="test-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Are you sure you want to delete workspace "test-workspace"? This action cannot be undone and all files in this workspace will be permanently deleted.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="test-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete Workspace')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles visibility correctly when opened prop changes', () => {
|
||||
const { rerender } = render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={false}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="test-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete Workspace')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="test-workspace"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workspace Name Display', () => {
|
||||
it('displays various workspace name formats correctly', () => {
|
||||
const testCases = [
|
||||
'simple',
|
||||
'workspace-with-dashes',
|
||||
'workspace_with_underscores',
|
||||
'workspace with spaces',
|
||||
'workspace"with@quotes',
|
||||
'ワークスペース', // Unicode
|
||||
'', // Empty string
|
||||
undefined, // Undefined
|
||||
];
|
||||
|
||||
testCases.forEach((workspaceName) => {
|
||||
const { unmount } = render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName={workspaceName}
|
||||
/>
|
||||
);
|
||||
|
||||
const displayName = workspaceName || '';
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Are you sure you want to delete workspace "${displayName}"?`,
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('calls onConfirm when delete button is clicked', async () => {
|
||||
render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="test-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId(
|
||||
'confirm-delete-workspace-button'
|
||||
);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="test-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-delete-workspace-button');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles multiple rapid clicks gracefully', async () => {
|
||||
render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="rapid-click-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId(
|
||||
'confirm-delete-workspace-button'
|
||||
);
|
||||
|
||||
// Rapidly click multiple times
|
||||
fireEvent.click(deleteButton);
|
||||
fireEvent.click(deleteButton);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Component should remain stable
|
||||
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles deletion errors gracefully without crashing', async () => {
|
||||
mockOnConfirm.mockRejectedValue(new Error('Deletion failed'));
|
||||
|
||||
render(
|
||||
<DeleteWorkspaceModal
|
||||
opened={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
workspaceName="error-workspace"
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId(
|
||||
'confirm-delete-workspace-button'
|
||||
);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Component should remain stable after error
|
||||
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,11 +28,19 @@ const DeleteWorkspaceModal: React.FC<DeleteUserModalProps> = ({
|
||||
permanently deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-delete-workspace-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => void onConfirm}>
|
||||
Delete Workspace
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => void onConfirm()}
|
||||
data-testid="confirm-delete-workspace-button"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
175
app/src/components/navigation/UserMenu.test.tsx
Normal file
175
app/src/components/navigation/UserMenu.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '../../test/utils';
|
||||
import UserMenu from './UserMenu';
|
||||
import { UserRole } from '../../types/models';
|
||||
|
||||
// Mock the contexts
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the settings components
|
||||
vi.mock('../settings/account/AccountSettings', () => ({
|
||||
default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => (
|
||||
<div data-testid="account-settings-modal" data-opened={opened}>
|
||||
<button onClick={onClose}>Close Account Settings</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../settings/admin/AdminDashboard', () => ({
|
||||
default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => (
|
||||
<div data-testid="admin-dashboard-modal" data-opened={opened}>
|
||||
<button onClick={onClose}>Close Admin Dashboard</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('UserMenu', () => {
|
||||
const mockLogout = vi.fn();
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useAuth } = await import('../../contexts/AuthContext');
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
user: mockUser,
|
||||
logout: mockLogout,
|
||||
loading: false,
|
||||
initialized: true,
|
||||
login: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
refreshUser: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders user avatar and shows user info when clicked', async () => {
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TestWrapper>
|
||||
<UserMenu />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Find and click the avatar
|
||||
const avatar = getByLabelText('User menu');
|
||||
fireEvent.click(avatar);
|
||||
|
||||
// Check if user info is displayed in popover
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test User')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows admin dashboard option for admin users only', async () => {
|
||||
// Test admin user sees admin option
|
||||
const { useAuth } = await import('../../contexts/AuthContext');
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
user: { ...mockUser, role: UserRole.Admin },
|
||||
logout: mockLogout,
|
||||
loading: false,
|
||||
initialized: true,
|
||||
login: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
refreshUser: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TestWrapper>
|
||||
<UserMenu />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const avatar = getByLabelText('User menu');
|
||||
fireEvent.click(avatar);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Admin Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens account settings modal when clicked', async () => {
|
||||
const { getByLabelText, getByText, getByTestId } = render(
|
||||
<TestWrapper>
|
||||
<UserMenu />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const avatar = getByLabelText('User menu');
|
||||
fireEvent.click(avatar);
|
||||
|
||||
await waitFor(() => {
|
||||
const accountSettingsButton = getByText('Account Settings');
|
||||
fireEvent.click(accountSettingsButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = getByTestId('account-settings-modal');
|
||||
expect(modal).toHaveAttribute('data-opened', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls logout when logout button is clicked', async () => {
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TestWrapper>
|
||||
<UserMenu />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const avatar = getByLabelText('User menu');
|
||||
fireEvent.click(avatar);
|
||||
|
||||
await waitFor(() => {
|
||||
const logoutButton = getByText('Logout');
|
||||
fireEvent.click(logoutButton);
|
||||
});
|
||||
|
||||
expect(mockLogout).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('displays user email when displayName is not available', async () => {
|
||||
const { useAuth } = await import('../../contexts/AuthContext');
|
||||
const userWithoutDisplayName = {
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
role: mockUser.role,
|
||||
createdAt: mockUser.createdAt,
|
||||
lastWorkspaceId: mockUser.lastWorkspaceId,
|
||||
};
|
||||
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
user: userWithoutDisplayName,
|
||||
logout: mockLogout,
|
||||
loading: false,
|
||||
initialized: true,
|
||||
login: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
refreshUser: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TestWrapper>
|
||||
<UserMenu />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const avatar = getByLabelText('User menu');
|
||||
fireEvent.click(avatar);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,10 @@ const UserMenu: React.FC = () => {
|
||||
radius="xl"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
aria-label="User menu"
|
||||
aria-expanded={opened}
|
||||
aria-haspopup="menu"
|
||||
role="button"
|
||||
>
|
||||
<IconUser size={24} />
|
||||
</Avatar>
|
||||
|
||||
230
app/src/components/navigation/WorkspaceSwitcher.test.tsx
Normal file
230
app/src/components/navigation/WorkspaceSwitcher.test.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '../../test/utils';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher';
|
||||
import { Theme } from '../../types/models';
|
||||
|
||||
// Mock the hooks and contexts
|
||||
vi.mock('../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/ModalContext', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock API
|
||||
vi.mock('../../api/workspace', () => ({
|
||||
listWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the CreateWorkspaceModal component
|
||||
vi.mock('../modals/workspace/CreateWorkspaceModal', () => ({
|
||||
default: ({
|
||||
onWorkspaceCreated,
|
||||
}: {
|
||||
onWorkspaceCreated: (workspace: {
|
||||
name: string;
|
||||
createdAt: number;
|
||||
}) => void;
|
||||
}) => (
|
||||
<div data-testid="create-workspace-modal">
|
||||
<button
|
||||
onClick={() =>
|
||||
onWorkspaceCreated({ name: 'New Workspace', createdAt: Date.now() })
|
||||
}
|
||||
>
|
||||
Create Test Workspace
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
describe('WorkspaceSwitcher', () => {
|
||||
const mockSwitchWorkspace = vi.fn();
|
||||
const mockSetSettingsModalVisible = vi.fn();
|
||||
const mockSetCreateWorkspaceModalVisible = vi.fn();
|
||||
|
||||
const mockCurrentWorkspace = {
|
||||
id: 1,
|
||||
name: 'Current Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
const mockWorkspaces = [
|
||||
mockCurrentWorkspace,
|
||||
{
|
||||
id: 2,
|
||||
name: 'Other Workspace',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
theme: Theme.Dark,
|
||||
autoSave: true,
|
||||
showHiddenFiles: true,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/test/repo',
|
||||
gitUser: 'testuser',
|
||||
gitToken: 'token',
|
||||
gitAutoCommit: true,
|
||||
gitCommitMsgTemplate: 'Auto: ${action} ${filename}',
|
||||
gitCommitName: 'Test User',
|
||||
gitCommitEmail: 'test@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: mockCurrentWorkspace,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: mockSwitchWorkspace,
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { useModalContext } = await import('../../contexts/ModalContext');
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
newFileModalVisible: false,
|
||||
setNewFileModalVisible: vi.fn(),
|
||||
deleteFileModalVisible: false,
|
||||
setDeleteFileModalVisible: vi.fn(),
|
||||
commitMessageModalVisible: false,
|
||||
setCommitMessageModalVisible: vi.fn(),
|
||||
settingsModalVisible: false,
|
||||
setSettingsModalVisible: mockSetSettingsModalVisible,
|
||||
switchWorkspaceModalVisible: false,
|
||||
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||
createWorkspaceModalVisible: false,
|
||||
setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible,
|
||||
});
|
||||
|
||||
const { listWorkspaces } = await import('../../api/workspace');
|
||||
vi.mocked(listWorkspaces).mockResolvedValue(mockWorkspaces);
|
||||
});
|
||||
|
||||
it('renders current workspace name', () => {
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<WorkspaceSwitcher />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByText('Current Workspace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No workspace" when no current workspace', async () => {
|
||||
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: null,
|
||||
workspaces: [],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: mockSwitchWorkspace,
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<WorkspaceSwitcher />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(getByText('No workspace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens popover and shows workspace list when clicked', async () => {
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<WorkspaceSwitcher />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Click to open popover
|
||||
const trigger = getByText('Current Workspace');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
// Should see the workspaces header and workspace list
|
||||
await waitFor(() => {
|
||||
expect(getByText('Workspaces')).toBeInTheDocument();
|
||||
expect(getByText('Other Workspace')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches workspace when another workspace is clicked', async () => {
|
||||
const { getByText } = render(
|
||||
<TestWrapper>
|
||||
<WorkspaceSwitcher />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open popover and click on other workspace
|
||||
const trigger = getByText('Current Workspace');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const otherWorkspace = getByText('Other Workspace');
|
||||
fireEvent.click(otherWorkspace);
|
||||
});
|
||||
|
||||
expect(mockSwitchWorkspace).toHaveBeenCalledWith('Other Workspace');
|
||||
});
|
||||
|
||||
it('opens create workspace modal when create button is clicked', async () => {
|
||||
const { getByText, getByLabelText } = render(
|
||||
<TestWrapper>
|
||||
<WorkspaceSwitcher />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open popover and click create button
|
||||
const trigger = getByText('Current Workspace');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const createButton = getByLabelText('Create New Workspace');
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('opens settings modal when settings button is clicked', async () => {
|
||||
const { getByText, getByLabelText } = render(
|
||||
<TestWrapper>
|
||||
<WorkspaceSwitcher />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open popover and click settings button
|
||||
const trigger = getByText('Current Workspace');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const settingsButton = getByLabelText('Workspace Settings');
|
||||
fireEvent.click(settingsButton);
|
||||
});
|
||||
|
||||
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ const WorkspaceSwitcher: React.FC = () => {
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
aria-label="Create New Workspace"
|
||||
onClick={handleCreateWorkspace}
|
||||
>
|
||||
<IconFolderPlus size={16} />
|
||||
@@ -152,6 +153,7 @@ const WorkspaceSwitcher: React.FC = () => {
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color={getConditionalColor(theme, true)}
|
||||
aria-label="Workspace Settings"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSettingsModalVisible(true);
|
||||
|
||||
114
app/src/components/settings/AccordionControl.test.tsx
Normal file
114
app/src/components/settings/AccordionControl.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider, Accordion } from '@mantine/core';
|
||||
import AccordionControl from './AccordionControl';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
// Test wrapper component to properly provide Accordion context
|
||||
const AccordionWrapper: React.FC<{
|
||||
children: React.ReactNode;
|
||||
defaultValue?: string[];
|
||||
}> = ({ children, defaultValue = ['test'] }) => (
|
||||
<Accordion defaultValue={defaultValue} multiple>
|
||||
<Accordion.Item value="test">{children}</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
describe('AccordionControl', () => {
|
||||
describe('Normal Operation', () => {
|
||||
it('renders children as Title with order 4', () => {
|
||||
render(
|
||||
<AccordionWrapper>
|
||||
<AccordionControl>Settings Title</AccordionControl>
|
||||
</AccordionWrapper>
|
||||
);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 4 });
|
||||
expect(title).toHaveTextContent('Settings Title');
|
||||
});
|
||||
|
||||
it('renders complex children correctly', () => {
|
||||
render(
|
||||
<AccordionWrapper>
|
||||
<AccordionControl>
|
||||
<span data-testid="complex-child">Complex</span> Content
|
||||
</AccordionControl>
|
||||
</AccordionWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('complex-child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Complex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('functions as accordion control', () => {
|
||||
render(
|
||||
<AccordionWrapper defaultValue={[]}>
|
||||
<AccordionControl>Toggle Section</AccordionControl>
|
||||
<Accordion.Panel>Hidden Content</Accordion.Panel>
|
||||
</AccordionWrapper>
|
||||
);
|
||||
|
||||
const control = screen.getByRole('button');
|
||||
fireEvent.click(control);
|
||||
|
||||
expect(screen.getByText('Hidden Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty children gracefully', () => {
|
||||
render(
|
||||
<AccordionWrapper>
|
||||
<AccordionControl>{''}</AccordionControl>
|
||||
</AccordionWrapper>
|
||||
);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 4 });
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes through children props correctly', () => {
|
||||
const mockClickHandler = vi.fn();
|
||||
|
||||
render(
|
||||
<AccordionWrapper>
|
||||
<AccordionControl>
|
||||
<button onClick={mockClickHandler} data-testid="inner-button">
|
||||
Click Me
|
||||
</button>
|
||||
</AccordionControl>
|
||||
</AccordionWrapper>
|
||||
);
|
||||
|
||||
const innerButton = screen.getByTestId('inner-button');
|
||||
fireEvent.click(innerButton);
|
||||
expect(mockClickHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('provides proper semantic structure', () => {
|
||||
render(
|
||||
<AccordionWrapper>
|
||||
<AccordionControl>Accessible Title</AccordionControl>
|
||||
</AccordionWrapper>
|
||||
);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 4 });
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
expect(title).toHaveTextContent('Accessible Title');
|
||||
expect(button).toContainElement(title);
|
||||
});
|
||||
});
|
||||
});
|
||||
246
app/src/components/settings/account/AccountSettings.test.tsx
Normal file
246
app/src/components/settings/account/AccountSettings.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AccountSettings from './AccountSettings';
|
||||
|
||||
// Mock the auth context
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: 'editor' as const,
|
||||
};
|
||||
const mockRefreshUser = vi.fn();
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUser,
|
||||
refreshUser: mockRefreshUser,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the profile settings hook
|
||||
const mockUpdateProfile = vi.fn();
|
||||
vi.mock('../../../hooks/useProfileSettings', () => ({
|
||||
useProfileSettings: () => ({
|
||||
loading: false,
|
||||
updateProfile: mockUpdateProfile,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the sub-components
|
||||
vi.mock('./ProfileSettings', () => ({
|
||||
default: ({
|
||||
settings,
|
||||
onInputChange,
|
||||
}: {
|
||||
settings: {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
};
|
||||
onInputChange: (field: string, value: string) => void;
|
||||
}) => (
|
||||
<div data-testid="profile-settings">
|
||||
<input
|
||||
data-testid="display-name-input"
|
||||
value={settings.displayName || ''}
|
||||
onChange={(e) => onInputChange('displayName', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
data-testid="email-input"
|
||||
value={settings.email || ''}
|
||||
onChange={(e) => onInputChange('email', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./SecuritySettings', () => ({
|
||||
default: ({
|
||||
settings,
|
||||
onInputChange,
|
||||
}: {
|
||||
settings: {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
onInputChange: (field: string, value: string) => void;
|
||||
}) => (
|
||||
<div data-testid="security-settings">
|
||||
<input
|
||||
data-testid="current-password-input"
|
||||
value={settings.currentPassword || ''}
|
||||
onChange={(e) => onInputChange('currentPassword', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
data-testid="new-password-input"
|
||||
value={settings.newPassword || ''}
|
||||
onChange={(e) => onInputChange('newPassword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./DangerZoneSettings', () => ({
|
||||
default: () => <div data-testid="danger-zone-settings">Danger Zone</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../modals/account/EmailPasswordModal', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onConfirm,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onConfirm: (password: string) => void;
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="email-password-modal">
|
||||
<button
|
||||
onClick={() => void onConfirm('test-password')}
|
||||
data-testid="confirm-email"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('AccountSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUpdateProfile.mockResolvedValue(mockUser);
|
||||
mockRefreshUser.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders modal with all sections', () => {
|
||||
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Account Settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('profile-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('security-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unsaved changes badge when settings are modified', () => {
|
||||
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } });
|
||||
|
||||
expect(screen.getByText('Unsaved Changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables save button when there are changes', () => {
|
||||
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } });
|
||||
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('saves profile changes successfully', async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
render(<AccountSettings opened={true} onClose={mockOnClose} />);
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ displayName: 'Updated Name' })
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens email confirmation modal for email changes', () => {
|
||||
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(screen.getByTestId('email-password-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('completes email change with password confirmation', async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
render(<AccountSettings opened={true} onClose={mockOnClose} />);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-email');
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'new@example.com',
|
||||
currentPassword: 'test-password',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal when cancel is clicked', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
render(<AccountSettings opened={true} onClose={mockOnClose} />);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<AccountSettings opened={false} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText('Account Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -153,7 +153,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailConfirm = async (password: string): Promise<void> => {
|
||||
const handleEmailConfirm = async (password: string): Promise<boolean> => {
|
||||
const updates: UserProfileSettings = {
|
||||
...state.localSettings,
|
||||
currentPassword: password,
|
||||
@@ -181,6 +181,11 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
|
||||
dispatch({ type: SettingsActionType.MARK_SAVED });
|
||||
setEmailModalOpened(false);
|
||||
onClose();
|
||||
return true;
|
||||
} else {
|
||||
// TODO: Handle errors appropriately
|
||||
// notifications.show({...
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -238,7 +243,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
loading={loading}
|
||||
disabled={!state.hasUnsavedChanges}
|
||||
>
|
||||
|
||||
140
app/src/components/settings/account/DangerZoneSettings.test.tsx
Normal file
140
app/src/components/settings/account/DangerZoneSettings.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import DangerZoneSettings from './DangerZoneSettings';
|
||||
|
||||
// Mock the auth context
|
||||
const mockLogout = vi.fn();
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ logout: mockLogout }),
|
||||
}));
|
||||
|
||||
// Mock the profile settings hook
|
||||
const mockDeleteAccount = vi.fn();
|
||||
vi.mock('../../../hooks/useProfileSettings', () => ({
|
||||
useProfileSettings: () => ({ deleteAccount: mockDeleteAccount }),
|
||||
}));
|
||||
|
||||
// Mock the DeleteAccountModal
|
||||
vi.mock('../../modals/account/DeleteAccountModal', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (password: string) => void;
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="delete-account-modal">
|
||||
<button onClick={onClose} data-testid="modal-close">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm('test-password')}
|
||||
data-testid="modal-confirm"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('DangerZoneSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDeleteAccount.mockResolvedValue(true);
|
||||
mockLogout.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders delete button with warning text', () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete Account' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Once you delete your account, there is no going back. Please be certain.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens and closes delete modal', () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete Account' });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(
|
||||
screen.queryByTestId('delete-account-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('completes account deletion and logout flow', async () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Account' }));
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAccount).toHaveBeenCalledWith('test-password');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('delete-account-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps modal open when deletion fails', async () => {
|
||||
mockDeleteAccount.mockResolvedValue(false);
|
||||
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Account' }));
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument();
|
||||
expect(mockLogout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows cancellation of deletion process', () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Account' }));
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('delete-account-modal')
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockDeleteAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
113
app/src/components/settings/account/ProfileSettings.test.tsx
Normal file
113
app/src/components/settings/account/ProfileSettings.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import ProfileSettings from './ProfileSettings';
|
||||
import type { UserProfileSettings } from '@/types/models';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('ProfileSettings', () => {
|
||||
const mockOnInputChange = vi.fn();
|
||||
|
||||
const defaultSettings: UserProfileSettings = {
|
||||
displayName: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
};
|
||||
|
||||
const emptySettings: UserProfileSettings = {
|
||||
displayName: '',
|
||||
email: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders form fields with current values', () => {
|
||||
render(
|
||||
<ProfileSettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
|
||||
expect(displayNameInput).toHaveValue('John Doe');
|
||||
expect(emailInput).toHaveValue('john.doe@example.com');
|
||||
});
|
||||
|
||||
it('renders with empty settings', () => {
|
||||
render(
|
||||
<ProfileSettings
|
||||
settings={emptySettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
|
||||
expect(displayNameInput).toHaveValue('');
|
||||
expect(emailInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('calls onInputChange when display name is modified', () => {
|
||||
render(
|
||||
<ProfileSettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
fireEvent.change(displayNameInput, { target: { value: 'Jane Smith' } });
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('displayName', 'Jane Smith');
|
||||
});
|
||||
|
||||
it('calls onInputChange when email is modified', () => {
|
||||
render(
|
||||
<ProfileSettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
fireEvent.change(emailInput, { target: { value: 'jane@example.com' } });
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('email', 'jane@example.com');
|
||||
});
|
||||
|
||||
it('has correct input types and accessibility', () => {
|
||||
render(
|
||||
<ProfileSettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const displayNameInput = screen.getByTestId('display-name-input');
|
||||
const emailInput = screen.getByTestId('email-input');
|
||||
|
||||
expect(displayNameInput).toHaveAttribute('type', 'text');
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(displayNameInput).toHaveAccessibleName();
|
||||
expect(emailInput).toHaveAccessibleName();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ interface ProfileSettingsProps {
|
||||
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
|
||||
}
|
||||
|
||||
const ProfileSettingsComponent: React.FC<ProfileSettingsProps> = ({
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
settings,
|
||||
onInputChange,
|
||||
}) => (
|
||||
@@ -15,18 +15,22 @@ const ProfileSettingsComponent: React.FC<ProfileSettingsProps> = ({
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
type="text"
|
||||
value={settings.displayName || ''}
|
||||
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
|
||||
placeholder="Enter display name"
|
||||
data-testid="display-name-input"
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings.email || ''}
|
||||
onChange={(e) => onInputChange('email', e.currentTarget.value)}
|
||||
placeholder="Enter email"
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default ProfileSettingsComponent;
|
||||
export default ProfileSettings;
|
||||
|
||||
137
app/src/components/settings/account/SecuritySettings.test.tsx
Normal file
137
app/src/components/settings/account/SecuritySettings.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import SecuritySettings from './SecuritySettings';
|
||||
import type { UserProfileSettings } from '@/types/models';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('SecuritySettings', () => {
|
||||
const mockOnInputChange = vi.fn();
|
||||
|
||||
const defaultSettings: UserProfileSettings = {
|
||||
displayName: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all password fields', () => {
|
||||
render(
|
||||
<SecuritySettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Current Password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('New Password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Confirm New Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onInputChange for current password', () => {
|
||||
render(
|
||||
<SecuritySettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const currentPasswordInput = screen.getByLabelText('Current Password');
|
||||
fireEvent.change(currentPasswordInput, { target: { value: 'oldpass123' } });
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith(
|
||||
'currentPassword',
|
||||
'oldpass123'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onInputChange for new password', () => {
|
||||
render(
|
||||
<SecuritySettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const newPasswordInput = screen.getByLabelText('New Password');
|
||||
fireEvent.change(newPasswordInput, { target: { value: 'newpass123' } });
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('newPassword', 'newpass123');
|
||||
});
|
||||
|
||||
it('shows error when passwords do not match', () => {
|
||||
render(
|
||||
<SecuritySettings
|
||||
settings={{ ...defaultSettings, newPassword: 'password123' }}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
|
||||
fireEvent.change(confirmPasswordInput, {
|
||||
target: { value: 'different123' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears error when passwords match', () => {
|
||||
render(
|
||||
<SecuritySettings
|
||||
settings={{ ...defaultSettings, newPassword: 'password123' }}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
|
||||
|
||||
// First make them not match
|
||||
fireEvent.change(confirmPasswordInput, {
|
||||
target: { value: 'different123' },
|
||||
});
|
||||
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
|
||||
|
||||
// Then make them match
|
||||
fireEvent.change(confirmPasswordInput, {
|
||||
target: { value: 'password123' },
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('Passwords do not match')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct input types and help text', () => {
|
||||
render(
|
||||
<SecuritySettings
|
||||
settings={defaultSettings}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const currentPasswordInput = screen.getByLabelText('Current Password');
|
||||
const newPasswordInput = screen.getByLabelText('New Password');
|
||||
const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
|
||||
|
||||
expect(currentPasswordInput).toHaveAttribute('type', 'password');
|
||||
expect(newPasswordInput).toHaveAttribute('type', 'password');
|
||||
expect(confirmPasswordInput).toHaveAttribute('type', 'password');
|
||||
|
||||
expect(
|
||||
screen.getByText(/Password must be at least 8 characters long/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({
|
||||
<Stack gap="md">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={settings.currentPassword || ''}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('currentPassword', e.currentTarget.value)
|
||||
@@ -49,6 +50,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={settings.newPassword || ''}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('newPassword', e.currentTarget.value)
|
||||
@@ -57,6 +59,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
|
||||
|
||||
110
app/src/components/settings/admin/AdminDashboard.test.tsx
Normal file
110
app/src/components/settings/admin/AdminDashboard.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AdminDashboard from './AdminDashboard';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Mock the auth context
|
||||
const mockCurrentUser: User = {
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: UserRole.Admin,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
user: mockCurrentUser,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the sub-components
|
||||
vi.mock('./AdminUsersTab', () => ({
|
||||
default: ({ currentUser }: { currentUser: User }) => (
|
||||
<div data-testid="admin-users-tab">Users Tab - {currentUser.email}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./AdminWorkspacesTab', () => ({
|
||||
default: () => <div data-testid="admin-workspaces-tab">Workspaces Tab</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./AdminStatsTab', () => ({
|
||||
default: () => <div data-testid="admin-stats-tab">Stats Tab</div>,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('AdminDashboard', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders modal with all tabs', () => {
|
||||
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /users/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: /workspaces/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: /statistics/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows users tab by default', () => {
|
||||
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId('admin-users-tab')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Users Tab - admin@example.com')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to workspaces tab when clicked', () => {
|
||||
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /workspaces/i }));
|
||||
|
||||
expect(screen.getByTestId('admin-workspaces-tab')).toBeInTheDocument();
|
||||
expect(screen.getByText('Workspaces Tab')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to statistics tab when clicked', () => {
|
||||
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /statistics/i }));
|
||||
|
||||
expect(screen.getByTestId('admin-stats-tab')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stats Tab')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes current user to users tab', () => {
|
||||
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||
|
||||
// Should pass current user to AdminUsersTab
|
||||
expect(
|
||||
screen.getByText('Users Tab - admin@example.com')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<AdminDashboard opened={false} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.queryByText('Admin Dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
126
app/src/components/settings/admin/AdminStatsTab.test.tsx
Normal file
126
app/src/components/settings/admin/AdminStatsTab.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AdminStatsTab from './AdminStatsTab';
|
||||
import type { SystemStats } from '@/types/models';
|
||||
|
||||
// Mock the admin data hook
|
||||
vi.mock('../../../hooks/useAdminData', () => ({
|
||||
useAdminData: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the formatBytes utility
|
||||
vi.mock('../../../utils/formatBytes', () => ({
|
||||
formatBytes: (bytes: number) => `${bytes} bytes`,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('AdminStatsTab', () => {
|
||||
const mockStats: SystemStats = {
|
||||
totalUsers: 150,
|
||||
activeUsers: 120,
|
||||
totalWorkspaces: 85,
|
||||
totalFiles: 2500,
|
||||
totalSize: 1073741824, // 1GB in bytes
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: mockStats,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders statistics table with all metrics', () => {
|
||||
render(<AdminStatsTab />);
|
||||
|
||||
expect(screen.getByText('System Statistics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Workspaces')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Files')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Storage Size')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct statistics values', () => {
|
||||
render(<AdminStatsTab />);
|
||||
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
expect(screen.getByText('120')).toBeInTheDocument();
|
||||
expect(screen.getByText('85')).toBeInTheDocument();
|
||||
expect(screen.getByText('2500')).toBeInTheDocument();
|
||||
expect(screen.getByText('1073741824 bytes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state', async () => {
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: {} as SystemStats,
|
||||
loading: true,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AdminStatsTab />);
|
||||
|
||||
// Mantine LoadingOverlay should be visible
|
||||
expect(
|
||||
document.querySelector('.mantine-LoadingOverlay-root')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state', async () => {
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: {} as SystemStats,
|
||||
loading: false,
|
||||
error: 'Failed to load statistics',
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AdminStatsTab />);
|
||||
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to load statistics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles zero values correctly', async () => {
|
||||
const zeroStats: SystemStats = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalWorkspaces: 0,
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
};
|
||||
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: zeroStats,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AdminStatsTab />);
|
||||
|
||||
// Should display zeros without issues
|
||||
const zeros = screen.getAllByText('0');
|
||||
expect(zeros.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('0 bytes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
288
app/src/components/settings/admin/AdminUsersTab.test.tsx
Normal file
288
app/src/components/settings/admin/AdminUsersTab.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AdminUsersTab from './AdminUsersTab';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Mock the user admin hook
|
||||
const mockCreate = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock('../../../hooks/useUserAdmin', () => ({
|
||||
useUserAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock notifications
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the user modals
|
||||
vi.mock('../../modals/user/CreateUserModal', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onCreateUser,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onCreateUser: (userData: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
}) => Promise<boolean>;
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="create-user-modal">
|
||||
<button
|
||||
onClick={() =>
|
||||
void onCreateUser({
|
||||
email: 'new@example.com',
|
||||
password: 'pass',
|
||||
displayName: 'New User',
|
||||
role: UserRole.Editor,
|
||||
})
|
||||
}
|
||||
data-testid="create-user-button"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('../../modals/user/EditUserModal', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onEditUser,
|
||||
user,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onEditUser: (
|
||||
userId: number,
|
||||
userData: { email: string }
|
||||
) => Promise<boolean>;
|
||||
user: User | null;
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="edit-user-modal">
|
||||
<span data-testid="edit-user-email">{user?.email}</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
void onEditUser(user?.id || 0, { email: 'updated@example.com' })
|
||||
}
|
||||
data-testid="edit-user-button"
|
||||
>
|
||||
Update User
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('../../modals/user/DeleteUserModal', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onConfirm,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="delete-user-modal">
|
||||
<button
|
||||
onClick={() => void onConfirm()}
|
||||
data-testid="delete-user-button"
|
||||
>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('AdminUsersTab', () => {
|
||||
const mockCurrentUser: User = {
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: UserRole.Admin,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
const mockUsers: User[] = [
|
||||
mockCurrentUser,
|
||||
{
|
||||
id: 2,
|
||||
email: 'editor@example.com',
|
||||
displayName: 'Editor User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-15T00:00:00Z',
|
||||
lastWorkspaceId: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
email: 'viewer@example.com',
|
||||
displayName: 'Viewer User',
|
||||
role: UserRole.Viewer,
|
||||
createdAt: '2024-02-01T00:00:00Z',
|
||||
lastWorkspaceId: 3,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate.mockResolvedValue(true);
|
||||
mockUpdate.mockResolvedValue(true);
|
||||
mockDelete.mockResolvedValue(true);
|
||||
|
||||
const { useUserAdmin } = await import('../../../hooks/useUserAdmin');
|
||||
vi.mocked(useUserAdmin).mockReturnValue({
|
||||
users: mockUsers,
|
||||
loading: false,
|
||||
error: null,
|
||||
create: mockCreate,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders users table with all users', () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('editor@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('viewer@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin User')).toBeInTheDocument();
|
||||
expect(screen.getByText('Editor User')).toBeInTheDocument();
|
||||
expect(screen.getByText('Viewer User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows create user button', () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /create user/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens create user modal when create button is clicked', () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
||||
|
||||
expect(screen.getByTestId('create-user-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates new user successfully', async () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
||||
fireEvent.click(screen.getByTestId('create-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
email: 'new@example.com',
|
||||
password: 'pass',
|
||||
displayName: 'New User',
|
||||
role: UserRole.Editor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('opens edit modal when edit button is clicked', () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
const editButtons = screen.getAllByLabelText(/edit/i);
|
||||
expect(editButtons[0]).toBeDefined();
|
||||
fireEvent.click(editButtons[0]!); // Click first edit button
|
||||
|
||||
expect(screen.getByTestId('edit-user-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-user-email')).toHaveTextContent(
|
||||
'admin@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('updates user successfully', async () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
const editButtons = screen.getAllByLabelText(/edit/i);
|
||||
expect(editButtons[0]).toBeDefined();
|
||||
fireEvent.click(editButtons[0]!);
|
||||
fireEvent.click(screen.getByTestId('edit-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(1, {
|
||||
email: 'updated@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents deleting current user', () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||
const currentUserDeleteButton = deleteButtons[0]; // First user is current user
|
||||
|
||||
expect(currentUserDeleteButton).toBeDefined();
|
||||
expect(currentUserDeleteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('allows deleting other users', () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||
expect(deleteButtons[1]).toBeDefined();
|
||||
fireEvent.click(deleteButtons[1]!); // Click delete for second user
|
||||
|
||||
expect(screen.getByTestId('delete-user-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes user successfully', async () => {
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||
expect(deleteButtons[1]).toBeDefined();
|
||||
fireEvent.click(deleteButtons[1]!);
|
||||
fireEvent.click(screen.getByTestId('delete-user-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith(2); // Second user's ID
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error state when loading fails', async () => {
|
||||
const { useUserAdmin } = await import('../../../hooks/useUserAdmin');
|
||||
vi.mocked(useUserAdmin).mockReturnValue({
|
||||
users: [],
|
||||
loading: false,
|
||||
error: 'Failed to load users',
|
||||
create: mockCreate,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
|
||||
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to load users')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,7 @@ const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label="Edit user"
|
||||
color="blue"
|
||||
onClick={() => setEditModalData(user)}
|
||||
>
|
||||
@@ -93,6 +94,7 @@ const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label="Delete user"
|
||||
color="red"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
disabled={user.id === currentUser.id}
|
||||
@@ -125,6 +127,7 @@ const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
aria-label="Create user"
|
||||
onClick={() => setCreateModalOpened(true)}
|
||||
>
|
||||
Create User
|
||||
|
||||
140
app/src/components/settings/admin/AdminWorkspacesTab.test.tsx
Normal file
140
app/src/components/settings/admin/AdminWorkspacesTab.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AdminWorkspacesTab from './AdminWorkspacesTab';
|
||||
import type { WorkspaceStats } from '@/types/models';
|
||||
|
||||
// Mock the admin data hook
|
||||
vi.mock('../../../hooks/useAdminData', () => ({
|
||||
useAdminData: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the formatBytes utility
|
||||
vi.mock('../../../utils/formatBytes', () => ({
|
||||
formatBytes: (bytes: number) => `${bytes} bytes`,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('AdminWorkspacesTab', () => {
|
||||
const mockWorkspaces: WorkspaceStats[] = [
|
||||
{
|
||||
workspaceID: 1,
|
||||
userID: 1,
|
||||
userEmail: 'user1@example.com',
|
||||
workspaceName: 'Project Alpha',
|
||||
workspaceCreatedAt: '2024-01-15T10:30:00Z',
|
||||
fileCountStats: {
|
||||
totalFiles: 25,
|
||||
totalSize: 1048576, // 1MB
|
||||
},
|
||||
},
|
||||
{
|
||||
workspaceID: 2,
|
||||
userID: 2,
|
||||
userEmail: 'user2@example.com',
|
||||
workspaceName: 'Project Beta',
|
||||
workspaceCreatedAt: '2024-02-20T14:45:00Z',
|
||||
fileCountStats: {
|
||||
totalFiles: 42,
|
||||
totalSize: 2097152, // 2MB
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: mockWorkspaces,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders workspace table with all columns', () => {
|
||||
render(<AdminWorkspacesTab />);
|
||||
|
||||
expect(screen.getByText('Workspace Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Files')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Size')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays workspace data correctly', () => {
|
||||
render(<AdminWorkspacesTab />);
|
||||
|
||||
expect(screen.getByText('user1@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project Alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/15/2024')).toBeInTheDocument();
|
||||
expect(screen.getByText('25')).toBeInTheDocument();
|
||||
expect(screen.getByText('1048576 bytes')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('user2@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project Beta')).toBeInTheDocument();
|
||||
expect(screen.getByText('2/20/2024')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText('2097152 bytes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state', async () => {
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AdminWorkspacesTab />);
|
||||
|
||||
expect(
|
||||
document.querySelector('.mantine-LoadingOverlay-root')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state', async () => {
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: [],
|
||||
loading: false,
|
||||
error: 'Failed to load workspaces',
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AdminWorkspacesTab />);
|
||||
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to load workspaces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty workspace list', async () => {
|
||||
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||
vi.mocked(useAdminData).mockReturnValue({
|
||||
data: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AdminWorkspacesTab />);
|
||||
|
||||
expect(screen.getByText('Workspace Management')).toBeInTheDocument();
|
||||
// Table headers should still be present
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AppearanceSettings from './AppearanceSettings';
|
||||
import { Theme } from '@/types/models';
|
||||
|
||||
const mockUpdateColorScheme = vi.fn();
|
||||
|
||||
vi.mock('../../../contexts/ThemeContext', () => ({
|
||||
useTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('AppearanceSettings', () => {
|
||||
const mockOnThemeChange = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { useTheme } = await import('../../../contexts/ThemeContext');
|
||||
vi.mocked(useTheme).mockReturnValue({
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: mockUpdateColorScheme,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders dark mode toggle with correct state', () => {
|
||||
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />);
|
||||
|
||||
expect(screen.getByText('Dark Mode')).toBeInTheDocument();
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows toggle as checked when in dark mode', async () => {
|
||||
const { useTheme } = await import('../../../contexts/ThemeContext');
|
||||
vi.mocked(useTheme).mockReturnValue({
|
||||
colorScheme: 'dark',
|
||||
updateColorScheme: mockUpdateColorScheme,
|
||||
});
|
||||
|
||||
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
|
||||
it('toggles theme from light to dark', () => {
|
||||
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Dark);
|
||||
expect(mockOnThemeChange).toHaveBeenCalledWith(Theme.Dark);
|
||||
});
|
||||
|
||||
it('toggles theme from dark to light', async () => {
|
||||
const { useTheme } = await import('../../../contexts/ThemeContext');
|
||||
vi.mocked(useTheme).mockReturnValue({
|
||||
colorScheme: 'dark',
|
||||
updateColorScheme: mockUpdateColorScheme,
|
||||
});
|
||||
|
||||
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
|
||||
expect(mockOnThemeChange).toHaveBeenCalledWith(Theme.Light);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import DangerZoneSettings from './DangerZoneSettings';
|
||||
import { Theme } from '@/types/models';
|
||||
|
||||
const mockDeleteCurrentWorkspace = vi.fn();
|
||||
|
||||
vi.mock('../../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetSettingsModalVisible = vi.fn();
|
||||
vi.mock('../../../contexts/ModalContext', () => ({
|
||||
useModalContext: () => ({
|
||||
setSettingsModalVisible: mockSetSettingsModalVisible,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../modals/workspace/DeleteWorkspaceModal', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
workspaceName,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
workspaceName: string | undefined;
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="delete-workspace-modal">
|
||||
<span data-testid="workspace-name">{workspaceName}</span>
|
||||
<button onClick={onClose} data-testid="modal-close">
|
||||
Close
|
||||
</button>
|
||||
<button onClick={() => void onConfirm()} data-testid="modal-confirm">
|
||||
Confirm Delete
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('DangerZoneSettings (Workspace)', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDeleteCurrentWorkspace.mockResolvedValue(undefined);
|
||||
|
||||
const { useWorkspace } = await import('../../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Test Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
workspaces: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Workspace 1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
name: 'Workspace 2',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: mockDeleteCurrentWorkspace,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders delete button when multiple workspaces exist', () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
const deleteButton = screen.getByRole('button', {
|
||||
name: 'Delete Workspace',
|
||||
});
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
expect(deleteButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables delete button when only one workspace exists', async () => {
|
||||
const { useWorkspace } = await import('../../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Last Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
workspaces: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Last Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
],
|
||||
updateSettings: vi.fn(),
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: mockDeleteCurrentWorkspace,
|
||||
});
|
||||
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
const deleteButton = screen.getByRole('button', {
|
||||
name: 'Delete Workspace',
|
||||
});
|
||||
expect(deleteButton).toBeDisabled();
|
||||
expect(deleteButton).toHaveAttribute(
|
||||
'title',
|
||||
'Cannot delete the last workspace'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens and closes delete modal', () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
const deleteButton = screen.getByRole('button', {
|
||||
name: 'Delete Workspace',
|
||||
});
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(screen.getByTestId('delete-workspace-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workspace-name')).toHaveTextContent(
|
||||
'Test Workspace'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(
|
||||
screen.queryByTestId('delete-workspace-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('completes workspace deletion flow', async () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Workspace' }));
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteCurrentWorkspace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('delete-workspace-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows cancellation of deletion process', () => {
|
||||
render(<DangerZoneSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Workspace' }));
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('delete-workspace-modal')
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockDeleteCurrentWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockSetSettingsModalVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import EditorSettings from './EditorSettings';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('EditorSettings', () => {
|
||||
const mockOnAutoSaveChange = vi.fn();
|
||||
const mockOnShowHiddenFilesChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders both toggle switches with labels', () => {
|
||||
render(
|
||||
<EditorSettings
|
||||
autoSave={false}
|
||||
showHiddenFiles={false}
|
||||
onAutoSaveChange={mockOnAutoSaveChange}
|
||||
onShowHiddenFilesChange={mockOnShowHiddenFilesChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Auto Save')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show Hidden Files')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct toggle states', () => {
|
||||
render(
|
||||
<EditorSettings
|
||||
autoSave={true}
|
||||
showHiddenFiles={false}
|
||||
onAutoSaveChange={mockOnAutoSaveChange}
|
||||
onShowHiddenFilesChange={mockOnShowHiddenFilesChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggles = screen.getAllByRole('switch');
|
||||
const autoSaveToggle = toggles[0];
|
||||
const hiddenFilesToggle = toggles[1];
|
||||
|
||||
expect(autoSaveToggle).toBeChecked();
|
||||
expect(hiddenFilesToggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls onShowHiddenFilesChange when toggle is clicked', () => {
|
||||
render(
|
||||
<EditorSettings
|
||||
autoSave={false}
|
||||
showHiddenFiles={false}
|
||||
onAutoSaveChange={mockOnAutoSaveChange}
|
||||
onShowHiddenFilesChange={mockOnShowHiddenFilesChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// Get the show hidden files toggle by finding the one that's not disabled
|
||||
const toggles = screen.getAllByRole('switch');
|
||||
const hiddenFilesToggle = toggles.find(
|
||||
(toggle) => !toggle.hasAttribute('disabled')
|
||||
);
|
||||
|
||||
expect(hiddenFilesToggle).toBeDefined();
|
||||
fireEvent.click(hiddenFilesToggle!);
|
||||
|
||||
expect(mockOnShowHiddenFilesChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('GeneralSettings', () => {
|
||||
const mockOnInputChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders workspace name input with current value', () => {
|
||||
render(
|
||||
<GeneralSettings name="My Workspace" onInputChange={mockOnInputChange} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByDisplayValue('My Workspace');
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
expect(screen.getByText('Workspace Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with empty name', () => {
|
||||
render(<GeneralSettings name="" onInputChange={mockOnInputChange} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter workspace name');
|
||||
expect(nameInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('calls onInputChange when name is modified', () => {
|
||||
render(
|
||||
<GeneralSettings name="Old Name" onInputChange={mockOnInputChange} />
|
||||
);
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Old Name');
|
||||
fireEvent.change(nameInput, { target: { value: 'New Workspace Name' } });
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith(
|
||||
'name',
|
||||
'New Workspace Name'
|
||||
);
|
||||
});
|
||||
|
||||
it('has required attribute on input', () => {
|
||||
render(<GeneralSettings name="Test" onInputChange={mockOnInputChange} />);
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Test');
|
||||
expect(nameInput).toHaveAttribute('required');
|
||||
});
|
||||
});
|
||||
134
app/src/components/settings/workspace/GitSettings.test.tsx
Normal file
134
app/src/components/settings/workspace/GitSettings.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import GitSettings from './GitSettings';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('GitSettings', () => {
|
||||
const mockOnInputChange = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
onInputChange: mockOnInputChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all git settings fields', () => {
|
||||
render(<GitSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Enable Git Repository')).toBeInTheDocument();
|
||||
expect(screen.getByText('Git URL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByText('Access Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Commit on Save')).toBeInTheDocument();
|
||||
expect(screen.getByText('Commit Message Template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Commit Author')).toBeInTheDocument();
|
||||
expect(screen.getByText('Commit Author Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables all inputs when git is not enabled', () => {
|
||||
render(<GitSettings {...defaultProps} gitEnabled={false} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter Git URL')).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText('Enter Git username')).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText('Enter Git token')).toBeDisabled();
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const commitOnSaveSwitch = switches[1]; // Second switch is commit on save
|
||||
expect(commitOnSaveSwitch).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables all inputs when git is enabled', () => {
|
||||
render(<GitSettings {...defaultProps} gitEnabled={true} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter Git URL')).not.toBeDisabled();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter Git username')
|
||||
).not.toBeDisabled();
|
||||
expect(screen.getByPlaceholderText('Enter Git token')).not.toBeDisabled();
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const commitOnSaveSwitch = switches[1];
|
||||
expect(commitOnSaveSwitch).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onInputChange when git enabled toggle is changed', () => {
|
||||
render(<GitSettings {...defaultProps} />);
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const gitEnabledSwitch = switches[0];
|
||||
expect(gitEnabledSwitch).toBeDefined();
|
||||
|
||||
fireEvent.click(gitEnabledSwitch!);
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('gitEnabled', true);
|
||||
});
|
||||
|
||||
it('calls onInputChange when git URL is changed', () => {
|
||||
render(<GitSettings {...defaultProps} gitEnabled={true} />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('Enter Git URL');
|
||||
fireEvent.change(urlInput, {
|
||||
target: { value: 'https://github.com/user/repo.git' },
|
||||
});
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith(
|
||||
'gitUrl',
|
||||
'https://github.com/user/repo.git'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onInputChange when commit template is changed', () => {
|
||||
render(<GitSettings {...defaultProps} gitEnabled={true} />);
|
||||
|
||||
const templateInput = screen.getByPlaceholderText(
|
||||
'Enter commit message template'
|
||||
);
|
||||
fireEvent.change(templateInput, {
|
||||
target: { value: '${action}: ${filename}' },
|
||||
});
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith(
|
||||
'gitCommitMsgTemplate',
|
||||
'${action}: ${filename}'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows current values in form fields', () => {
|
||||
const propsWithValues = {
|
||||
...defaultProps,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/test/repo.git',
|
||||
gitUser: 'testuser',
|
||||
gitCommitMsgTemplate: 'Update ${filename}',
|
||||
};
|
||||
|
||||
render(<GitSettings {...propsWithValues} />);
|
||||
|
||||
expect(
|
||||
screen.getByDisplayValue('https://github.com/test/repo.git')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('testuser')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Update ${filename}')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
195
app/src/components/settings/workspace/WorkspaceSettings.test.tsx
Normal file
195
app/src/components/settings/workspace/WorkspaceSettings.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
render as rtlRender,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import WorkspaceSettings from './WorkspaceSettings';
|
||||
import { Theme } from '@/types/models';
|
||||
|
||||
const mockUpdateSettings = vi.fn();
|
||||
vi.mock('../../../hooks/useWorkspace', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetSettingsModalVisible = vi.fn();
|
||||
vi.mock('../../../contexts/ModalContext', () => ({
|
||||
useModalContext: () => ({
|
||||
settingsModalVisible: true,
|
||||
setSettingsModalVisible: mockSetSettingsModalVisible,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./GeneralSettings', () => ({
|
||||
default: ({
|
||||
name,
|
||||
onInputChange,
|
||||
}: {
|
||||
name: string;
|
||||
onInputChange: (key: string, value: string) => void;
|
||||
}) => (
|
||||
<div data-testid="general-settings">
|
||||
<input
|
||||
data-testid="workspace-name-input"
|
||||
value={name}
|
||||
onChange={(e) => onInputChange('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./AppearanceSettings', () => ({
|
||||
default: ({ onThemeChange }: { onThemeChange: (theme: string) => void }) => (
|
||||
<div data-testid="appearance-settings">
|
||||
<button onClick={() => onThemeChange('dark')} data-testid="theme-toggle">
|
||||
Toggle Theme
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./EditorSettings', () => ({
|
||||
default: () => <div data-testid="editor-settings">Editor Settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./GitSettings', () => ({
|
||||
default: () => <div data-testid="git-settings">Git Settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./DangerZoneSettings', () => ({
|
||||
default: () => <div data-testid="danger-zone-settings">Danger Zone</div>,
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
// Custom render function
|
||||
const render = (ui: React.ReactElement) => {
|
||||
return rtlRender(ui, { wrapper: TestWrapper });
|
||||
};
|
||||
|
||||
describe('WorkspaceSettings', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockUpdateSettings.mockResolvedValue(undefined);
|
||||
|
||||
const { useWorkspace } = await import('../../../hooks/useWorkspace');
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
currentWorkspace: {
|
||||
name: 'Test Workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
workspaces: [],
|
||||
updateSettings: mockUpdateSettings,
|
||||
loading: false,
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: vi.fn(),
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders modal with all setting sections', () => {
|
||||
render(<WorkspaceSettings />);
|
||||
|
||||
expect(screen.getByText('Workspace Settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('general-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('appearance-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('editor-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('git-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unsaved changes badge when settings are modified', () => {
|
||||
render(<WorkspaceSettings />);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
fireEvent.change(nameInput, { target: { value: 'Updated Workspace' } });
|
||||
|
||||
expect(screen.getByText('Unsaved Changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves settings successfully', async () => {
|
||||
render(<WorkspaceSettings />);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
fireEvent.change(nameInput, { target: { value: 'Updated Workspace' } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||
expect(saveButton).toBeDefined();
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Updated Workspace' })
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles theme changes', () => {
|
||||
render(<WorkspaceSettings />);
|
||||
|
||||
const themeToggle = screen.getByTestId('theme-toggle');
|
||||
fireEvent.click(themeToggle);
|
||||
|
||||
expect(screen.getByText('Unsaved Changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes modal when cancel is clicked', () => {
|
||||
render(<WorkspaceSettings />);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('prevents saving with empty workspace name', async () => {
|
||||
const { notifications } = await import('@mantine/notifications');
|
||||
|
||||
render(<WorkspaceSettings />);
|
||||
|
||||
const nameInput = screen.getByTestId('workspace-name-input');
|
||||
fireEvent.change(nameInput, { target: { value: ' ' } }); // Empty/whitespace
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Workspace name cannot be empty',
|
||||
color: 'red',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -235,7 +235,7 @@ const WorkspaceSettings: React.FC = () => {
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit}>Save Changes</Button>
|
||||
<Button onClick={() => void handleSubmit()}>Save Changes</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
770
app/src/contexts/AuthContext.test.tsx
Normal file
770
app/src/contexts/AuthContext.test.tsx
Normal file
@@ -0,0 +1,770 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Set up mocks before imports are used
|
||||
vi.mock('@/api/auth', () => {
|
||||
return {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@mantine/notifications', () => {
|
||||
return {
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import the mocks after they've been defined
|
||||
import {
|
||||
login as mockLogin,
|
||||
logout as mockLogout,
|
||||
refreshToken as mockRefreshToken,
|
||||
getCurrentUser as mockGetCurrentUser,
|
||||
} from '@/api/auth';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
// Get reference to the mocked notifications.show function
|
||||
const mockNotificationsShow = notifications.show as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
// Mock user data
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const createWrapper = () => {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
Wrapper.displayName = 'AuthProviderTestWrapper';
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe('AuthContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('AuthProvider initialization', () => {
|
||||
it('initializes with null user and loading state', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.initialized).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('provides all expected functions', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
expect(typeof result.current.login).toBe('function');
|
||||
expect(typeof result.current.logout).toBe('function');
|
||||
expect(typeof result.current.refreshToken).toBe('function');
|
||||
expect(typeof result.current.refreshUser).toBe('function');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads current user on mount when authenticated', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockGetCurrentUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles initialization error gracefully', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to initialize auth:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAuth hook', () => {
|
||||
it('throws error when used outside AuthProvider', () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useAuth());
|
||||
}).toThrow('useAuth must be used within an AuthProvider');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns auth context when used within provider', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(typeof result.current).toBe('object');
|
||||
});
|
||||
|
||||
it('maintains function stability across re-renders', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result, rerender } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
const initialFunctions = {
|
||||
login: result.current.login,
|
||||
logout: result.current.logout,
|
||||
refreshToken: result.current.refreshToken,
|
||||
refreshUser: result.current.refreshUser,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.login).toBe(initialFunctions.login);
|
||||
expect(result.current.logout).toBe(initialFunctions.logout);
|
||||
expect(result.current.refreshToken).toBe(initialFunctions.refreshToken);
|
||||
expect(result.current.refreshUser).toBe(initialFunctions.refreshUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login functionality', () => {
|
||||
beforeEach(() => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
});
|
||||
|
||||
it('logs in user successfully', async () => {
|
||||
(mockLogin as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
let loginResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login(
|
||||
'test@example.com',
|
||||
'password123'
|
||||
);
|
||||
});
|
||||
|
||||
expect(loginResult).toBe(true);
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Logged in successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login failure with error message', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
(mockLogin as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Invalid credentials')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
let loginResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
loginResult = await result.current.login(
|
||||
'test@example.com',
|
||||
'wrongpassword'
|
||||
);
|
||||
});
|
||||
|
||||
expect(loginResult).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Login failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Invalid credentials',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles multiple login attempts', async () => {
|
||||
(mockLogin as ReturnType<typeof vi.fn>)
|
||||
.mockRejectedValueOnce(new Error('First attempt failed'))
|
||||
.mockResolvedValueOnce(mockUser);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
// First attempt fails
|
||||
let firstResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
firstResult = await result.current.login(
|
||||
'test@example.com',
|
||||
'wrongpassword'
|
||||
);
|
||||
});
|
||||
|
||||
expect(firstResult).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
|
||||
// Second attempt succeeds
|
||||
let secondResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
secondResult = await result.current.login(
|
||||
'test@example.com',
|
||||
'correctpassword'
|
||||
);
|
||||
});
|
||||
|
||||
expect(secondResult).toBe(true);
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout functionality', () => {
|
||||
it('logs out user successfully', async () => {
|
||||
// Start with authenticated user
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears user state even when logout API fails', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
// Start with authenticated user
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
(mockLogout as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Logout failed')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Logout failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles logout when user is already null', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken functionality', () => {
|
||||
it('refreshes token successfully', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
(mockRefreshToken as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
let refreshResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
refreshResult = await result.current.refreshToken();
|
||||
});
|
||||
|
||||
expect(refreshResult).toBe(true);
|
||||
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles token refresh failure and logs out user', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
// Start with authenticated user
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
(mockRefreshToken as ReturnType<typeof vi.fn>).mockResolvedValue(false);
|
||||
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
let refreshResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
refreshResult = await result.current.refreshToken();
|
||||
});
|
||||
|
||||
expect(refreshResult).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles token refresh API error and logs out user', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
// Start with authenticated user
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
(mockRefreshToken as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Refresh failed')
|
||||
);
|
||||
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
let refreshResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
refreshResult = await result.current.refreshToken();
|
||||
});
|
||||
|
||||
expect(refreshResult).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Token refresh failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshUser functionality', () => {
|
||||
it('refreshes user data successfully', async () => {
|
||||
// Start with authenticated user
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
// Mock updated user data
|
||||
const updatedUser = { ...mockUser, displayName: 'Updated User' };
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
updatedUser
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshUser();
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(updatedUser);
|
||||
expect(mockGetCurrentUser).toHaveBeenCalledTimes(2); // Once on init, once on refresh
|
||||
});
|
||||
|
||||
it('handles user refresh failure', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
// Start with authenticated user
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
// Mock refresh failure
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Refresh user failed')
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshUser();
|
||||
});
|
||||
|
||||
// User should remain the same after failed refresh
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to refresh user data:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication state transitions', () => {
|
||||
it('transitions from unauthenticated to authenticated', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
(mockLogin as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login('test@example.com', 'password123');
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('transitions from authenticated to unauthenticated', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
|
||||
it('handles user data updates while authenticated', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
// Simulate user profile update
|
||||
const updatedUser = { ...mockUser, displayName: 'Updated Name' };
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
updatedUser
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshUser();
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(updatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context value structure', () => {
|
||||
it('provides expected context interface', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
// Check boolean and object values
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.initialized).toBe(true);
|
||||
|
||||
// Check function types
|
||||
expect(typeof result.current.login).toBe('function');
|
||||
expect(typeof result.current.logout).toBe('function');
|
||||
expect(typeof result.current.refreshToken).toBe('function');
|
||||
expect(typeof result.current.refreshUser).toBe('function');
|
||||
});
|
||||
|
||||
it('provides correct context when authenticated', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
// Check boolean and object values
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.initialized).toBe(true);
|
||||
|
||||
// Check function types
|
||||
expect(typeof result.current.login).toBe('function');
|
||||
expect(typeof result.current.logout).toBe('function');
|
||||
expect(typeof result.current.refreshToken).toBe('function');
|
||||
expect(typeof result.current.refreshUser).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('shows loading during initialization', async () => {
|
||||
let resolveGetCurrentUser: (value: User) => void;
|
||||
const pendingPromise = new Promise<User>((resolve) => {
|
||||
resolveGetCurrentUser = resolve;
|
||||
});
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
pendingPromise
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.initialized).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
resolveGetCurrentUser!(mockUser);
|
||||
await pendingPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears loading after initialization completes', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockUser
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears loading after initialization fails', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Init failed')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles invalid user data during initialization', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
// Use a more precise type for testing
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
invalid: 'user',
|
||||
} as unknown as User);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual({ invalid: 'user' });
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('handles concurrent login attempts', async () => {
|
||||
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Not authenticated')
|
||||
);
|
||||
(mockLogin as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.initialized).toBe(true);
|
||||
});
|
||||
|
||||
// Make concurrent login calls
|
||||
const [result1, result2] = await act(async () => {
|
||||
return Promise.all([
|
||||
result.current.login('test@example.com', 'password123'),
|
||||
result.current.login('test@example.com', 'password123'),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(true);
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(mockLogin).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
app/src/contexts/ModalContext.test.tsx
Normal file
218
app/src/contexts/ModalContext.test.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ModalProvider, useModalContext } from './ModalContext';
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const createWrapper = () => {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ModalProvider>{children}</ModalProvider>
|
||||
);
|
||||
Wrapper.displayName = 'ModalProviderTestWrapper';
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
// Modal field pairs for parameterized testing
|
||||
const modalFieldPairs = [
|
||||
{ field: 'newFileModalVisible', setter: 'setNewFileModalVisible' },
|
||||
{ field: 'deleteFileModalVisible', setter: 'setDeleteFileModalVisible' },
|
||||
{
|
||||
field: 'commitMessageModalVisible',
|
||||
setter: 'setCommitMessageModalVisible',
|
||||
},
|
||||
{ field: 'settingsModalVisible', setter: 'setSettingsModalVisible' },
|
||||
{
|
||||
field: 'switchWorkspaceModalVisible',
|
||||
setter: 'setSwitchWorkspaceModalVisible',
|
||||
},
|
||||
{
|
||||
field: 'createWorkspaceModalVisible',
|
||||
setter: 'setCreateWorkspaceModalVisible',
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe('ModalContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ModalProvider', () => {
|
||||
it('provides modal context with initial false values and all setter functions', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
// All modal states should be false initially and setters should be functions
|
||||
modalFieldPairs.forEach(({ field, setter }) => {
|
||||
expect(result.current[field]).toBe(false);
|
||||
expect(typeof result.current[setter]).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains function stability across re-renders', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result, rerender } = renderHook(() => useModalContext(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const initialSetters = modalFieldPairs.map(
|
||||
({ setter }) => result.current[setter]
|
||||
);
|
||||
|
||||
rerender();
|
||||
|
||||
modalFieldPairs.forEach(({ setter }, index) => {
|
||||
expect(result.current[setter]).toBe(initialSetters[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useModalContext hook', () => {
|
||||
it('throws error when used outside ModalProvider', () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useModalContext());
|
||||
}).toThrow('useModalContext must be used within a ModalProvider');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns complete context interface', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
modalFieldPairs.forEach(({ field, setter }) => {
|
||||
expect(field in result.current).toBe(true);
|
||||
expect(setter in result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal state management', () => {
|
||||
// Test all modals with the same pattern using parameterized tests
|
||||
modalFieldPairs.forEach(({ field, setter }) => {
|
||||
describe(field, () => {
|
||||
it('can be toggled true and false', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
// Set to true
|
||||
act(() => {
|
||||
result.current[setter](true);
|
||||
});
|
||||
expect(result.current[field]).toBe(true);
|
||||
|
||||
// Set to false
|
||||
act(() => {
|
||||
result.current[setter](false);
|
||||
});
|
||||
expect(result.current[field]).toBe(false);
|
||||
});
|
||||
|
||||
it('supports function updater pattern', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
// Toggle using function updater
|
||||
act(() => {
|
||||
result.current[setter]((prev) => !prev);
|
||||
});
|
||||
expect(result.current[field]).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current[setter]((prev) => !prev);
|
||||
});
|
||||
expect(result.current[field]).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('each modal state is independent', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
// Set first three modals to true
|
||||
act(() => {
|
||||
result.current.setNewFileModalVisible(true);
|
||||
result.current.setDeleteFileModalVisible(true);
|
||||
result.current.setSettingsModalVisible(true);
|
||||
});
|
||||
|
||||
expect(result.current.newFileModalVisible).toBe(true);
|
||||
expect(result.current.deleteFileModalVisible).toBe(true);
|
||||
expect(result.current.settingsModalVisible).toBe(true);
|
||||
expect(result.current.commitMessageModalVisible).toBe(false);
|
||||
expect(result.current.switchWorkspaceModalVisible).toBe(false);
|
||||
expect(result.current.createWorkspaceModalVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('setting one modal does not affect others', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
// Set all modals to true
|
||||
act(() => {
|
||||
modalFieldPairs.forEach(({ setter }) => {
|
||||
result.current[setter](true);
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle one modal off
|
||||
act(() => {
|
||||
result.current.setNewFileModalVisible(false);
|
||||
});
|
||||
|
||||
expect(result.current.newFileModalVisible).toBe(false);
|
||||
// All others should remain true
|
||||
modalFieldPairs.slice(1).forEach(({ field }) => {
|
||||
expect(result.current[field]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports rapid state updates', () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useModalContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setNewFileModalVisible(true);
|
||||
result.current.setNewFileModalVisible(false);
|
||||
result.current.setNewFileModalVisible(true);
|
||||
});
|
||||
|
||||
expect(result.current.newFileModalVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider nesting', () => {
|
||||
it('inner provider creates independent context', () => {
|
||||
const OuterWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ModalProvider>{children}</ModalProvider>
|
||||
);
|
||||
|
||||
const InnerWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<OuterWrapper>
|
||||
<ModalProvider>{children}</ModalProvider>
|
||||
</OuterWrapper>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useModalContext(), {
|
||||
wrapper: InnerWrapper,
|
||||
});
|
||||
|
||||
// Should work with nested providers (inner context takes precedence)
|
||||
expect(result.current.newFileModalVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.setNewFileModalVisible(true);
|
||||
});
|
||||
|
||||
expect(result.current.newFileModalVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
256
app/src/contexts/ThemeContext.test.tsx
Normal file
256
app/src/contexts/ThemeContext.test.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ThemeProvider, useTheme } from './ThemeContext';
|
||||
import type { MantineColorScheme } from '@mantine/core';
|
||||
|
||||
// Mock Mantine's color scheme hook
|
||||
const mockSetColorScheme = vi.fn();
|
||||
const mockUseMantineColorScheme = vi.fn();
|
||||
|
||||
vi.mock('@mantine/core', () => ({
|
||||
useMantineColorScheme: (): {
|
||||
colorScheme: MantineColorScheme | undefined;
|
||||
setColorScheme?: (scheme: MantineColorScheme) => void;
|
||||
} =>
|
||||
mockUseMantineColorScheme() as {
|
||||
colorScheme: MantineColorScheme | undefined;
|
||||
setColorScheme?: (scheme: MantineColorScheme) => void;
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const createWrapper = (initialColorScheme: MantineColorScheme = 'light') => {
|
||||
mockUseMantineColorScheme.mockReturnValue({
|
||||
colorScheme: initialColorScheme,
|
||||
setColorScheme: mockSetColorScheme,
|
||||
});
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
);
|
||||
Wrapper.displayName = 'ThemeProviderTestWrapper';
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe('ThemeContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
it('provides theme context with light scheme by default', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(result.current.colorScheme).toBe('light');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
|
||||
it('provides theme context with dark scheme', () => {
|
||||
const wrapper = createWrapper('dark');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(result.current.colorScheme).toBe('dark');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
|
||||
it('provides theme context with fallback to light scheme', () => {
|
||||
const wrapper = createWrapper('auto');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(result.current.colorScheme).toBe('light');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
|
||||
it('calls useMantineColorScheme hook', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(mockUseMantineColorScheme).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTheme hook', () => {
|
||||
it('throws error when used outside ThemeProvider', () => {
|
||||
// Suppress console.error for this test since we expect an error
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTheme());
|
||||
}).toThrow('useTheme must be used within a ThemeProvider');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns current color scheme from Mantine', () => {
|
||||
const wrapper = createWrapper('dark');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(result.current.colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
it('provides updateColorScheme function', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
|
||||
it('maintains function stability across re-renders', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result, rerender } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
const initialUpdateFunction = result.current.updateColorScheme;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.updateColorScheme).toBe(initialUpdateFunction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateColorScheme functionality', () => {
|
||||
it('calls setColorScheme when updateColorScheme is invoked', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateColorScheme('dark');
|
||||
});
|
||||
|
||||
expect(mockSetColorScheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('handles multiple color scheme changes', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateColorScheme('dark');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Should not set color scheme to 'auto'
|
||||
result.current.updateColorScheme('auto');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateColorScheme('light');
|
||||
});
|
||||
|
||||
expect(mockSetColorScheme).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetColorScheme).toHaveBeenNthCalledWith(1, 'dark');
|
||||
expect(mockSetColorScheme).toHaveBeenNthCalledWith(2, 'light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('context structure', () => {
|
||||
it('provides expected context interface', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(result.current).toEqual({
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: expect.any(Function) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('context value has correct types', () => {
|
||||
const wrapper = createWrapper('dark');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(typeof result.current.colorScheme).toBe('string');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
it('maintains function reference when color scheme changes', () => {
|
||||
mockUseMantineColorScheme.mockReturnValue({
|
||||
colorScheme: 'light',
|
||||
setColorScheme: mockSetColorScheme,
|
||||
});
|
||||
|
||||
const wrapper = createWrapper('light');
|
||||
const { result, rerender } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
const initialUpdateFunction = result.current.updateColorScheme;
|
||||
|
||||
// Change color scheme
|
||||
mockUseMantineColorScheme.mockReturnValue({
|
||||
colorScheme: 'dark',
|
||||
setColorScheme: mockSetColorScheme,
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.updateColorScheme).toBe(initialUpdateFunction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider nesting', () => {
|
||||
it('works with nested providers (inner provider takes precedence)', () => {
|
||||
mockUseMantineColorScheme.mockReturnValue({
|
||||
colorScheme: 'dark',
|
||||
setColorScheme: mockSetColorScheme,
|
||||
});
|
||||
|
||||
const NestedWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTheme(), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
expect(result.current.colorScheme).toBe('dark');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles undefined color scheme gracefully by falling back to light theme', () => {
|
||||
mockUseMantineColorScheme.mockReturnValue({
|
||||
colorScheme: undefined,
|
||||
setColorScheme: mockSetColorScheme,
|
||||
});
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
// Should fallback to 'light' theme rather than being undefined
|
||||
expect(result.current.colorScheme).toBe('light');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
|
||||
it('handles missing setColorScheme function', () => {
|
||||
mockUseMantineColorScheme.mockReturnValue({
|
||||
colorScheme: 'light',
|
||||
setColorScheme: undefined,
|
||||
});
|
||||
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// Should not throw during render
|
||||
expect(() => {
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles updateColorScheme with same color scheme', () => {
|
||||
const wrapper = createWrapper('light');
|
||||
const { result } = renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateColorScheme('light'); // Same as current
|
||||
});
|
||||
|
||||
expect(mockSetColorScheme).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,13 +22,19 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
|
||||
const updateColorScheme = useCallback(
|
||||
(newTheme: MantineColorScheme): void => {
|
||||
setColorScheme(newTheme);
|
||||
if (newTheme === 'light' || newTheme === 'dark') {
|
||||
if (setColorScheme) {
|
||||
setColorScheme(newTheme);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setColorScheme]
|
||||
);
|
||||
|
||||
// Ensure colorScheme is never undefined by falling back to light theme
|
||||
const value: ThemeContextType = {
|
||||
colorScheme,
|
||||
colorScheme:
|
||||
colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : 'light',
|
||||
updateColorScheme,
|
||||
};
|
||||
|
||||
|
||||
762
app/src/contexts/WorkspaceDataContext.test.tsx
Normal file
762
app/src/contexts/WorkspaceDataContext.test.tsx
Normal file
@@ -0,0 +1,762 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {
|
||||
WorkspaceDataProvider,
|
||||
useWorkspaceData,
|
||||
} from './WorkspaceDataContext';
|
||||
import {
|
||||
DEFAULT_WORKSPACE_SETTINGS,
|
||||
type Workspace,
|
||||
Theme,
|
||||
} from '@/types/models';
|
||||
|
||||
// Set up mocks before imports are used
|
||||
vi.mock('@/api/workspace', () => {
|
||||
return {
|
||||
getWorkspace: vi.fn(),
|
||||
listWorkspaces: vi.fn(),
|
||||
getLastWorkspaceName: vi.fn(),
|
||||
updateLastWorkspaceName: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@mantine/notifications', () => {
|
||||
return {
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./ThemeContext', () => {
|
||||
return {
|
||||
useTheme: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Import the mocks after they've been defined
|
||||
import {
|
||||
getWorkspace as mockGetWorkspace,
|
||||
listWorkspaces as mockListWorkspaces,
|
||||
getLastWorkspaceName as mockGetLastWorkspaceName,
|
||||
updateLastWorkspaceName as mockUpdateLastWorkspaceName,
|
||||
} from '@/api/workspace';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTheme } from './ThemeContext';
|
||||
|
||||
// Get reference to the mocked functions
|
||||
const mockNotificationsShow = notifications.show as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const mockUseTheme = useTheme as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateColorScheme = vi.fn();
|
||||
|
||||
// Mock workspace data
|
||||
const mockWorkspace: Workspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
theme: Theme.Dark,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
autoSave: true,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
const mockWorkspace2: Workspace = {
|
||||
id: 2,
|
||||
name: 'workspace-2',
|
||||
theme: Theme.Light,
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
const mockWorkspaceList: Workspace[] = [mockWorkspace, mockWorkspace2];
|
||||
|
||||
// Helper wrapper component for testing
|
||||
const createWrapper = () => {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkspaceDataProvider>{children}</WorkspaceDataProvider>
|
||||
);
|
||||
Wrapper.displayName = 'WorkspaceDataProviderTestWrapper';
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe('WorkspaceDataContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default theme mock
|
||||
mockUseTheme.mockReturnValue({
|
||||
colorScheme: 'light',
|
||||
updateColorScheme: mockUpdateColorScheme,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('WorkspaceDataProvider initialization', () => {
|
||||
it('initializes with null workspace and loading state', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('provides all expected functions', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
expect(typeof result.current.loadWorkspaces).toBe('function');
|
||||
expect(typeof result.current.loadWorkspaceData).toBe('function');
|
||||
expect(typeof result.current.setCurrentWorkspace).toBe('function');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads last workspace when available', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
'test-workspace'
|
||||
);
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspaceList
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
||||
expect(result.current.settings).toEqual(mockWorkspace);
|
||||
expect(mockGetLastWorkspaceName).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||
expect(mockListWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('loads first available workspace when no last workspace', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspaceList
|
||||
);
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
(
|
||||
mockUpdateLastWorkspaceName as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(mockUpdateLastWorkspaceName).toHaveBeenCalledWith(
|
||||
'test-workspace'
|
||||
);
|
||||
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
||||
it('handles initialization error gracefully', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspaceList
|
||||
);
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
(
|
||||
mockUpdateLastWorkspaceName as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to initialize workspace:',
|
||||
expect.any(Error)
|
||||
);
|
||||
// Should fallback to loading first available workspace
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles case when no workspaces are available', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkspaceData hook', () => {
|
||||
it('throws error when used outside WorkspaceDataProvider', () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useWorkspaceData());
|
||||
}).toThrow(
|
||||
'useWorkspaceData must be used within a WorkspaceDataProvider'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns workspace context when used within provider', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(typeof result.current).toBe('object');
|
||||
});
|
||||
|
||||
it('maintains function stability across re-renders', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result, rerender } = renderHook(() => useWorkspaceData(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
const initialFunctions = {
|
||||
loadWorkspaces: result.current.loadWorkspaces,
|
||||
loadWorkspaceData: result.current.loadWorkspaceData,
|
||||
setCurrentWorkspace: result.current.setCurrentWorkspace,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.loadWorkspaces).toBe(
|
||||
initialFunctions.loadWorkspaces
|
||||
);
|
||||
expect(result.current.loadWorkspaceData).toBe(
|
||||
initialFunctions.loadWorkspaceData
|
||||
);
|
||||
expect(result.current.setCurrentWorkspace).toBe(
|
||||
initialFunctions.setCurrentWorkspace
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadWorkspaces functionality', () => {
|
||||
beforeEach(() => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads workspaces successfully', async () => {
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspaceList
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let workspaces: Workspace[] | undefined;
|
||||
await act(async () => {
|
||||
workspaces = await result.current.loadWorkspaces();
|
||||
});
|
||||
|
||||
expect(workspaces).toEqual(mockWorkspaceList);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
||||
});
|
||||
|
||||
it('handles loadWorkspaces failure', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([]) // Initial load
|
||||
.mockRejectedValueOnce(new Error('Failed to load workspaces'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let workspaces: Workspace[] | undefined;
|
||||
await act(async () => {
|
||||
workspaces = await result.current.loadWorkspaces();
|
||||
});
|
||||
|
||||
expect(workspaces).toEqual([]);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load workspaces:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspaces list',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadWorkspaceData functionality', () => {
|
||||
beforeEach(() => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads workspace data successfully', async () => {
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadWorkspaceData('test-workspace');
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.settings).toEqual(mockWorkspace);
|
||||
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('handles loadWorkspaceData failure', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Workspace not found')
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadWorkspaceData('nonexistent-workspace');
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load workspace data:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspace data',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('updates theme when loading workspace', async () => {
|
||||
const lightThemeWorkspace = { ...mockWorkspace, theme: 'light' };
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
lightThemeWorkspace
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadWorkspaceData('test-workspace');
|
||||
});
|
||||
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentWorkspace functionality', () => {
|
||||
beforeEach(() => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('sets current workspace', async () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentWorkspace(mockWorkspace);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.settings).toEqual(mockWorkspace);
|
||||
});
|
||||
|
||||
it('sets workspace to null', async () => {
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Set a workspace first
|
||||
act(() => {
|
||||
result.current.setCurrentWorkspace(mockWorkspace);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
|
||||
// Then set it to null
|
||||
act(() => {
|
||||
result.current.setCurrentWorkspace(null);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace state transitions', () => {
|
||||
beforeEach(() => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('transitions from null to workspace', async () => {
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadWorkspaceData('test-workspace');
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
});
|
||||
|
||||
it('transitions between different workspaces', async () => {
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockWorkspace)
|
||||
.mockResolvedValueOnce(mockWorkspace2);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Load first workspace
|
||||
await act(async () => {
|
||||
await result.current.loadWorkspaceData('test-workspace');
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark');
|
||||
|
||||
// Load second workspace
|
||||
await act(async () => {
|
||||
await result.current.loadWorkspaceData('workspace-2');
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace2);
|
||||
expect(mockUpdateColorScheme).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('context value structure', () => {
|
||||
it('provides expected context interface when no workspace loaded', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS);
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
expect(typeof result.current.loadWorkspaces).toBe('function');
|
||||
expect(typeof result.current.loadWorkspaceData).toBe('function');
|
||||
expect(typeof result.current.setCurrentWorkspace).toBe('function');
|
||||
});
|
||||
|
||||
it('provides correct context when workspace loaded', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
'test-workspace'
|
||||
);
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspaceList
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
||||
expect(result.current.settings).toEqual(mockWorkspace);
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
expect(typeof result.current.loadWorkspaces).toBe('function');
|
||||
expect(typeof result.current.loadWorkspaceData).toBe('function');
|
||||
expect(typeof result.current.setCurrentWorkspace).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('shows loading during initialization', async () => {
|
||||
let resolveGetLastWorkspaceName: (value: string | null) => void;
|
||||
const pendingPromise = new Promise<string | null>((resolve) => {
|
||||
resolveGetLastWorkspaceName = resolve;
|
||||
});
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
pendingPromise
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveGetLastWorkspaceName!(null);
|
||||
await pendingPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears loading after initialization completes', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
'test-workspace'
|
||||
);
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspace
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockWorkspaceList
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears loading after initialization fails', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Init failed')
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('handles concurrent loadWorkspaceData calls', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(mockGetWorkspace as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockWorkspace)
|
||||
.mockResolvedValueOnce(mockWorkspace2);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Make concurrent calls
|
||||
await act(async () => {
|
||||
await Promise.all([
|
||||
result.current.loadWorkspaceData('test-workspace'),
|
||||
result.current.loadWorkspaceData('workspace-2'),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mockGetWorkspace).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||
expect(mockGetWorkspace).toHaveBeenCalledWith('workspace-2');
|
||||
});
|
||||
|
||||
it('handles concurrent loadWorkspaces calls', async () => {
|
||||
(mockGetLastWorkspaceName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null
|
||||
);
|
||||
(mockListWorkspaces as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([]) // Initial load
|
||||
.mockResolvedValue(mockWorkspaceList) // Subsequent calls
|
||||
.mockResolvedValue(mockWorkspaceList);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useWorkspaceData(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Make concurrent calls
|
||||
const [result1, result2] = await act(async () => {
|
||||
return Promise.all([
|
||||
result.current.loadWorkspaces(),
|
||||
result.current.loadWorkspaces(),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(result1).toEqual(mockWorkspaceList);
|
||||
expect(result2).toEqual(mockWorkspaceList);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaceList);
|
||||
});
|
||||
});
|
||||
});
|
||||
577
app/src/hooks/useAdminData.test.ts
Normal file
577
app/src/hooks/useAdminData.test.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useAdminData } from './useAdminData';
|
||||
import * as adminApi from '@/api/admin';
|
||||
import {
|
||||
UserRole,
|
||||
type SystemStats,
|
||||
type User,
|
||||
type WorkspaceStats,
|
||||
} from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/admin');
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import notifications for assertions
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
// Mock data
|
||||
const mockSystemStats: SystemStats = {
|
||||
totalUsers: 10,
|
||||
activeUsers: 8,
|
||||
totalWorkspaces: 15,
|
||||
totalFiles: 150,
|
||||
totalSize: 1024000,
|
||||
};
|
||||
|
||||
const mockUsers: User[] = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: UserRole.Admin,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'editor@example.com',
|
||||
displayName: 'Editor User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
lastWorkspaceId: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const mockWorkspaceStats: WorkspaceStats[] = [
|
||||
{
|
||||
userID: 1,
|
||||
userEmail: 'admin@example.com',
|
||||
workspaceID: 1,
|
||||
workspaceName: 'admin-workspace',
|
||||
workspaceCreatedAt: '2024-01-01T00:00:00Z',
|
||||
fileCountStats: {
|
||||
totalFiles: 10,
|
||||
totalSize: 204800,
|
||||
},
|
||||
},
|
||||
{
|
||||
userID: 2,
|
||||
userEmail: 'editor@example.com',
|
||||
workspaceID: 2,
|
||||
workspaceName: 'editor-workspace',
|
||||
workspaceCreatedAt: '2024-01-02T00:00:00Z',
|
||||
fileCountStats: {
|
||||
totalFiles: 15,
|
||||
totalSize: 307200,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('useAdminData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('stats data type', () => {
|
||||
it('initializes with empty stats and loading state', async () => {
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
expect(result.current.data).toEqual({});
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(typeof result.current.reload).toBe('function');
|
||||
|
||||
// Wait for the hook to complete its async initialization
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads system stats successfully', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
mockGetSystemStats.mockResolvedValue(mockSystemStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockSystemStats);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockGetSystemStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles stats loading errors', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
mockGetSystemStats.mockRejectedValue(new Error('Failed to load stats'));
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual({});
|
||||
expect(result.current.error).toBe('Failed to load stats');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to load stats: Failed to load stats',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads stats data', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
mockGetSystemStats.mockResolvedValue(mockSystemStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockGetSystemStats).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
expect(mockGetSystemStats).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.data).toEqual(mockSystemStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('users data type', () => {
|
||||
it('initializes with empty users array and loading state', async () => {
|
||||
const { result } = renderHook(() => useAdminData('users'));
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(typeof result.current.reload).toBe('function');
|
||||
|
||||
// Wait for the hook to complete its async initialization
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads users successfully', async () => {
|
||||
const mockGetUsers = vi.mocked(adminApi.getUsers);
|
||||
mockGetUsers.mockResolvedValue(mockUsers);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('users'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockUsers);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockGetUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles users loading errors', async () => {
|
||||
const mockGetUsers = vi.mocked(adminApi.getUsers);
|
||||
mockGetUsers.mockRejectedValue(new Error('Failed to load users'));
|
||||
|
||||
const { result } = renderHook(() => useAdminData('users'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.error).toBe('Failed to load users');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to load users: Failed to load users',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads users data', async () => {
|
||||
const mockGetUsers = vi.mocked(adminApi.getUsers);
|
||||
mockGetUsers.mockResolvedValue(mockUsers);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('users'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockGetUsers).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
expect(mockGetUsers).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.data).toEqual(mockUsers);
|
||||
});
|
||||
|
||||
it('handles empty users array', async () => {
|
||||
const mockGetUsers = vi.mocked(adminApi.getUsers);
|
||||
mockGetUsers.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('users'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspaces data type', () => {
|
||||
it('initializes with empty workspaces array and loading state', async () => {
|
||||
const { result } = renderHook(() => useAdminData('workspaces'));
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(typeof result.current.reload).toBe('function');
|
||||
|
||||
// Wait for the hook to complete its async initialization
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads workspaces successfully', async () => {
|
||||
const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces);
|
||||
mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('workspaces'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockWorkspaceStats);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockGetWorkspaces).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles workspaces loading errors', async () => {
|
||||
const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces);
|
||||
mockGetWorkspaces.mockRejectedValue(
|
||||
new Error('Failed to load workspaces')
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('workspaces'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.error).toBe('Failed to load workspaces');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspaces: Failed to load workspaces',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads workspaces data', async () => {
|
||||
const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces);
|
||||
mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('workspaces'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockGetWorkspaces).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
expect(mockGetWorkspaces).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.data).toEqual(mockWorkspaceStats);
|
||||
});
|
||||
|
||||
it('handles workspaces with minimal configuration', async () => {
|
||||
const minimalWorkspaceStats: WorkspaceStats[] = [
|
||||
{
|
||||
userID: 3,
|
||||
userEmail: 'minimal@example.com',
|
||||
workspaceID: 3,
|
||||
workspaceName: 'minimal-workspace',
|
||||
workspaceCreatedAt: '2024-01-03T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces);
|
||||
mockGetWorkspaces.mockResolvedValue(minimalWorkspaceStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('workspaces'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(minimalWorkspaceStats);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles API errors with error response object', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
// Create a properly typed error object to simulate API error response
|
||||
const errorWithResponse = new Error('Request failed');
|
||||
type ErrorWithResponse = Error & {
|
||||
response: {
|
||||
data: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
(errorWithResponse as ErrorWithResponse).response = {
|
||||
data: {
|
||||
error: 'Custom API error message',
|
||||
},
|
||||
};
|
||||
mockGetSystemStats.mockRejectedValue(errorWithResponse);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Custom API error message');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to load stats: Custom API error message',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears error on successful reload', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
mockGetSystemStats
|
||||
.mockRejectedValueOnce(new Error('Initial error'))
|
||||
.mockResolvedValueOnce(mockSystemStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
// Wait for initial error
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Initial error');
|
||||
});
|
||||
|
||||
// Reload successfully
|
||||
await act(async () => {
|
||||
await result.current.reload();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.data).toEqual(mockSystemStats);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state management', () => {
|
||||
it('manages loading state correctly through full lifecycle', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
let resolvePromise: (value: SystemStats) => void;
|
||||
const pendingPromise = new Promise<SystemStats>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockGetSystemStats.mockReturnValue(pendingPromise);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
// Initial load should be loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Resolve initial load
|
||||
await act(async () => {
|
||||
resolvePromise!(mockSystemStats);
|
||||
await pendingPromise;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
// Test reload loading state
|
||||
let resolveReload: (value: SystemStats) => void;
|
||||
const reloadPromise = new Promise<SystemStats>((resolve) => {
|
||||
resolveReload = resolve;
|
||||
});
|
||||
mockGetSystemStats.mockReturnValueOnce(reloadPromise);
|
||||
|
||||
act(() => {
|
||||
void result.current.reload();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveReload!(mockSystemStats);
|
||||
await reloadPromise;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data consistency', () => {
|
||||
it('handles data type parameter changes', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
const mockGetUsers = vi.mocked(adminApi.getUsers);
|
||||
|
||||
mockGetSystemStats.mockResolvedValue(mockSystemStats);
|
||||
mockGetUsers.mockResolvedValue(mockUsers);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ type }) => useAdminData(type),
|
||||
{
|
||||
initialProps: { type: 'stats' as const } as {
|
||||
type: 'stats' | 'users' | 'workspaces';
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for stats to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockSystemStats);
|
||||
|
||||
// Change to users type
|
||||
rerender({ type: 'users' as const });
|
||||
|
||||
// Should reset to loading and empty array for users
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockUsers);
|
||||
expect(mockGetUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('handles data type changes correctly with different initial values', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
const mockGetUsers = vi.mocked(adminApi.getUsers);
|
||||
const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces);
|
||||
|
||||
mockGetSystemStats.mockResolvedValue(mockSystemStats);
|
||||
mockGetUsers.mockResolvedValue(mockUsers);
|
||||
mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ type }) => useAdminData(type),
|
||||
{
|
||||
initialProps: { type: 'stats' as const } as {
|
||||
type: 'stats' | 'users' | 'workspaces';
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for stats to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.data).toEqual(mockSystemStats);
|
||||
|
||||
// Change to users type - should reset to empty array and reload
|
||||
act(() => {
|
||||
rerender({ type: 'users' as const });
|
||||
});
|
||||
|
||||
// Data should reset to empty array immediately when type changes
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.data).toEqual(mockUsers);
|
||||
|
||||
// Change to workspaces type - should reset to empty array and reload
|
||||
act(() => {
|
||||
rerender({ type: 'workspaces' as const });
|
||||
});
|
||||
|
||||
// Data should reset to empty array immediately when type changes
|
||||
expect(result.current.data).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.data).toEqual(mockWorkspaceStats);
|
||||
|
||||
// Verify correct API calls were made
|
||||
expect(mockGetSystemStats).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetUsers).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetWorkspaces).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function stability', () => {
|
||||
it('maintains stable reload function reference', async () => {
|
||||
const { result, rerender } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
const initialReload = result.current.reload;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.reload).toBe(initialReload);
|
||||
|
||||
// Wait for the hook to complete its async initialization
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('handles multiple concurrent reloads', async () => {
|
||||
const mockGetSystemStats = vi.mocked(adminApi.getSystemStats);
|
||||
mockGetSystemStats.mockResolvedValue(mockSystemStats);
|
||||
|
||||
const { result } = renderHook(() => useAdminData('stats'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Trigger multiple reloads
|
||||
await act(async () => {
|
||||
await Promise.all([
|
||||
result.current.reload(),
|
||||
result.current.reload(),
|
||||
result.current.reload(),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mockGetSystemStats).toHaveBeenCalledTimes(4); // 1 initial + 3 reloads
|
||||
expect(result.current.data).toEqual(mockSystemStats);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export const useAdminData = <T extends AdminDataType>(
|
||||
type: T
|
||||
): AdminDataResult<T> => {
|
||||
// Initialize with the appropriate empty type
|
||||
const getInitialData = (): AdminData<T> => {
|
||||
const getInitialData = useCallback((): AdminData<T> => {
|
||||
if (type === 'stats') {
|
||||
return {} as SystemStats as AdminData<T>;
|
||||
} else if (type === 'workspaces') {
|
||||
@@ -38,12 +38,18 @@ export const useAdminData = <T extends AdminDataType>(
|
||||
} else {
|
||||
return [] as unknown as AdminData<T>;
|
||||
}
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
const [data, setData] = useState<AdminData<T>>(getInitialData());
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset data when type changes
|
||||
useEffect(() => {
|
||||
setData(getInitialData());
|
||||
setError(null);
|
||||
}, [type, getInitialData]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
509
app/src/hooks/useFileContent.test.ts
Normal file
509
app/src/hooks/useFileContent.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useFileContent } from './useFileContent';
|
||||
import * as fileApi from '@/api/file';
|
||||
import * as fileHelpers from '@/utils/fileHelpers';
|
||||
import { DEFAULT_FILE } from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/file');
|
||||
vi.mock('@/utils/fileHelpers');
|
||||
|
||||
// Create a mock workspace context hook
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: { id: number; name: string } | null;
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
describe('useFileContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('returns default content and no unsaved changes initially', () => {
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
expect(result.current.content).toBe(DEFAULT_FILE.content);
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('provides setters for content and unsaved changes', () => {
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
expect(typeof result.current.setContent).toBe('function');
|
||||
expect(typeof result.current.setHasUnsavedChanges).toBe('function');
|
||||
expect(typeof result.current.loadFileContent).toBe('function');
|
||||
expect(typeof result.current.handleContentChange).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading file content', () => {
|
||||
it('loads default file content when selectedFile is DEFAULT_FILE.path', async () => {
|
||||
const { result } = renderHook(() => useFileContent(DEFAULT_FILE.path));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe(DEFAULT_FILE.content);
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
|
||||
expect(fileApi.getFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads file content from API for regular files', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent.mockResolvedValue('# Test Content');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useFileContent('test.md'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('# Test Content');
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(mockGetFileContent).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets empty content for image files', async () => {
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
mockIsImageFile.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useFileContent('image.png'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
|
||||
expect(fileApi.getFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockGetFileContent.mockRejectedValue(new Error('API Error'));
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useFileContent('error.md'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error loading file content:',
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not load content when no workspace is available', () => {
|
||||
// Mock no workspace
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useFileContent('test.md'));
|
||||
|
||||
expect(result.current.content).toBe(DEFAULT_FILE.content);
|
||||
expect(fileApi.getFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content changes', () => {
|
||||
it('updates content and tracks unsaved changes', () => {
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('New content');
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe('New content');
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark as unsaved when content matches original', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent.mockResolvedValue('Original content');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useFileContent('test.md'));
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Original content');
|
||||
});
|
||||
|
||||
// Change content
|
||||
act(() => {
|
||||
result.current.handleContentChange('Modified content');
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
|
||||
// Change back to original
|
||||
act(() => {
|
||||
result.current.handleContentChange('Original content');
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('allows direct content setting', () => {
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent('Direct content');
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe('Direct content');
|
||||
// Note: setContent doesn't automatically update unsaved changes
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file changes', () => {
|
||||
it('reloads content when selectedFile changes', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent
|
||||
.mockResolvedValueOnce('First file content')
|
||||
.mockResolvedValueOnce('Second file content');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedFile }) => useFileContent(selectedFile),
|
||||
{ initialProps: { selectedFile: 'first.md' } }
|
||||
);
|
||||
|
||||
// Wait for first file to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('First file content');
|
||||
});
|
||||
|
||||
// Change to second file
|
||||
rerender({ selectedFile: 'second.md' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Second file content');
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(mockGetFileContent).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetFileContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'test-workspace',
|
||||
'first.md'
|
||||
);
|
||||
expect(mockGetFileContent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'test-workspace',
|
||||
'second.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('resets unsaved changes when file changes', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent
|
||||
.mockResolvedValueOnce('File content')
|
||||
.mockResolvedValueOnce('Other file content');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedFile }) => useFileContent(selectedFile),
|
||||
{ initialProps: { selectedFile: 'first.md' } }
|
||||
);
|
||||
|
||||
// Wait for initial load and make changes
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('File content');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('Modified content');
|
||||
});
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
|
||||
// Change file
|
||||
rerender({ selectedFile: 'second.md' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reload when selectedFile is null', () => {
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
expect(result.current.content).toBe(DEFAULT_FILE.content);
|
||||
expect(fileApi.getFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual loadFileContent', () => {
|
||||
it('can manually load file content', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent.mockResolvedValue('Manually loaded content');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileContent('manual.md');
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe('Manually loaded content');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(mockGetFileContent).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'manual.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles manual load errors', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockGetFileContent.mockRejectedValue(new Error('Manual load error'));
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useFileContent(null));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileContent('error.md');
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe('');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error loading file content:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace dependency changes', () => {
|
||||
it('reloads content when workspace changes while file is selected', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent
|
||||
.mockResolvedValueOnce('Content from workspace 1')
|
||||
.mockResolvedValueOnce('Content from workspace 2');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileContent('test.md'));
|
||||
|
||||
// Wait for initial load from workspace 1
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content from workspace 1');
|
||||
});
|
||||
|
||||
// Change workspace
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 2,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
// Should reload content from new workspace
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content from workspace 2');
|
||||
});
|
||||
|
||||
expect(mockGetFileContent).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test.md'
|
||||
);
|
||||
expect(mockGetFileContent).toHaveBeenCalledWith(
|
||||
'different-workspace',
|
||||
'test.md'
|
||||
);
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('clears content when workspace becomes null', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
mockGetFileContent.mockResolvedValue('Initial content');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileContent('test.md'));
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Initial content');
|
||||
});
|
||||
|
||||
expect(mockGetFileContent).toHaveBeenCalledTimes(1);
|
||||
vi.clearAllMocks(); // Clear previous calls
|
||||
|
||||
// Remove workspace
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
rerender();
|
||||
|
||||
// Content should remain the same (no clearing happens when workspace becomes null)
|
||||
// The hook keeps the current content and just prevents new loads
|
||||
expect(result.current.content).toBe('Initial content');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(mockGetFileContent).not.toHaveBeenCalled(); // No new API calls
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty string selectedFile', () => {
|
||||
const { result } = renderHook(() => useFileContent(''));
|
||||
|
||||
// Empty string should not trigger file loading
|
||||
expect(result.current.content).toBe(DEFAULT_FILE.content);
|
||||
expect(fileApi.getFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles rapid file changes', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
// Set up different responses for each file
|
||||
mockGetFileContent
|
||||
.mockImplementationOnce(() => Promise.resolve('Content 1'))
|
||||
.mockImplementationOnce(() => Promise.resolve('Content 2'))
|
||||
.mockImplementationOnce(() => Promise.resolve('Content 3'));
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedFile }) => useFileContent(selectedFile),
|
||||
{ initialProps: { selectedFile: 'file1.md' } }
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content 1');
|
||||
});
|
||||
|
||||
// Rapidly change files
|
||||
rerender({ selectedFile: 'file2.md' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content 2');
|
||||
});
|
||||
|
||||
rerender({ selectedFile: 'file3.md' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content 3');
|
||||
});
|
||||
|
||||
expect(mockGetFileContent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function stability', () => {
|
||||
it('maintains stable function references across re-renders and workspace changes', async () => {
|
||||
const mockGetFileContent = vi.mocked(fileApi.getFileContent);
|
||||
const mockIsImageFile = vi.mocked(fileHelpers.isImageFile);
|
||||
|
||||
// Mock API calls for both workspaces
|
||||
mockGetFileContent
|
||||
.mockResolvedValueOnce('Content from workspace 1')
|
||||
.mockResolvedValueOnce('Content from workspace 2');
|
||||
mockIsImageFile.mockReturnValue(false);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileContent('test.md'));
|
||||
|
||||
// Wait for initial load to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content from workspace 1');
|
||||
});
|
||||
|
||||
const initialFunctions = {
|
||||
setContent: result.current.setContent,
|
||||
setHasUnsavedChanges: result.current.setHasUnsavedChanges,
|
||||
loadFileContent: result.current.loadFileContent,
|
||||
handleContentChange: result.current.handleContentChange,
|
||||
};
|
||||
|
||||
// Re-render with different file
|
||||
rerender();
|
||||
|
||||
expect(result.current.setContent).toBe(initialFunctions.setContent);
|
||||
expect(result.current.setHasUnsavedChanges).toBe(
|
||||
initialFunctions.setHasUnsavedChanges
|
||||
);
|
||||
expect(result.current.loadFileContent).toBe(
|
||||
initialFunctions.loadFileContent
|
||||
);
|
||||
expect(result.current.handleContentChange).toBe(
|
||||
initialFunctions.handleContentChange
|
||||
);
|
||||
|
||||
// Change workspace
|
||||
act(() => {
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 2,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
// Wait for content to load from new workspace
|
||||
await waitFor(() => {
|
||||
expect(result.current.content).toBe('Content from workspace 2');
|
||||
});
|
||||
|
||||
// Functions should still be stable (except handleContentChange which depends on originalContent)
|
||||
expect(result.current.setContent).toBe(initialFunctions.setContent);
|
||||
expect(result.current.setHasUnsavedChanges).toBe(
|
||||
initialFunctions.setHasUnsavedChanges
|
||||
);
|
||||
expect(result.current.loadFileContent).not.toBe(
|
||||
initialFunctions.loadFileContent
|
||||
);
|
||||
// handleContentChange depends on originalContent which changes when workspace changes
|
||||
expect(result.current.handleContentChange).not.toBe(
|
||||
initialFunctions.handleContentChange
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
499
app/src/hooks/useFileList.test.ts
Normal file
499
app/src/hooks/useFileList.test.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useFileList } from './useFileList';
|
||||
import * as fileApi from '@/api/file';
|
||||
import type { FileNode } from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/file');
|
||||
|
||||
// Mock workspace context
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: { id: number; name: string } | null;
|
||||
loading: boolean;
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
},
|
||||
loading: false,
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
// Mock file data
|
||||
const mockFiles: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'README.md',
|
||||
path: 'README.md',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'docs',
|
||||
path: 'docs',
|
||||
children: [
|
||||
{
|
||||
id: '3',
|
||||
name: 'guide.md',
|
||||
path: 'docs/guide.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'notes.md',
|
||||
path: 'notes.md',
|
||||
},
|
||||
];
|
||||
|
||||
describe('useFileList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data to defaults
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
};
|
||||
mockWorkspaceData.loading = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with empty files array', () => {
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(typeof result.current.loadFileList).toBe('function');
|
||||
});
|
||||
|
||||
it('provides loadFileList function', () => {
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
expect(typeof result.current.loadFileList).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadFileList', () => {
|
||||
it('loads files successfully', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(mockFiles);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
expect(mockListFiles).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
||||
it('handles empty file list', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(mockListFiles).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockListFiles.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load file list:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not load when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(fileApi.listFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not load when workspace is loading', async () => {
|
||||
mockWorkspaceData.loading = true;
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(fileApi.listFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can be called multiple times', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles
|
||||
.mockResolvedValueOnce(mockFiles[0] ? [mockFiles[0]] : [])
|
||||
.mockResolvedValueOnce(mockFiles);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
// First call
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([mockFiles[0]]);
|
||||
|
||||
// Second call
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
expect(mockListFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles concurrent calls gracefully', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(mockFiles);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
// Make multiple concurrent calls
|
||||
await Promise.all([
|
||||
result.current.loadFileList(),
|
||||
result.current.loadFileList(),
|
||||
result.current.loadFileList(),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
expect(mockListFiles).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace dependency', () => {
|
||||
it('uses correct workspace name for API calls', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(mockFiles);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileList());
|
||||
|
||||
// Load with initial workspace
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(mockListFiles).toHaveBeenCalledWith('test-workspace');
|
||||
|
||||
// Change workspace
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 2,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(mockListFiles).toHaveBeenCalledWith('different-workspace');
|
||||
});
|
||||
|
||||
it('handles workspace becoming null after successful load', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(mockFiles);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileList());
|
||||
|
||||
// Load files with workspace
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
|
||||
// Remove workspace
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
rerender();
|
||||
|
||||
// Try to load again
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
// Files should remain from previous load, but no new API call
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
expect(mockListFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles workspace loading state changes', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(mockFiles);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileList());
|
||||
|
||||
// Start with loading workspace
|
||||
mockWorkspaceData.loading = true;
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(mockListFiles).not.toHaveBeenCalled();
|
||||
|
||||
// Workspace finishes loading
|
||||
mockWorkspaceData.loading = false;
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
expect(mockListFiles).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file data handling', () => {
|
||||
it('handles complex file tree structure', async () => {
|
||||
const complexFiles: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'root.md',
|
||||
path: 'root.md',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'folder1',
|
||||
path: 'folder1',
|
||||
children: [
|
||||
{
|
||||
id: '3',
|
||||
name: 'subfolder',
|
||||
path: 'folder1/subfolder',
|
||||
children: [
|
||||
{
|
||||
id: '4',
|
||||
name: 'deep.md',
|
||||
path: 'folder1/subfolder/deep.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'file1.md',
|
||||
path: 'folder1/file1.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(complexFiles);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(complexFiles);
|
||||
});
|
||||
|
||||
it('handles large file lists efficiently', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
|
||||
// Create a large file list
|
||||
const largeFileList: FileNode[] = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => ({
|
||||
id: `file-${i}`,
|
||||
name: `file-${i}.md`,
|
||||
path: `folder/file-${i}.md`,
|
||||
})
|
||||
);
|
||||
|
||||
mockListFiles.mockResolvedValue(largeFileList);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(largeFileList);
|
||||
expect(result.current.files).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('handles files with special characters', async () => {
|
||||
const specialFiles: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'file with spaces.md',
|
||||
path: 'file with spaces.md',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'special-chars_123.md',
|
||||
path: 'special-chars_123.md',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'unicode-文档.md',
|
||||
path: 'unicode-文档.md',
|
||||
},
|
||||
];
|
||||
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(specialFiles);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(specialFiles);
|
||||
});
|
||||
|
||||
it('handles files without children property', async () => {
|
||||
const filesWithoutChildren: FileNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'simple.md',
|
||||
path: 'simple.md',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'another.md',
|
||||
path: 'another.md',
|
||||
},
|
||||
];
|
||||
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
mockListFiles.mockResolvedValue(filesWithoutChildren);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(filesWithoutChildren);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface stability', () => {
|
||||
it('loadFileList function is stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useFileList());
|
||||
|
||||
const initialLoadFunction = result.current.loadFileList;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.loadFileList).toBe(initialLoadFunction);
|
||||
});
|
||||
|
||||
it('returns consistent interface', () => {
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
expect(Array.isArray(result.current.files)).toBe(true);
|
||||
expect(typeof result.current.loadFileList).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error recovery', () => {
|
||||
it('recovers from API errors on subsequent calls', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// First call fails
|
||||
mockListFiles.mockRejectedValueOnce(new Error('First error'));
|
||||
// Second call succeeds
|
||||
mockListFiles.mockResolvedValueOnce(mockFiles);
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
// First call - should fail
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
|
||||
// Second call - should succeed
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('maintains previous data after error', async () => {
|
||||
const mockListFiles = vi.mocked(fileApi.listFiles);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// First call succeeds
|
||||
mockListFiles.mockResolvedValueOnce(mockFiles);
|
||||
// Second call fails
|
||||
mockListFiles.mockRejectedValueOnce(new Error('Second error'));
|
||||
|
||||
const { result } = renderHook(() => useFileList());
|
||||
|
||||
// First call - should succeed
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual(mockFiles);
|
||||
|
||||
// Second call - should fail but maintain previous data
|
||||
await act(async () => {
|
||||
await result.current.loadFileList();
|
||||
});
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
472
app/src/hooks/useFileNavigation.test.ts
Normal file
472
app/src/hooks/useFileNavigation.test.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useFileNavigation } from './useFileNavigation';
|
||||
import { DEFAULT_FILE } from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: { id: number; name: string } | null;
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
},
|
||||
};
|
||||
|
||||
const mockLastOpenedFile = {
|
||||
loadLastOpenedFile: vi.fn(),
|
||||
saveLastOpenedFile: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
vi.mock('./useLastOpenedFile', () => ({
|
||||
useLastOpenedFile: () => mockLastOpenedFile,
|
||||
}));
|
||||
|
||||
describe('useFileNavigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data to defaults
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with default file selected', () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
expect(typeof result.current.handleFileSelect).toBe('function');
|
||||
});
|
||||
|
||||
it('loads last opened file on mount when available', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(
|
||||
'documents/readme.md'
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('documents/readme.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays with default file when no last opened file exists', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleFileSelect', () => {
|
||||
it('selects a regular file correctly', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('notes/todo.md');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('notes/todo.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
|
||||
'notes/todo.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles null file selection (defaults to default file)', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect(null);
|
||||
});
|
||||
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty string file selection with default file', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('');
|
||||
});
|
||||
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves current selection when passed empty string with existing selection', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
// First select a valid file
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('existing-file.md');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('existing-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Now send empty string
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('');
|
||||
});
|
||||
|
||||
// Selection should be preserved
|
||||
expect(result.current.selectedFile).toBe('existing-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles different file path formats', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
const testCases = [
|
||||
'simple.md',
|
||||
'folder/file.md',
|
||||
'deep/nested/path/document.md',
|
||||
'file with spaces.md',
|
||||
'special-chars_123.md',
|
||||
'unicode-文档.md',
|
||||
];
|
||||
|
||||
for (const filePath of testCases) {
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect(filePath);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(filePath);
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledTimes(
|
||||
testCases.length
|
||||
);
|
||||
});
|
||||
|
||||
it('handles rapid file selections', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
const files = ['file1.md', 'file2.md', 'file3.md'];
|
||||
|
||||
// Use sequential state updates instead of Promise.all for more predictable results
|
||||
for (const file of files) {
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect(file);
|
||||
});
|
||||
}
|
||||
|
||||
// After all updates, we should have the last file selected
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(files[files.length - 1]);
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledTimes(
|
||||
files.length
|
||||
);
|
||||
});
|
||||
|
||||
it('handles file selection errors gracefully', async () => {
|
||||
mockLastOpenedFile.saveLastOpenedFile.mockRejectedValue(
|
||||
new Error('Save failed')
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Should not throw
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('error-file.md');
|
||||
});
|
||||
|
||||
// Wait for state update despite the error
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('error-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace changes', () => {
|
||||
it('reinitializes when workspace changes', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile
|
||||
.mockResolvedValueOnce('workspace1-file.md')
|
||||
.mockResolvedValueOnce('workspace2-file.md');
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('workspace1-file.md');
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change workspace
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 2,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
// Should reinitialize with new workspace
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('workspace2-file.md');
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles workspace becoming null', async () => {
|
||||
const { result, rerender } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Start with workspace
|
||||
await waitFor(() => {
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Remove workspace
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
rerender();
|
||||
|
||||
// Should still work but with default behavior
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
});
|
||||
|
||||
it('handles workspace reappearing', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(
|
||||
'restored-file.md'
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Start with no workspace
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
rerender();
|
||||
|
||||
// Add workspace back
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'restored-workspace',
|
||||
};
|
||||
rerender();
|
||||
|
||||
// Should reinitialize
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('restored-file.md');
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization scenarios', () => {
|
||||
it('handles loadLastOpenedFile returning empty string', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue('');
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles loadLastOpenedFile errors', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile.mockRejectedValue(
|
||||
new Error('Load failed')
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Should fallback to default file
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles successful load followed by handleFileSelect', async () => {
|
||||
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(
|
||||
'initial-file.md'
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('initial-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
// Then select a different file
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('different-file.md');
|
||||
});
|
||||
|
||||
expect(result.current.selectedFile).toBe('different-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
|
||||
'different-file.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state consistency', () => {
|
||||
it('maintains correct isNewFile state for default file', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
// Initially should be new file
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
|
||||
// Select a real file
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('real-file.md');
|
||||
});
|
||||
|
||||
// Wait for state to update
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('real-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
|
||||
// Go back to null (should default to default file)
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect(null);
|
||||
});
|
||||
|
||||
// Wait for state to update again
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
|
||||
expect(result.current.isNewFile).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains correct isNewFile state for regular files', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
const testFiles = ['file1.md', 'file2.md', 'folder/file3.md'];
|
||||
|
||||
for (const file of testFiles) {
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect(file);
|
||||
});
|
||||
|
||||
// Wait for each file selection to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe(file);
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface stability', () => {
|
||||
it('handleFileSelect function is stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useFileNavigation());
|
||||
|
||||
const initialHandler = result.current.handleFileSelect;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.handleFileSelect).toBe(initialHandler);
|
||||
});
|
||||
|
||||
it('returns consistent interface', () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
expect(typeof result.current.selectedFile).toBe('string');
|
||||
expect(typeof result.current.isNewFile).toBe('boolean');
|
||||
expect(typeof result.current.handleFileSelect).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with useLastOpenedFile', () => {
|
||||
it('calls loadLastOpenedFile on mount', async () => {
|
||||
renderHook(() => useFileNavigation());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls saveLastOpenedFile when selecting files', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('test-file.md');
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
|
||||
'test-file.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call saveLastOpenedFile for null selections', async () => {
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect(null);
|
||||
});
|
||||
|
||||
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles saveLastOpenedFile errors without affecting state', async () => {
|
||||
mockLastOpenedFile.saveLastOpenedFile.mockRejectedValue(
|
||||
new Error('Save error')
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useFileNavigation());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileSelect('test-file.md');
|
||||
});
|
||||
|
||||
// State should still be updated despite save error
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedFile).toBe('test-file.md');
|
||||
expect(result.current.isNewFile).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,35 +17,55 @@ export const useFileNavigation = (): UseFileNavigationResult => {
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (filePath: string | null): Promise<void> => {
|
||||
const newPath = filePath || DEFAULT_FILE.path;
|
||||
setSelectedFile(newPath);
|
||||
setIsNewFile(!filePath);
|
||||
// Consider empty string as null
|
||||
const effectiveFilePath = filePath === '' ? null : filePath;
|
||||
|
||||
if (filePath) {
|
||||
await saveLastOpenedFile(filePath);
|
||||
if (effectiveFilePath) {
|
||||
setSelectedFile(effectiveFilePath);
|
||||
setIsNewFile(false);
|
||||
|
||||
try {
|
||||
// Try to save the last opened file
|
||||
await saveLastOpenedFile(effectiveFilePath);
|
||||
} catch (err) {
|
||||
// Silently handle the error so state still updates
|
||||
console.error('Failed to save last opened file:', err);
|
||||
}
|
||||
} else if (selectedFile === DEFAULT_FILE.path || filePath === null) {
|
||||
setSelectedFile(DEFAULT_FILE.path);
|
||||
setIsNewFile(true);
|
||||
}
|
||||
},
|
||||
[saveLastOpenedFile]
|
||||
[saveLastOpenedFile, selectedFile]
|
||||
);
|
||||
|
||||
// Load last opened file when workspace changes
|
||||
useEffect(() => {
|
||||
const initializeFile = async (): Promise<void> => {
|
||||
setSelectedFile(DEFAULT_FILE.path);
|
||||
setIsNewFile(true);
|
||||
try {
|
||||
setSelectedFile(DEFAULT_FILE.path);
|
||||
setIsNewFile(true);
|
||||
|
||||
const lastFile = await loadLastOpenedFile();
|
||||
if (lastFile) {
|
||||
await handleFileSelect(lastFile);
|
||||
} else {
|
||||
await handleFileSelect(null);
|
||||
const lastFile = await loadLastOpenedFile();
|
||||
|
||||
if (lastFile) {
|
||||
setSelectedFile(lastFile);
|
||||
setIsNewFile(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load last opened file:', err);
|
||||
setSelectedFile(DEFAULT_FILE.path);
|
||||
setIsNewFile(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentWorkspace) {
|
||||
void initializeFile();
|
||||
} else {
|
||||
setSelectedFile(DEFAULT_FILE.path);
|
||||
setIsNewFile(true);
|
||||
}
|
||||
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
|
||||
}, [currentWorkspace, loadLastOpenedFile, saveLastOpenedFile]);
|
||||
|
||||
return { selectedFile, isNewFile, handleFileSelect };
|
||||
};
|
||||
|
||||
537
app/src/hooks/useFileOperations.test.ts
Normal file
537
app/src/hooks/useFileOperations.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useFileOperations } from './useFileOperations';
|
||||
import * as fileApi from '@/api/file';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/file');
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the workspace context and git operations
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: { id: number; name: string } | null;
|
||||
settings: {
|
||||
gitAutoCommit: boolean;
|
||||
gitEnabled: boolean;
|
||||
gitCommitMsgTemplate: string;
|
||||
};
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
},
|
||||
settings: {
|
||||
gitAutoCommit: false,
|
||||
gitEnabled: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
},
|
||||
};
|
||||
|
||||
const mockGitOperations = {
|
||||
handleCommitAndPush: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
vi.mock('./useGitOperations', () => ({
|
||||
useGitOperations: () => mockGitOperations,
|
||||
}));
|
||||
|
||||
// Import notifications for assertions
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
describe('useFileOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data to defaults
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
};
|
||||
mockWorkspaceData.settings = {
|
||||
gitAutoCommit: false,
|
||||
gitEnabled: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleSave', () => {
|
||||
it('saves file successfully and shows success notification', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let saveResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.handleSave(
|
||||
'test.md',
|
||||
'# Test Content'
|
||||
);
|
||||
});
|
||||
|
||||
expect(saveResult).toBe(true);
|
||||
expect(mockSaveFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test.md',
|
||||
'# Test Content'
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'File saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles save errors and shows error notification', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockSaveFile.mockRejectedValue(new Error('Save failed'));
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let saveResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.handleSave(
|
||||
'test.md',
|
||||
'# Test Content'
|
||||
);
|
||||
});
|
||||
|
||||
expect(saveResult).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error saving file:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to save file',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let saveResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.handleSave(
|
||||
'test.md',
|
||||
'# Test Content'
|
||||
);
|
||||
});
|
||||
|
||||
expect(saveResult).toBe(false);
|
||||
expect(fileApi.saveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers auto-commit when enabled', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Enable auto-commit
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave('test.md', '# Test Content');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Update test.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses custom commit message template', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'docs/readme.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Enable auto-commit with custom template
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
mockWorkspaceData.settings.gitCommitMsgTemplate =
|
||||
'Modified ${filename} - ${action}';
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave('docs/readme.md', '# Documentation');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Modified docs/readme.md - update'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDelete', () => {
|
||||
it('deletes file successfully and shows success notification', async () => {
|
||||
const mockDeleteFile = vi.mocked(fileApi.deleteFile);
|
||||
mockDeleteFile.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.handleDelete('test.md');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(true);
|
||||
expect(mockDeleteFile).toHaveBeenCalledWith('test-workspace', 'test.md');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'File deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete errors and shows error notification', async () => {
|
||||
const mockDeleteFile = vi.mocked(fileApi.deleteFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockDeleteFile.mockRejectedValue(new Error('Delete failed'));
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.handleDelete('test.md');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error deleting file:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete file',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.handleDelete('test.md');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(fileApi.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers auto-commit when enabled', async () => {
|
||||
const mockDeleteFile = vi.mocked(fileApi.deleteFile);
|
||||
mockDeleteFile.mockResolvedValue(undefined);
|
||||
|
||||
// Enable auto-commit
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete('old-file.md');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Delete old-file.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCreate', () => {
|
||||
it('creates file successfully with default content', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'new.md',
|
||||
size: 0,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.handleCreate('new.md');
|
||||
});
|
||||
|
||||
expect(createResult).toBe(true);
|
||||
expect(mockSaveFile).toHaveBeenCalledWith('test-workspace', 'new.md', '');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'File created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates file with custom initial content', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'template.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.handleCreate(
|
||||
'template.md',
|
||||
'# Template\n\nContent here'
|
||||
);
|
||||
});
|
||||
|
||||
expect(createResult).toBe(true);
|
||||
expect(mockSaveFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'template.md',
|
||||
'# Template\n\nContent here'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles create errors and shows error notification', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockSaveFile.mockRejectedValue(new Error('Create failed'));
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.handleCreate('new.md');
|
||||
});
|
||||
|
||||
expect(createResult).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error creating new file:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to create new file',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.handleCreate('new.md');
|
||||
});
|
||||
|
||||
expect(createResult).toBe(false);
|
||||
expect(fileApi.saveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers auto-commit when enabled', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'new-file.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Enable auto-commit
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate('new-file.md', 'Initial content');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Create new-file.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-commit behavior', () => {
|
||||
it('does not auto-commit when git is disabled', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Enable auto-commit but disable git
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = false;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave('test.md', 'content');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not auto-commit when auto-commit is disabled', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Enable git but disable auto-commit
|
||||
mockWorkspaceData.settings.gitAutoCommit = false;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave('test.md', 'content');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('capitalizes commit messages correctly', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// Enable auto-commit with lowercase template
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
mockWorkspaceData.settings.gitCommitMsgTemplate = 'updated ${filename}';
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave('test.md', 'content');
|
||||
});
|
||||
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Updated test.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles different file actions correctly', async () => {
|
||||
const mockSaveFile = vi.mocked(fileApi.saveFile);
|
||||
const mockDeleteFile = vi.mocked(fileApi.deleteFile);
|
||||
|
||||
mockSaveFile.mockResolvedValue({
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
mockDeleteFile.mockResolvedValue(undefined);
|
||||
|
||||
// Enable auto-commit
|
||||
mockWorkspaceData.settings.gitAutoCommit = true;
|
||||
mockWorkspaceData.settings.gitEnabled = true;
|
||||
mockWorkspaceData.settings.gitCommitMsgTemplate =
|
||||
'${action}: ${filename}';
|
||||
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
// Test create action
|
||||
await act(async () => {
|
||||
await result.current.handleCreate('new.md');
|
||||
});
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Create: new.md'
|
||||
);
|
||||
|
||||
// Test update action
|
||||
await act(async () => {
|
||||
await result.current.handleSave('existing.md', 'content');
|
||||
});
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Update: existing.md'
|
||||
);
|
||||
|
||||
// Test delete action
|
||||
await act(async () => {
|
||||
await result.current.handleDelete('old.md');
|
||||
});
|
||||
expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith(
|
||||
'Delete: old.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface', () => {
|
||||
it('returns correct function interface', () => {
|
||||
const { result } = renderHook(() => useFileOperations());
|
||||
|
||||
expect(typeof result.current.handleSave).toBe('function');
|
||||
expect(typeof result.current.handleDelete).toBe('function');
|
||||
expect(typeof result.current.handleCreate).toBe('function');
|
||||
});
|
||||
|
||||
it('functions are stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useFileOperations());
|
||||
|
||||
const initialHandlers = {
|
||||
handleSave: result.current.handleSave,
|
||||
handleDelete: result.current.handleDelete,
|
||||
handleCreate: result.current.handleCreate,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.handleSave).toBe(initialHandlers.handleSave);
|
||||
expect(result.current.handleDelete).toBe(initialHandlers.handleDelete);
|
||||
expect(result.current.handleCreate).toBe(initialHandlers.handleCreate);
|
||||
});
|
||||
});
|
||||
});
|
||||
413
app/src/hooks/useGitOperations.test.ts
Normal file
413
app/src/hooks/useGitOperations.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useGitOperations } from './useGitOperations';
|
||||
import * as gitApi from '@/api/git';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/git');
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the workspace context
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: { id: number; name: string } | null;
|
||||
settings: { gitEnabled: boolean };
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
},
|
||||
settings: {
|
||||
gitEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
// Import notifications for assertions
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
describe('useGitOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data to defaults
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
};
|
||||
mockWorkspaceData.settings = {
|
||||
gitEnabled: true,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handlePull', () => {
|
||||
it('pulls changes successfully and shows success notification', async () => {
|
||||
const mockPullChanges = vi.mocked(gitApi.pullChanges);
|
||||
mockPullChanges.mockResolvedValue('Successfully pulled latest changes');
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(true);
|
||||
expect(mockPullChanges).toHaveBeenCalledWith('test-workspace');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Successfully pulled latest changes',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles pull errors and shows error notification', async () => {
|
||||
const mockPullChanges = vi.mocked(gitApi.pullChanges);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockPullChanges.mockRejectedValue(new Error('Pull failed'));
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to pull latest changes:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to pull latest changes',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(gitApi.pullChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when git is disabled', async () => {
|
||||
mockWorkspaceData.settings.gitEnabled = false;
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(gitApi.pullChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles pull with different response messages', async () => {
|
||||
const mockPullChanges = vi.mocked(gitApi.pullChanges);
|
||||
mockPullChanges.mockResolvedValue('Already up to date');
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Already up to date',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCommitAndPush', () => {
|
||||
it('commits and pushes successfully with commit hash', async () => {
|
||||
const mockCommitAndPush = vi.mocked(gitApi.commitAndPush);
|
||||
mockCommitAndPush.mockResolvedValue('abc123def456');
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush('Add new feature');
|
||||
});
|
||||
|
||||
expect(mockCommitAndPush).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'Add new feature'
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Successfully committed and pushed changes abc123def456',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles commit errors and shows error notification', async () => {
|
||||
const mockCommitAndPush = vi.mocked(gitApi.commitAndPush);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockCommitAndPush.mockRejectedValue(new Error('Commit failed'));
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush('Failed commit');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to commit and push changes:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to commit and push changes',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush('Test commit');
|
||||
});
|
||||
|
||||
expect(gitApi.commitAndPush).not.toHaveBeenCalled();
|
||||
expect(notifications.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when git is disabled', async () => {
|
||||
mockWorkspaceData.settings.gitEnabled = false;
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush('Test commit');
|
||||
});
|
||||
|
||||
expect(gitApi.commitAndPush).not.toHaveBeenCalled();
|
||||
expect(notifications.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty commit messages', async () => {
|
||||
const mockCommitAndPush = vi.mocked(gitApi.commitAndPush);
|
||||
mockCommitAndPush.mockResolvedValue('xyz789abc123');
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush('');
|
||||
});
|
||||
|
||||
expect(mockCommitAndPush).toHaveBeenCalledWith('test-workspace', '');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Successfully committed and pushed changes xyz789abc123',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles long commit messages', async () => {
|
||||
const mockCommitAndPush = vi.mocked(gitApi.commitAndPush);
|
||||
mockCommitAndPush.mockResolvedValue('longcommithash123456789');
|
||||
|
||||
const longMessage =
|
||||
'This is a very long commit message that describes in detail all the changes that were made to the codebase including bug fixes, new features, and documentation updates';
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush(longMessage);
|
||||
});
|
||||
|
||||
expect(mockCommitAndPush).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
longMessage
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message:
|
||||
'Successfully committed and pushed changes longcommithash123456789',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles commit with special characters in message', async () => {
|
||||
const mockCommitAndPush = vi.mocked(gitApi.commitAndPush);
|
||||
mockCommitAndPush.mockResolvedValue('special123hash');
|
||||
|
||||
const specialMessage =
|
||||
'Fix: update file with special chars àáâãäå & symbols!@#$%';
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitAndPush(specialMessage);
|
||||
});
|
||||
|
||||
expect(mockCommitAndPush).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
specialMessage
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Successfully committed and pushed changes special123hash',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace and settings dependencies', () => {
|
||||
it('handles workspace changes correctly', async () => {
|
||||
const mockPullChanges = vi.mocked(gitApi.pullChanges);
|
||||
mockPullChanges.mockResolvedValue('Success');
|
||||
|
||||
const { result, rerender } = renderHook(() => useGitOperations());
|
||||
|
||||
// Test with initial workspace
|
||||
await act(async () => {
|
||||
await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(mockPullChanges).toHaveBeenCalledWith('test-workspace');
|
||||
|
||||
// Change workspace
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 2,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(mockPullChanges).toHaveBeenCalledWith('different-workspace');
|
||||
});
|
||||
|
||||
it('handles git settings changes correctly', async () => {
|
||||
const { result, rerender } = renderHook(() => useGitOperations());
|
||||
|
||||
// Initially git is enabled
|
||||
expect(mockWorkspaceData.settings.gitEnabled).toBe(true);
|
||||
|
||||
// Disable git
|
||||
mockWorkspaceData.settings.gitEnabled = false;
|
||||
rerender();
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(gitApi.pullChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface', () => {
|
||||
it('returns correct function interface', () => {
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
expect(typeof result.current.handlePull).toBe('function');
|
||||
expect(typeof result.current.handleCommitAndPush).toBe('function');
|
||||
});
|
||||
|
||||
it('functions are stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useGitOperations());
|
||||
|
||||
const initialHandlers = {
|
||||
handlePull: result.current.handlePull,
|
||||
handleCommitAndPush: result.current.handleCommitAndPush,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.handlePull).toBe(initialHandlers.handlePull);
|
||||
expect(result.current.handleCommitAndPush).toBe(
|
||||
initialHandlers.handleCommitAndPush
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles null workspace gracefully', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(gitApi.pullChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles undefined workspace name gracefully', async () => {
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: undefined!,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(gitApi.pullChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles missing settings gracefully', async () => {
|
||||
mockWorkspaceData.settings = {
|
||||
gitEnabled: undefined!,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useGitOperations());
|
||||
|
||||
let pullResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
pullResult = await result.current.handlePull();
|
||||
});
|
||||
|
||||
expect(pullResult).toBe(false);
|
||||
expect(gitApi.pullChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,14 @@ export const useGitOperations = (): UseGitOperationsResult => {
|
||||
const { currentWorkspace, settings } = useWorkspaceData();
|
||||
|
||||
const handlePull = useCallback(async (): Promise<boolean> => {
|
||||
if (!currentWorkspace || !settings.gitEnabled) return false;
|
||||
if (!currentWorkspace || !settings.gitEnabled || !currentWorkspace.name)
|
||||
return false;
|
||||
|
||||
try {
|
||||
await pullChanges(currentWorkspace.name);
|
||||
const message = await pullChanges(currentWorkspace.name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Successfully pulled latest changes',
|
||||
message: message || 'Successfully pulled latest changes',
|
||||
color: 'green',
|
||||
});
|
||||
return true;
|
||||
@@ -37,11 +38,12 @@ export const useGitOperations = (): UseGitOperationsResult => {
|
||||
const handleCommitAndPush = useCallback(
|
||||
async (message: string): Promise<void> => {
|
||||
if (!currentWorkspace || !settings.gitEnabled) return;
|
||||
const commitHash: CommitHash = await commitAndPush(
|
||||
currentWorkspace.name,
|
||||
message
|
||||
);
|
||||
|
||||
try {
|
||||
const commitHash: CommitHash = await commitAndPush(
|
||||
currentWorkspace.name,
|
||||
message
|
||||
);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Successfully committed and pushed changes ' + commitHash,
|
||||
|
||||
376
app/src/hooks/useLastOpenedFile.test.ts
Normal file
376
app/src/hooks/useLastOpenedFile.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useLastOpenedFile } from './useLastOpenedFile';
|
||||
import * as fileApi from '@/api/file';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/file');
|
||||
|
||||
// Mock the workspace context
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: { id: number; name: string } | null;
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
describe('useLastOpenedFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data to defaults
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
name: 'test-workspace',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('loadLastOpenedFile', () => {
|
||||
it('loads last opened file successfully', async () => {
|
||||
const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile);
|
||||
mockGetLastOpenedFile.mockResolvedValue('documents/readme.md');
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
});
|
||||
|
||||
expect(lastFile).toBe('documents/readme.md');
|
||||
expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
||||
it('returns null for empty response', async () => {
|
||||
const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile);
|
||||
mockGetLastOpenedFile.mockResolvedValue('');
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
});
|
||||
|
||||
expect(lastFile).toBeNull();
|
||||
expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockGetLastOpenedFile.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
});
|
||||
|
||||
expect(lastFile).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load last opened file:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns null when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
});
|
||||
|
||||
expect(lastFile).toBeNull();
|
||||
expect(fileApi.getLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles different file path formats', async () => {
|
||||
const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile);
|
||||
|
||||
// Test various file path formats
|
||||
const testCases = [
|
||||
'simple.md',
|
||||
'folder/file.md',
|
||||
'deep/nested/path/document.md',
|
||||
'file with spaces.md',
|
||||
'special-chars_123.md',
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
for (const testPath of testCases) {
|
||||
mockGetLastOpenedFile.mockResolvedValueOnce(testPath);
|
||||
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
});
|
||||
|
||||
expect(lastFile).toBe(testPath);
|
||||
}
|
||||
|
||||
expect(mockGetLastOpenedFile).toHaveBeenCalledTimes(testCases.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveLastOpenedFile', () => {
|
||||
it('saves last opened file successfully', async () => {
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
mockUpdateLastOpenedFile.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveLastOpenedFile('notes/todo.md');
|
||||
});
|
||||
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'notes/todo.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockUpdateLastOpenedFile.mockRejectedValue(new Error('Save Error'));
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveLastOpenedFile('error.md');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to save last opened file:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when no workspace is available', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveLastOpenedFile('test.md');
|
||||
});
|
||||
|
||||
expect(fileApi.updateLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when file path is empty', async () => {
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
mockUpdateLastOpenedFile.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveLastOpenedFile('');
|
||||
});
|
||||
|
||||
expect(mockUpdateLastOpenedFile).not.toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('handles different file path formats', async () => {
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
mockUpdateLastOpenedFile.mockResolvedValue(undefined);
|
||||
|
||||
const testCases = [
|
||||
'simple.md',
|
||||
'folder/file.md',
|
||||
'deep/nested/path/document.md',
|
||||
'file with spaces.md',
|
||||
'special-chars_123.md',
|
||||
'unicode-文件.md',
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
for (const testPath of testCases) {
|
||||
await act(async () => {
|
||||
await result.current.saveLastOpenedFile(testPath);
|
||||
});
|
||||
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
testPath
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledTimes(testCases.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace dependency', () => {
|
||||
it('handles workspace changes correctly', async () => {
|
||||
const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile);
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
|
||||
mockGetLastOpenedFile.mockResolvedValue('file.md');
|
||||
mockUpdateLastOpenedFile.mockResolvedValue(undefined);
|
||||
|
||||
const { result, rerender } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
// Test with initial workspace
|
||||
await act(async () => {
|
||||
await result.current.loadLastOpenedFile();
|
||||
await result.current.saveLastOpenedFile('test.md');
|
||||
});
|
||||
|
||||
expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace');
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test.md'
|
||||
);
|
||||
|
||||
// Change workspace
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 2,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadLastOpenedFile();
|
||||
await result.current.saveLastOpenedFile('other.md');
|
||||
});
|
||||
|
||||
expect(mockGetLastOpenedFile).toHaveBeenCalledWith('different-workspace');
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith(
|
||||
'different-workspace',
|
||||
'other.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles workspace becoming null', async () => {
|
||||
const { result, rerender } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
// Start with workspace
|
||||
expect(mockWorkspaceData.currentWorkspace).not.toBeNull();
|
||||
|
||||
// Remove workspace
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
rerender();
|
||||
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
await result.current.saveLastOpenedFile('test.md');
|
||||
});
|
||||
|
||||
expect(lastFile).toBeNull();
|
||||
expect(fileApi.getLastOpenedFile).not.toHaveBeenCalled();
|
||||
expect(fileApi.updateLastOpenedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface', () => {
|
||||
it('returns correct function interface', () => {
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
expect(typeof result.current.loadLastOpenedFile).toBe('function');
|
||||
expect(typeof result.current.saveLastOpenedFile).toBe('function');
|
||||
});
|
||||
|
||||
it('functions are stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
const initialHandlers = {
|
||||
loadLastOpenedFile: result.current.loadLastOpenedFile,
|
||||
saveLastOpenedFile: result.current.saveLastOpenedFile,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.loadLastOpenedFile).toBe(
|
||||
initialHandlers.loadLastOpenedFile
|
||||
);
|
||||
expect(result.current.saveLastOpenedFile).toBe(
|
||||
initialHandlers.saveLastOpenedFile
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('handles load after save', async () => {
|
||||
const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile);
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
|
||||
mockUpdateLastOpenedFile.mockResolvedValue(undefined);
|
||||
mockGetLastOpenedFile.mockResolvedValue('saved-file.md');
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
// Save a file
|
||||
await act(async () => {
|
||||
await result.current.saveLastOpenedFile('saved-file.md');
|
||||
});
|
||||
|
||||
// Load the last opened file
|
||||
let lastFile: string | null = '';
|
||||
await act(async () => {
|
||||
lastFile = await result.current.loadLastOpenedFile();
|
||||
});
|
||||
|
||||
expect(lastFile).toBe('saved-file.md');
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'saved-file.md'
|
||||
);
|
||||
expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
||||
it('handles multiple rapid saves', async () => {
|
||||
const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile);
|
||||
mockUpdateLastOpenedFile.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useLastOpenedFile());
|
||||
|
||||
const filePaths = ['file1.md', 'file2.md', 'file3.md'];
|
||||
|
||||
// Rapidly save multiple files
|
||||
await act(async () => {
|
||||
await Promise.all(
|
||||
filePaths.map((path) => result.current.saveLastOpenedFile(path))
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledTimes(3);
|
||||
filePaths.forEach((path) => {
|
||||
expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
path
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export const useLastOpenedFile = (): UseLastOpenedFileResult => {
|
||||
|
||||
const saveLastOpenedFile = useCallback(
|
||||
async (filePath: string): Promise<void> => {
|
||||
if (!currentWorkspace) return;
|
||||
if (!currentWorkspace || !filePath.trim()) return;
|
||||
|
||||
try {
|
||||
await updateLastOpenedFile(currentWorkspace.name, filePath);
|
||||
|
||||
500
app/src/hooks/useProfileSettings.test.ts
Normal file
500
app/src/hooks/useProfileSettings.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useProfileSettings } from './useProfileSettings';
|
||||
import * as userApi from '@/api/user';
|
||||
import type { UpdateProfileRequest } from '@/types/api';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/user');
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import notifications for assertions
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
// Mock user data
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
describe('useProfileSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('returns correct initial loading state', () => {
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(typeof result.current.updateProfile).toBe('function');
|
||||
expect(typeof result.current.deleteAccount).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('updates profile successfully', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
const updatedUser: User = {
|
||||
...mockUser,
|
||||
displayName: 'Updated Name',
|
||||
};
|
||||
mockUpdateProfile.mockResolvedValue(updatedUser);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
displayName: 'Updated Name',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toEqual(updatedUser);
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Profile updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('updates email successfully', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
const updatedUser: User = {
|
||||
...mockUser,
|
||||
email: 'newemail@example.com',
|
||||
};
|
||||
mockUpdateProfile.mockResolvedValue(updatedUser);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
email: 'newemail@example.com',
|
||||
currentPassword: 'current123',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toEqual(updatedUser);
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Profile updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates password successfully', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
currentPassword: 'oldpass123',
|
||||
newPassword: 'newpass456',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toEqual(mockUser);
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Profile updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates multiple fields successfully', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
const updatedUser: User = {
|
||||
...mockUser,
|
||||
displayName: 'New Display Name',
|
||||
email: 'updated@example.com',
|
||||
};
|
||||
mockUpdateProfile.mockResolvedValue(updatedUser);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
displayName: 'New Display Name',
|
||||
email: 'updated@example.com',
|
||||
currentPassword: 'current123',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toEqual(updatedUser);
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest);
|
||||
});
|
||||
|
||||
it('shows loading state during update', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
let resolveUpdate: (value: User) => void;
|
||||
const updatePromise = new Promise<User>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
mockUpdateProfile.mockReturnValue(updatePromise);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
// Start update
|
||||
act(() => {
|
||||
void result.current.updateProfile({ displayName: 'Test' });
|
||||
});
|
||||
|
||||
// Should be loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Resolve the promise
|
||||
await act(async () => {
|
||||
if (resolveUpdate) resolveUpdate(mockUser);
|
||||
await updatePromise;
|
||||
});
|
||||
|
||||
// Should no longer be loading
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('handles password errors specifically', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile.mockRejectedValue(
|
||||
new Error('Current password is incorrect')
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
currentPassword: 'wrongpass',
|
||||
newPassword: 'newpass123',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toBeNull();
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Current password is incorrect',
|
||||
color: 'red',
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('handles email errors specifically', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile.mockRejectedValue(new Error('email already exists'));
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
email: 'existing@example.com',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toBeNull();
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Email is already in use',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles generic update errors', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
const updateRequest: UpdateProfileRequest = {
|
||||
displayName: 'Test Name',
|
||||
};
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile(updateRequest);
|
||||
});
|
||||
|
||||
expect(returnedUser).toBeNull();
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to update profile',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-Error rejection', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile.mockRejectedValue('String error');
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile({
|
||||
displayName: 'Test',
|
||||
});
|
||||
});
|
||||
|
||||
expect(returnedUser).toBeNull();
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to update profile',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAccount', () => {
|
||||
it('deletes account successfully', async () => {
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
mockDeleteUser.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteAccount('password123');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(true);
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith('password123');
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Account deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('shows loading state during deletion', async () => {
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
let resolveDelete: () => void;
|
||||
const deletePromise = new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve;
|
||||
});
|
||||
mockDeleteUser.mockReturnValue(deletePromise);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
// Start deletion
|
||||
act(() => {
|
||||
void result.current.deleteAccount('password123');
|
||||
});
|
||||
|
||||
// Should be loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Resolve the promise
|
||||
await act(async () => {
|
||||
if (resolveDelete) resolveDelete();
|
||||
await deletePromise;
|
||||
});
|
||||
|
||||
// Should no longer be loading
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('handles delete errors with error message', async () => {
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
mockDeleteUser.mockRejectedValue(new Error('Invalid password'));
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteAccount('wrongpass');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Invalid password',
|
||||
color: 'red',
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('handles generic delete errors', async () => {
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
mockDeleteUser.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteAccount('password123');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Server error',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-Error rejection in delete', async () => {
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
mockDeleteUser.mockRejectedValue('String error');
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteAccount('password123');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete account',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty password', async () => {
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
mockDeleteUser.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteAccount('');
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(true);
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('handles concurrent profile updates', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile
|
||||
.mockResolvedValueOnce({ ...mockUser, displayName: 'Name 1' })
|
||||
.mockResolvedValueOnce({ ...mockUser, displayName: 'Name 2' });
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let results: (User | null)[] = [];
|
||||
await act(async () => {
|
||||
const promises = [
|
||||
result.current.updateProfile({ displayName: 'Name 1' }),
|
||||
result.current.updateProfile({ displayName: 'Name 2' }),
|
||||
];
|
||||
results = await Promise.all(promises);
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]?.displayName).toBe('Name 1');
|
||||
expect(results[1]?.displayName).toBe('Name 2');
|
||||
expect(mockUpdateProfile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles update followed by delete', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
const mockDeleteUser = vi.mocked(userApi.deleteUser);
|
||||
|
||||
mockUpdateProfile.mockResolvedValue(mockUser);
|
||||
mockDeleteUser.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let updateResult: User | null = null;
|
||||
let deleteResult: boolean | undefined;
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateProfile({
|
||||
displayName: 'Updated',
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteAccount('password123');
|
||||
});
|
||||
|
||||
expect(updateResult).toEqual(mockUser);
|
||||
expect(deleteResult).toBe(true);
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith({
|
||||
displayName: 'Updated',
|
||||
});
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith('password123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface', () => {
|
||||
it('returns correct interface', () => {
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
expect(typeof result.current.loading).toBe('boolean');
|
||||
expect(typeof result.current.updateProfile).toBe('function');
|
||||
expect(typeof result.current.deleteAccount).toBe('function');
|
||||
});
|
||||
|
||||
it('functions are stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useProfileSettings());
|
||||
|
||||
const initialFunctions = {
|
||||
updateProfile: result.current.updateProfile,
|
||||
deleteAccount: result.current.deleteAccount,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.updateProfile).toBe(initialFunctions.updateProfile);
|
||||
expect(result.current.deleteAccount).toBe(initialFunctions.deleteAccount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty update request', async () => {
|
||||
const mockUpdateProfile = vi.mocked(userApi.updateProfile);
|
||||
mockUpdateProfile.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useProfileSettings());
|
||||
|
||||
let returnedUser: User | null = null;
|
||||
await act(async () => {
|
||||
returnedUser = await result.current.updateProfile({});
|
||||
});
|
||||
|
||||
expect(returnedUser).toEqual(mockUser);
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
});
|
||||
610
app/src/hooks/useUserAdmin.test.ts
Normal file
610
app/src/hooks/useUserAdmin.test.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useUserAdmin } from './useUserAdmin';
|
||||
import * as adminApi from '@/api/admin';
|
||||
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
|
||||
import { UserRole, type User } from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/admin');
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useAdminData hook
|
||||
const mockAdminData = {
|
||||
data: [] as User[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
reload: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('./useAdminData', () => ({
|
||||
useAdminData: () => mockAdminData,
|
||||
}));
|
||||
|
||||
// Import notifications for assertions
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
// Mock user data
|
||||
const mockUsers: User[] = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: UserRole.Admin,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'editor@example.com',
|
||||
displayName: 'Editor User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get a user by index and ensure it's not undefined
|
||||
const getUser = (index: number): User => {
|
||||
const user = mockUsers[index];
|
||||
if (!user) {
|
||||
throw new Error(`User at index ${index} is undefined`);
|
||||
}
|
||||
return user;
|
||||
};
|
||||
|
||||
describe('useUserAdmin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock data
|
||||
mockAdminData.data = [...mockUsers];
|
||||
mockAdminData.loading = false;
|
||||
mockAdminData.error = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('returns users data from useAdminData', () => {
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(result.current.users).toEqual(mockUsers);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('returns loading state from useAdminData', () => {
|
||||
mockAdminData.loading = true;
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('returns error state from useAdminData', () => {
|
||||
mockAdminData.error = 'Failed to load users';
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(result.current.error).toBe('Failed to load users');
|
||||
});
|
||||
|
||||
it('provides CRUD functions', () => {
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(typeof result.current.create).toBe('function');
|
||||
expect(typeof result.current.update).toBe('function');
|
||||
expect(typeof result.current.delete).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create user', () => {
|
||||
it('creates user successfully', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
const newUser: User = {
|
||||
id: 3,
|
||||
email: 'newuser@example.com',
|
||||
displayName: 'New User',
|
||||
role: UserRole.Viewer,
|
||||
createdAt: '2024-01-03T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
mockCreateUser.mockResolvedValue(newUser);
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const createRequest: CreateUserRequest = {
|
||||
email: 'newuser@example.com',
|
||||
displayName: 'New User',
|
||||
password: 'password123',
|
||||
role: UserRole.Viewer,
|
||||
};
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.create(createRequest);
|
||||
});
|
||||
|
||||
expect(createResult).toBe(true);
|
||||
expect(mockCreateUser).toHaveBeenCalledWith(createRequest);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
expect(mockAdminData.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles create errors with specific message', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
mockCreateUser.mockRejectedValue(new Error('Email already exists'));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const createRequest: CreateUserRequest = {
|
||||
email: 'existing@example.com',
|
||||
displayName: 'Test User',
|
||||
password: 'password123',
|
||||
role: UserRole.Editor,
|
||||
};
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.create(createRequest);
|
||||
});
|
||||
|
||||
expect(createResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to create user: Email already exists',
|
||||
color: 'red',
|
||||
});
|
||||
expect(mockAdminData.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles create errors with non-Error rejection', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
mockCreateUser.mockRejectedValue('String error');
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const createRequest: CreateUserRequest = {
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
password: 'password123',
|
||||
role: UserRole.Editor,
|
||||
};
|
||||
|
||||
let createResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
createResult = await result.current.create(createRequest);
|
||||
});
|
||||
|
||||
expect(createResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to create user: String error',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update user', () => {
|
||||
it('updates user successfully', async () => {
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
const user = getUser(1);
|
||||
const updatedUser: User = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: 'Updated Editor',
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
lastWorkspaceId: user.lastWorkspaceId,
|
||||
};
|
||||
mockUpdateUser.mockResolvedValue(updatedUser);
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const updateRequest: UpdateUserRequest = {
|
||||
displayName: 'Updated Editor',
|
||||
};
|
||||
|
||||
let updateResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
updateResult = await result.current.update(2, updateRequest);
|
||||
});
|
||||
|
||||
expect(updateResult).toBe(true);
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
expect(mockAdminData.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates user email and role', async () => {
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
const user = getUser(1);
|
||||
const updatedUser: User = {
|
||||
id: user.id,
|
||||
email: 'newemail@example.com',
|
||||
displayName: user.displayName || '',
|
||||
role: UserRole.Admin,
|
||||
createdAt: user.createdAt,
|
||||
lastWorkspaceId: user.lastWorkspaceId,
|
||||
};
|
||||
mockUpdateUser.mockResolvedValue(updatedUser);
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const updateRequest: UpdateUserRequest = {
|
||||
email: 'newemail@example.com',
|
||||
role: UserRole.Admin,
|
||||
};
|
||||
|
||||
let updateResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
updateResult = await result.current.update(2, updateRequest);
|
||||
});
|
||||
|
||||
expect(updateResult).toBe(true);
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest);
|
||||
});
|
||||
|
||||
it('updates user password', async () => {
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
mockUpdateUser.mockResolvedValue(getUser(1));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const updateRequest: UpdateUserRequest = {
|
||||
password: 'newpassword123',
|
||||
};
|
||||
|
||||
let updateResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
updateResult = await result.current.update(2, updateRequest);
|
||||
});
|
||||
|
||||
expect(updateResult).toBe(true);
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest);
|
||||
});
|
||||
|
||||
it('handles update errors', async () => {
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
mockUpdateUser.mockRejectedValue(new Error('User not found'));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const updateRequest: UpdateUserRequest = {
|
||||
displayName: 'Updated Name',
|
||||
};
|
||||
|
||||
let updateResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
updateResult = await result.current.update(999, updateRequest);
|
||||
});
|
||||
|
||||
expect(updateResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to update user: User not found',
|
||||
color: 'red',
|
||||
});
|
||||
expect(mockAdminData.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty update request', async () => {
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
mockUpdateUser.mockResolvedValue(getUser(1));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
let updateResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
updateResult = await result.current.update(2, {});
|
||||
});
|
||||
|
||||
expect(updateResult).toBe(true);
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith(2, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete user', () => {
|
||||
it('deletes user successfully', async () => {
|
||||
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
|
||||
mockDeleteUser.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.delete(2);
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(true);
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith(2);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
expect(mockAdminData.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles delete errors', async () => {
|
||||
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
|
||||
mockDeleteUser.mockRejectedValue(new Error('Cannot delete admin user'));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.delete(1);
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete user: Cannot delete admin user',
|
||||
color: 'red',
|
||||
});
|
||||
expect(mockAdminData.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles delete with non-existent user', async () => {
|
||||
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
|
||||
mockDeleteUser.mockRejectedValue(new Error('User not found'));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
let deleteResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.delete(999);
|
||||
});
|
||||
|
||||
expect(deleteResult).toBe(false);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete user: User not found',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data integration', () => {
|
||||
it('reflects loading state changes', () => {
|
||||
const { result, rerender } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
// Change loading state
|
||||
mockAdminData.loading = true;
|
||||
rerender();
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('reflects error state changes', () => {
|
||||
const { result, rerender } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
// Add error
|
||||
mockAdminData.error = 'Network error';
|
||||
rerender();
|
||||
|
||||
expect(result.current.error).toBe('Network error');
|
||||
});
|
||||
|
||||
it('reflects data changes', () => {
|
||||
const { result, rerender } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(result.current.users).toEqual(mockUsers);
|
||||
|
||||
// Change users data
|
||||
const newUsers = [mockUsers[0]].filter((u): u is User => u !== undefined);
|
||||
mockAdminData.data = newUsers;
|
||||
rerender();
|
||||
|
||||
expect(result.current.users).toEqual(newUsers);
|
||||
});
|
||||
|
||||
it('calls reload after successful operations', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
|
||||
|
||||
mockCreateUser.mockResolvedValue(getUser(0));
|
||||
mockUpdateUser.mockResolvedValue(getUser(0));
|
||||
mockDeleteUser.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
// Test create
|
||||
await act(async () => {
|
||||
await result.current.create({
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test',
|
||||
password: 'pass',
|
||||
role: UserRole.Viewer,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockAdminData.reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test update
|
||||
await act(async () => {
|
||||
await result.current.update(1, { displayName: 'Updated' });
|
||||
});
|
||||
|
||||
expect(mockAdminData.reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Test delete
|
||||
await act(async () => {
|
||||
await result.current.delete(1);
|
||||
});
|
||||
|
||||
expect(mockAdminData.reload).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not call reload after failed operations', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
const mockUpdateUser = vi.mocked(adminApi.updateUser);
|
||||
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
|
||||
|
||||
mockCreateUser.mockRejectedValue(new Error('Create failed'));
|
||||
mockUpdateUser.mockRejectedValue(new Error('Update failed'));
|
||||
mockDeleteUser.mockRejectedValue(new Error('Delete failed'));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
// Test failed create
|
||||
await act(async () => {
|
||||
await result.current.create({
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test',
|
||||
password: 'pass',
|
||||
role: UserRole.Viewer,
|
||||
});
|
||||
});
|
||||
|
||||
// Test failed update
|
||||
await act(async () => {
|
||||
await result.current.update(1, { displayName: 'Updated' });
|
||||
});
|
||||
|
||||
// Test failed delete
|
||||
await act(async () => {
|
||||
await result.current.delete(1);
|
||||
});
|
||||
|
||||
expect(mockAdminData.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
it('handles multiple create operations', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
mockCreateUser
|
||||
.mockResolvedValueOnce({
|
||||
id: 3,
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
role: UserRole.Viewer,
|
||||
createdAt: '2024-01-03T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 4,
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-04T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const requests = [
|
||||
{
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
password: 'pass1',
|
||||
role: UserRole.Viewer,
|
||||
},
|
||||
{
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
password: 'pass2',
|
||||
role: UserRole.Editor,
|
||||
},
|
||||
];
|
||||
|
||||
let results: boolean[] = [];
|
||||
await act(async () => {
|
||||
results = await Promise.all(
|
||||
requests.map((req) => result.current.create(req))
|
||||
);
|
||||
});
|
||||
|
||||
expect(results).toEqual([true, true]);
|
||||
expect(mockCreateUser).toHaveBeenCalledTimes(2);
|
||||
expect(mockAdminData.reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles mixed successful and failed operations', async () => {
|
||||
const mockCreateUser = vi.mocked(adminApi.createUser);
|
||||
mockCreateUser
|
||||
.mockResolvedValueOnce(getUser(0))
|
||||
.mockRejectedValueOnce(new Error('Second create failed'));
|
||||
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
const requests = [
|
||||
{
|
||||
email: 'success@example.com',
|
||||
displayName: 'Success User',
|
||||
password: 'pass1',
|
||||
role: UserRole.Viewer,
|
||||
},
|
||||
{
|
||||
email: 'fail@example.com',
|
||||
displayName: 'Fail User',
|
||||
password: 'pass2',
|
||||
role: UserRole.Editor,
|
||||
},
|
||||
];
|
||||
|
||||
let results: boolean[] = [];
|
||||
await act(async () => {
|
||||
results = await Promise.all(
|
||||
requests.map((req) => result.current.create(req))
|
||||
);
|
||||
});
|
||||
|
||||
expect(results).toEqual([true, false]);
|
||||
expect(mockAdminData.reload).toHaveBeenCalledTimes(1); // Only for successful operation
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface stability', () => {
|
||||
it('functions are stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useUserAdmin());
|
||||
|
||||
const initialFunctions = {
|
||||
create: result.current.create,
|
||||
update: result.current.update,
|
||||
delete: result.current.delete,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.create).toBe(initialFunctions.create);
|
||||
expect(result.current.update).toBe(initialFunctions.update);
|
||||
expect(result.current.delete).toBe(initialFunctions.delete);
|
||||
});
|
||||
|
||||
it('returns consistent interface', () => {
|
||||
const { result } = renderHook(() => useUserAdmin());
|
||||
|
||||
expect(Array.isArray(result.current.users)).toBe(true);
|
||||
expect(typeof result.current.loading).toBe('boolean');
|
||||
expect(
|
||||
result.current.error === null ||
|
||||
typeof result.current.error === 'string'
|
||||
).toBe(true);
|
||||
expect(typeof result.current.create).toBe('function');
|
||||
expect(typeof result.current.update).toBe('function');
|
||||
expect(typeof result.current.delete).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deleteUser as adminDeleteUser,
|
||||
} from '../api/admin';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useCallback } from 'react';
|
||||
import type { User } from '@/types/models';
|
||||
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
|
||||
|
||||
@@ -20,73 +21,77 @@ interface UseUserAdminResult {
|
||||
export const useUserAdmin = (): UseUserAdminResult => {
|
||||
const { data: users, loading, error, reload } = useAdminData('users');
|
||||
|
||||
const handleCreate = async (
|
||||
userData: CreateUserRequest
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await createUser(userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleCreate = useCallback(
|
||||
async (userData: CreateUserRequest): Promise<boolean> => {
|
||||
try {
|
||||
await createUser(userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
const handleUpdate = async (
|
||||
userId: number,
|
||||
userData: UpdateUserRequest
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await updateUser(userId, userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to update user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleUpdate = useCallback(
|
||||
async (userId: number, userData: UpdateUserRequest): Promise<boolean> => {
|
||||
try {
|
||||
await updateUser(userId, userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to update user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
const handleDelete = async (userId: number): Promise<boolean> => {
|
||||
try {
|
||||
await adminDeleteUser(userId);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to delete user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleDelete = useCallback(
|
||||
async (userId: number): Promise<boolean> => {
|
||||
try {
|
||||
await adminDeleteUser(userId);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to delete user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
return {
|
||||
users,
|
||||
|
||||
491
app/src/hooks/useWorkspace.test.ts
Normal file
491
app/src/hooks/useWorkspace.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useWorkspace } from './useWorkspace';
|
||||
import {
|
||||
Theme,
|
||||
type Workspace,
|
||||
DEFAULT_WORKSPACE_SETTINGS,
|
||||
} from '@/types/models';
|
||||
import type { MantineColorScheme } from '@mantine/core';
|
||||
|
||||
// Mock the constituent hooks
|
||||
const mockWorkspaceData = {
|
||||
currentWorkspace: null as Workspace | null,
|
||||
workspaces: [] as Workspace[],
|
||||
settings: DEFAULT_WORKSPACE_SETTINGS,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const mockTheme = {
|
||||
colorScheme: 'light' as MantineColorScheme,
|
||||
updateColorScheme: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWorkspaceOperations = {
|
||||
switchWorkspace: vi.fn(),
|
||||
deleteCurrentWorkspace: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/ThemeContext', () => ({
|
||||
useTheme: () => mockTheme,
|
||||
}));
|
||||
|
||||
vi.mock('./useWorkspaceOperations', () => ({
|
||||
useWorkspaceOperations: () => mockWorkspaceOperations,
|
||||
}));
|
||||
|
||||
// Mock workspace data
|
||||
const mockWorkspace: Workspace = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
const mockWorkspaces: Workspace[] = [
|
||||
mockWorkspace,
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
name: 'second-workspace',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
theme: Theme.Dark,
|
||||
autoSave: true,
|
||||
showHiddenFiles: true,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/user/repo.git',
|
||||
gitUser: 'user',
|
||||
gitToken: 'token',
|
||||
gitAutoCommit: true,
|
||||
gitCommitMsgTemplate: 'auto: ${action} ${filename}',
|
||||
gitCommitName: 'Test User',
|
||||
gitCommitEmail: 'test@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
describe('useWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock data to defaults
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
mockWorkspaceData.workspaces = [];
|
||||
mockWorkspaceData.loading = false;
|
||||
mockTheme.colorScheme = 'light';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('returns default values when no workspace is loaded', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.colorScheme).toBe('light');
|
||||
});
|
||||
|
||||
it('provides all expected functions', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(typeof result.current.updateSettings).toBe('function');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
expect(typeof result.current.switchWorkspace).toBe('function');
|
||||
expect(typeof result.current.deleteCurrentWorkspace).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace data integration', () => {
|
||||
it('returns current workspace data', () => {
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
mockWorkspaceData.workspaces = mockWorkspaces;
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaces);
|
||||
});
|
||||
|
||||
it('returns loading state from workspace data', () => {
|
||||
mockWorkspaceData.loading = true;
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme integration', () => {
|
||||
it('returns color scheme from theme context', () => {
|
||||
mockTheme.colorScheme = 'dark';
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
it('provides updateColorScheme function from theme context', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.updateColorScheme).toBe(
|
||||
mockTheme.updateColorScheme
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace operations integration', () => {
|
||||
it('provides switchWorkspace function from operations', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.switchWorkspace).toBe(
|
||||
mockWorkspaceOperations.switchWorkspace
|
||||
);
|
||||
});
|
||||
|
||||
it('provides deleteCurrentWorkspace function from operations', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.deleteCurrentWorkspace).toBe(
|
||||
mockWorkspaceOperations.deleteCurrentWorkspace
|
||||
);
|
||||
});
|
||||
|
||||
it('provides updateSettings function from operations', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.updateSettings).toBe(
|
||||
mockWorkspaceOperations.updateSettings
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data consistency', () => {
|
||||
it('returns consistent data across multiple renders', () => {
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
mockWorkspaceData.workspaces = mockWorkspaces;
|
||||
mockTheme.colorScheme = 'dark';
|
||||
|
||||
const { result, rerender } = renderHook(() => useWorkspace());
|
||||
|
||||
const firstResult = { ...result.current };
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(
|
||||
firstResult.currentWorkspace
|
||||
);
|
||||
expect(result.current.workspaces).toEqual(firstResult.workspaces);
|
||||
expect(result.current.colorScheme).toEqual(firstResult.colorScheme);
|
||||
});
|
||||
|
||||
it('reflects changes in underlying data', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspace());
|
||||
|
||||
// Initially no workspace
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
|
||||
// Add workspace data
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
mockWorkspaceData.workspaces = mockWorkspaces;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaces);
|
||||
});
|
||||
|
||||
it('reflects theme changes', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspace());
|
||||
|
||||
// Initially light theme
|
||||
expect(result.current.colorScheme).toBe('light');
|
||||
|
||||
// Change to dark theme
|
||||
mockTheme.colorScheme = 'dark';
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
it('reflects loading state changes', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspace());
|
||||
|
||||
// Initially not loading
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
// Change to loading
|
||||
mockWorkspaceData.loading = true;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function stability', () => {
|
||||
it('maintains stable function references across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspace());
|
||||
|
||||
const initialFunctions = {
|
||||
updateSettings: result.current.updateSettings,
|
||||
updateColorScheme: result.current.updateColorScheme,
|
||||
switchWorkspace: result.current.switchWorkspace,
|
||||
deleteCurrentWorkspace: result.current.deleteCurrentWorkspace,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.updateSettings).toBe(
|
||||
initialFunctions.updateSettings
|
||||
);
|
||||
expect(result.current.updateColorScheme).toBe(
|
||||
initialFunctions.updateColorScheme
|
||||
);
|
||||
expect(result.current.switchWorkspace).toBe(
|
||||
initialFunctions.switchWorkspace
|
||||
);
|
||||
expect(result.current.deleteCurrentWorkspace).toBe(
|
||||
initialFunctions.deleteCurrentWorkspace
|
||||
);
|
||||
});
|
||||
|
||||
it('maintains stable function references when data changes', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspace());
|
||||
|
||||
const initialFunctions = {
|
||||
updateSettings: result.current.updateSettings,
|
||||
updateColorScheme: result.current.updateColorScheme,
|
||||
switchWorkspace: result.current.switchWorkspace,
|
||||
deleteCurrentWorkspace: result.current.deleteCurrentWorkspace,
|
||||
};
|
||||
|
||||
// Change data
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
mockWorkspaceData.workspaces = mockWorkspaces;
|
||||
mockTheme.colorScheme = 'dark';
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.updateSettings).toBe(
|
||||
initialFunctions.updateSettings
|
||||
);
|
||||
expect(result.current.updateColorScheme).toBe(
|
||||
initialFunctions.updateColorScheme
|
||||
);
|
||||
expect(result.current.switchWorkspace).toBe(
|
||||
initialFunctions.switchWorkspace
|
||||
);
|
||||
expect(result.current.deleteCurrentWorkspace).toBe(
|
||||
initialFunctions.deleteCurrentWorkspace
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface', () => {
|
||||
it('returns correct interface structure', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
const expectedKeys = [
|
||||
'currentWorkspace',
|
||||
'workspaces',
|
||||
'updateSettings',
|
||||
'loading',
|
||||
'colorScheme',
|
||||
'updateColorScheme',
|
||||
'switchWorkspace',
|
||||
'deleteCurrentWorkspace',
|
||||
];
|
||||
|
||||
expectedKeys.forEach((key) => {
|
||||
expect(key in result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns correct types for all properties', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(
|
||||
result.current.currentWorkspace === null ||
|
||||
typeof result.current.currentWorkspace === 'object'
|
||||
).toBe(true);
|
||||
expect(Array.isArray(result.current.workspaces)).toBe(true);
|
||||
expect(typeof result.current.updateSettings === 'function').toBe(true);
|
||||
expect(typeof result.current.loading === 'boolean').toBe(true);
|
||||
expect(typeof result.current.colorScheme === 'string').toBe(true);
|
||||
expect(typeof result.current.updateColorScheme === 'function').toBe(true);
|
||||
expect(typeof result.current.switchWorkspace === 'function').toBe(true);
|
||||
expect(typeof result.current.deleteCurrentWorkspace === 'function').toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles undefined workspace data gracefully', () => {
|
||||
// Simulate undefined data that might occur during loading
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
mockWorkspaceData.workspaces = [];
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
expect(typeof result.current.updateSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('handles empty workspaces array', () => {
|
||||
mockWorkspaceData.workspaces = [];
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single workspace', () => {
|
||||
const singleWorkspace = [mockWorkspace];
|
||||
mockWorkspaceData.workspaces = singleWorkspace;
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.workspaces).toEqual(singleWorkspace);
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
});
|
||||
|
||||
it('handles workspace with minimal data', () => {
|
||||
const minimalWorkspace: Workspace = {
|
||||
name: 'minimal',
|
||||
createdAt: Date.now(),
|
||||
...DEFAULT_WORKSPACE_SETTINGS,
|
||||
};
|
||||
|
||||
mockWorkspaceData.currentWorkspace = minimalWorkspace;
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.currentWorkspace).toEqual(minimalWorkspace);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('provides complete workspace management interface', () => {
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
mockWorkspaceData.workspaces = mockWorkspaces;
|
||||
mockTheme.colorScheme = 'light';
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
// Should have all data
|
||||
expect(result.current.currentWorkspace).toEqual(mockWorkspace);
|
||||
expect(result.current.workspaces).toEqual(mockWorkspaces);
|
||||
expect(result.current.colorScheme).toBe('light');
|
||||
|
||||
// Should have all operations
|
||||
expect(typeof result.current.updateSettings).toBe('function');
|
||||
expect(typeof result.current.switchWorkspace).toBe('function');
|
||||
expect(typeof result.current.deleteCurrentWorkspace).toBe('function');
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
});
|
||||
|
||||
it('supports workspace switching workflow', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
// Initially no workspace
|
||||
expect(result.current.currentWorkspace).toBeNull();
|
||||
|
||||
// Should provide switch function
|
||||
expect(typeof result.current.switchWorkspace).toBe('function');
|
||||
expect(result.current.switchWorkspace).toBe(
|
||||
mockWorkspaceOperations.switchWorkspace
|
||||
);
|
||||
});
|
||||
|
||||
it('supports settings management workflow', () => {
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
// Should provide update function
|
||||
expect(typeof result.current.updateSettings).toBe('function');
|
||||
expect(result.current.updateSettings).toBe(
|
||||
mockWorkspaceOperations.updateSettings
|
||||
);
|
||||
});
|
||||
|
||||
it('supports theme management workflow', () => {
|
||||
mockTheme.colorScheme = 'dark';
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
// Should have current color scheme
|
||||
expect(result.current.colorScheme).toBe('dark');
|
||||
|
||||
// Should provide update function
|
||||
expect(typeof result.current.updateColorScheme).toBe('function');
|
||||
expect(result.current.updateColorScheme).toBe(
|
||||
mockTheme.updateColorScheme
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock integration validation', () => {
|
||||
it('correctly integrates with WorkspaceDataContext mock', () => {
|
||||
mockWorkspaceData.currentWorkspace = mockWorkspace;
|
||||
mockWorkspaceData.workspaces = mockWorkspaces;
|
||||
mockWorkspaceData.loading = true;
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.currentWorkspace).toBe(
|
||||
mockWorkspaceData.currentWorkspace
|
||||
);
|
||||
expect(result.current.workspaces).toBe(mockWorkspaceData.workspaces);
|
||||
expect(result.current.loading).toBe(mockWorkspaceData.loading);
|
||||
});
|
||||
|
||||
it('correctly integrates with ThemeContext mock', () => {
|
||||
mockTheme.colorScheme = 'dark';
|
||||
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.colorScheme).toBe(mockTheme.colorScheme);
|
||||
expect(result.current.updateColorScheme).toBe(
|
||||
mockTheme.updateColorScheme
|
||||
);
|
||||
});
|
||||
|
||||
it('correctly integrates with useWorkspaceOperations mock', () => {
|
||||
const { result } = renderHook(() => useWorkspace());
|
||||
|
||||
expect(result.current.switchWorkspace).toBe(
|
||||
mockWorkspaceOperations.switchWorkspace
|
||||
);
|
||||
expect(result.current.deleteCurrentWorkspace).toBe(
|
||||
mockWorkspaceOperations.deleteCurrentWorkspace
|
||||
);
|
||||
expect(result.current.updateSettings).toBe(
|
||||
mockWorkspaceOperations.updateSettings
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useWorkspaceOperations } from './useWorkspaceOperations';
|
||||
import type { Workspace, DEFAULT_WORKSPACE_SETTINGS } from '@/types/models';
|
||||
import type { Workspace } from '@/types/models';
|
||||
import type { MantineColorScheme } from '@mantine/core';
|
||||
|
||||
interface UseWorkspaceResult {
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
|
||||
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
|
||||
loading: boolean;
|
||||
colorScheme: MantineColorScheme;
|
||||
@@ -17,8 +16,7 @@ interface UseWorkspaceResult {
|
||||
}
|
||||
|
||||
export const useWorkspace = (): UseWorkspaceResult => {
|
||||
const { currentWorkspace, workspaces, settings, loading } =
|
||||
useWorkspaceData();
|
||||
const { currentWorkspace, workspaces, loading } = useWorkspaceData();
|
||||
const { colorScheme, updateColorScheme } = useTheme();
|
||||
const { switchWorkspace, deleteCurrentWorkspace, updateSettings } =
|
||||
useWorkspaceOperations();
|
||||
@@ -26,7 +24,6 @@ export const useWorkspace = (): UseWorkspaceResult => {
|
||||
return {
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
settings,
|
||||
updateSettings,
|
||||
loading,
|
||||
colorScheme,
|
||||
|
||||
575
app/src/hooks/useWorkspaceOperations.test.ts
Normal file
575
app/src/hooks/useWorkspaceOperations.test.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useWorkspaceOperations } from './useWorkspaceOperations';
|
||||
import * as workspaceApi from '@/api/workspace';
|
||||
import { Theme, type Workspace } from '@/types/models';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/api/workspace');
|
||||
vi.mock('@mantine/notifications', () => ({
|
||||
notifications: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock workspace data context
|
||||
const mockWorkspaceData: {
|
||||
currentWorkspace: Workspace | null;
|
||||
loadWorkspaceData: ReturnType<typeof vi.fn>;
|
||||
loadWorkspaces: ReturnType<typeof vi.fn>;
|
||||
setCurrentWorkspace: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
currentWorkspace: {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
loadWorkspaceData: vi.fn(),
|
||||
loadWorkspaces: vi.fn(),
|
||||
setCurrentWorkspace: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock theme context
|
||||
const mockTheme = {
|
||||
updateColorScheme: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../contexts/WorkspaceDataContext', () => ({
|
||||
useWorkspaceData: () => mockWorkspaceData,
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/ThemeContext', () => ({
|
||||
useTheme: () => mockTheme,
|
||||
}));
|
||||
|
||||
// Import notifications for assertions
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
// Mock workspaces for testing
|
||||
const mockWorkspaces: Workspace[] = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'workspace-1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: 1,
|
||||
name: 'workspace-2',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
theme: Theme.Dark,
|
||||
autoSave: true,
|
||||
showHiddenFiles: true,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/user/repo.git',
|
||||
gitUser: 'user',
|
||||
gitToken: 'token',
|
||||
gitAutoCommit: true,
|
||||
gitCommitMsgTemplate: 'auto: ${action} ${filename}',
|
||||
gitCommitName: 'Test User',
|
||||
gitCommitEmail: 'test@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
describe('useWorkspaceOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset workspace data to defaults
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('switchWorkspace', () => {
|
||||
it('switches workspace successfully', async () => {
|
||||
const mockUpdateLastWorkspaceName = vi.mocked(
|
||||
workspaceApi.updateLastWorkspaceName
|
||||
);
|
||||
mockUpdateLastWorkspaceName.mockResolvedValue(undefined);
|
||||
mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchWorkspace('new-workspace');
|
||||
});
|
||||
|
||||
expect(mockUpdateLastWorkspaceName).toHaveBeenCalledWith('new-workspace');
|
||||
expect(mockWorkspaceData.loadWorkspaceData).toHaveBeenCalledWith(
|
||||
'new-workspace'
|
||||
);
|
||||
expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles switch workspace errors', async () => {
|
||||
const mockUpdateLastWorkspaceName = vi.mocked(
|
||||
workspaceApi.updateLastWorkspaceName
|
||||
);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockUpdateLastWorkspaceName.mockRejectedValue(new Error('Switch failed'));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchWorkspace('error-workspace');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to switch workspace:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to switch workspace',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles load workspace data errors during switch', async () => {
|
||||
const mockUpdateLastWorkspaceName = vi.mocked(
|
||||
workspaceApi.updateLastWorkspaceName
|
||||
);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockUpdateLastWorkspaceName.mockResolvedValue(undefined);
|
||||
mockWorkspaceData.loadWorkspaceData.mockRejectedValue(
|
||||
new Error('Load failed')
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchWorkspace('error-workspace');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to switch workspace:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to switch workspace',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCurrentWorkspace', () => {
|
||||
it('deletes workspace successfully when multiple workspaces exist', async () => {
|
||||
const mockDeleteWorkspace = vi.mocked(workspaceApi.deleteWorkspace);
|
||||
mockDeleteWorkspace.mockResolvedValue('next-workspace');
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteCurrentWorkspace();
|
||||
});
|
||||
|
||||
expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalledTimes(2); // Once for check, once after deletion
|
||||
expect(mockDeleteWorkspace).toHaveBeenCalledWith('test-workspace');
|
||||
expect(mockWorkspaceData.loadWorkspaceData).toHaveBeenCalledWith(
|
||||
'next-workspace'
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Success',
|
||||
message: 'Workspace deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents deletion when only one workspace exists', async () => {
|
||||
const singleWorkspace = [mockWorkspaces[0]].filter(
|
||||
(w): w is Workspace => w !== undefined
|
||||
);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(singleWorkspace);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteCurrentWorkspace();
|
||||
});
|
||||
|
||||
expect(workspaceApi.deleteWorkspace).not.toHaveBeenCalled();
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message:
|
||||
'Cannot delete the last workspace. At least one workspace must exist.',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when no current workspace', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteCurrentWorkspace();
|
||||
});
|
||||
|
||||
expect(workspaceApi.deleteWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceData.loadWorkspaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles delete workspace API errors', async () => {
|
||||
const mockDeleteWorkspace = vi.mocked(workspaceApi.deleteWorkspace);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
mockDeleteWorkspace.mockRejectedValue(new Error('Delete failed'));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteCurrentWorkspace();
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to delete workspace:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete workspace',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSettings', () => {
|
||||
it('updates workspace settings successfully', async () => {
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
const updatedWorkspace: Workspace = {
|
||||
...mockWorkspaceData.currentWorkspace!,
|
||||
autoSave: true,
|
||||
showHiddenFiles: true,
|
||||
};
|
||||
mockUpdateWorkspace.mockResolvedValue(updatedWorkspace);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
const newSettings = {
|
||||
autoSave: true,
|
||||
showHiddenFiles: true,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateSettings(newSettings);
|
||||
});
|
||||
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith('test-workspace', {
|
||||
...mockWorkspaceData.currentWorkspace,
|
||||
...newSettings,
|
||||
});
|
||||
expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith(
|
||||
updatedWorkspace
|
||||
);
|
||||
expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates theme and calls updateColorScheme', async () => {
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
const updatedWorkspace: Workspace = {
|
||||
...mockWorkspaceData.currentWorkspace!,
|
||||
theme: Theme.Dark,
|
||||
};
|
||||
mockUpdateWorkspace.mockResolvedValue(updatedWorkspace);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
const newSettings = {
|
||||
theme: Theme.Dark,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateSettings(newSettings);
|
||||
});
|
||||
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith('test-workspace', {
|
||||
...mockWorkspaceData.currentWorkspace,
|
||||
theme: Theme.Dark,
|
||||
});
|
||||
expect(mockTheme.updateColorScheme).toHaveBeenCalledWith(Theme.Dark);
|
||||
expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith(
|
||||
updatedWorkspace
|
||||
);
|
||||
});
|
||||
|
||||
it('updates multiple settings including theme', async () => {
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
const updatedWorkspace: Workspace = {
|
||||
...mockWorkspaceData.currentWorkspace!,
|
||||
theme: Theme.Dark,
|
||||
autoSave: true,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
mockUpdateWorkspace.mockResolvedValue(updatedWorkspace);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
const newSettings = {
|
||||
theme: Theme.Dark,
|
||||
autoSave: true,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateSettings(newSettings);
|
||||
});
|
||||
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith('test-workspace', {
|
||||
...mockWorkspaceData.currentWorkspace,
|
||||
...newSettings,
|
||||
});
|
||||
expect(mockTheme.updateColorScheme).toHaveBeenCalledWith(Theme.Dark);
|
||||
expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith(
|
||||
updatedWorkspace
|
||||
);
|
||||
expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no current workspace', async () => {
|
||||
mockWorkspaceData.currentWorkspace = null;
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateSettings({ autoSave: true });
|
||||
});
|
||||
|
||||
expect(workspaceApi.updateWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceData.setCurrentWorkspace).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceData.loadWorkspaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles update workspace API errors', async () => {
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
mockUpdateWorkspace.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.updateSettings({ autoSave: true });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('Update failed');
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to save settings:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles empty settings update', async () => {
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
const updatedWorkspace = mockWorkspaceData.currentWorkspace!;
|
||||
mockUpdateWorkspace.mockResolvedValue(updatedWorkspace);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateSettings({});
|
||||
});
|
||||
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
mockWorkspaceData.currentWorkspace
|
||||
);
|
||||
expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith(
|
||||
updatedWorkspace
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook interface', () => {
|
||||
it('returns correct function interface', () => {
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
expect(typeof result.current.switchWorkspace).toBe('function');
|
||||
expect(typeof result.current.deleteCurrentWorkspace).toBe('function');
|
||||
expect(typeof result.current.updateSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('functions are stable across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
const initialFunctions = {
|
||||
switchWorkspace: result.current.switchWorkspace,
|
||||
deleteCurrentWorkspace: result.current.deleteCurrentWorkspace,
|
||||
updateSettings: result.current.updateSettings,
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.switchWorkspace).toBe(
|
||||
initialFunctions.switchWorkspace
|
||||
);
|
||||
expect(result.current.deleteCurrentWorkspace).toBe(
|
||||
initialFunctions.deleteCurrentWorkspace
|
||||
);
|
||||
expect(result.current.updateSettings).toBe(
|
||||
initialFunctions.updateSettings
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace data integration', () => {
|
||||
it('uses current workspace name for API calls', async () => {
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
const mockDeleteWorkspace = vi.mocked(workspaceApi.deleteWorkspace);
|
||||
|
||||
// Update workspace name
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
...mockWorkspaceData.currentWorkspace!,
|
||||
name: 'different-workspace',
|
||||
};
|
||||
|
||||
mockUpdateWorkspace.mockResolvedValue(mockWorkspaceData.currentWorkspace);
|
||||
mockDeleteWorkspace.mockResolvedValue('next-workspace');
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
// Test update settings
|
||||
await act(async () => {
|
||||
await result.current.updateSettings({ autoSave: true });
|
||||
});
|
||||
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
|
||||
'different-workspace',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Test delete workspace
|
||||
await act(async () => {
|
||||
await result.current.deleteCurrentWorkspace();
|
||||
});
|
||||
|
||||
expect(mockDeleteWorkspace).toHaveBeenCalledWith('different-workspace');
|
||||
});
|
||||
|
||||
it('handles workspace changes during operations', () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
// Change workspace
|
||||
mockWorkspaceData.currentWorkspace = {
|
||||
...mockWorkspaceData.currentWorkspace!,
|
||||
name: 'new-workspace',
|
||||
};
|
||||
|
||||
rerender();
|
||||
|
||||
// Functions should still work with new workspace
|
||||
expect(typeof result.current.switchWorkspace).toBe('function');
|
||||
expect(typeof result.current.deleteCurrentWorkspace).toBe('function');
|
||||
expect(typeof result.current.updateSettings).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent operations', () => {
|
||||
|
||||
it('handles update settings after switch workspace', async () => {
|
||||
const mockUpdateLastWorkspaceName = vi.mocked(
|
||||
workspaceApi.updateLastWorkspaceName
|
||||
);
|
||||
const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace);
|
||||
|
||||
mockUpdateLastWorkspaceName.mockResolvedValue(undefined);
|
||||
mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined);
|
||||
mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces);
|
||||
mockUpdateWorkspace.mockResolvedValue(
|
||||
mockWorkspaceData.currentWorkspace!
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceOperations());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchWorkspace('new-workspace');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateSettings({ autoSave: true });
|
||||
});
|
||||
|
||||
expect(mockUpdateLastWorkspaceName).toHaveBeenCalledWith('new-workspace');
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
expect.objectContaining({ autoSave: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
app/src/test/setup.ts
Normal file
30
app/src/test/setup.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock window.API_BASE_URL
|
||||
Object.defineProperty(window, 'API_BASE_URL', {
|
||||
value: 'http://localhost:8080/api/v1',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock matchMedia - required for Mantine components
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock ResizeObserver - sometimes needed for Mantine components
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
21
app/src/test/utils.tsx
Normal file
21
app/src/test/utils.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
|
||||
// Create a custom render function that includes Mantine provider
|
||||
const AllTheProviders: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const customRender = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => render(ui, { wrapper: AllTheProviders, ...options });
|
||||
|
||||
// Re-export everything
|
||||
export * from '@testing-library/react';
|
||||
export { customRender as render };
|
||||
513
app/src/types/api.test.ts
Normal file
513
app/src/types/api.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isLoginResponse,
|
||||
isLookupResponse,
|
||||
isSaveFileResponse,
|
||||
type LoginResponse,
|
||||
type LookupResponse,
|
||||
type SaveFileResponse,
|
||||
} from './api';
|
||||
import { UserRole, type User } from './models';
|
||||
|
||||
// Mock user data for testing
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
describe('API Type Guards', () => {
|
||||
describe('isLoginResponse', () => {
|
||||
it('returns true for valid login response with all fields', () => {
|
||||
const validLoginResponse: LoginResponse = {
|
||||
user: mockUser,
|
||||
sessionId: 'session123',
|
||||
expiresAt: '2024-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
expect(isLoginResponse(validLoginResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid login response with only required fields', () => {
|
||||
const validLoginResponse: LoginResponse = {
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
expect(isLoginResponse(validLoginResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid login response with sessionId only', () => {
|
||||
const validLoginResponse: LoginResponse = {
|
||||
user: mockUser,
|
||||
sessionId: 'session123',
|
||||
};
|
||||
|
||||
expect(isLoginResponse(validLoginResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid login response with expiresAt only', () => {
|
||||
const validLoginResponse: LoginResponse = {
|
||||
user: mockUser,
|
||||
expiresAt: '2024-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
expect(isLoginResponse(validLoginResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isLoginResponse(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isLoginResponse(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isLoginResponse('string')).toBe(false);
|
||||
expect(isLoginResponse(123)).toBe(false);
|
||||
expect(isLoginResponse(true)).toBe(false);
|
||||
expect(isLoginResponse([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isLoginResponse({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when user field is missing', () => {
|
||||
const invalidResponse = {
|
||||
sessionId: 'session123',
|
||||
expiresAt: '2024-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
expect(isLoginResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when user field is invalid', () => {
|
||||
const invalidResponse = {
|
||||
user: {
|
||||
id: 'not-a-number', // Invalid user
|
||||
email: 'test@example.com',
|
||||
},
|
||||
sessionId: 'session123',
|
||||
};
|
||||
|
||||
expect(isLoginResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when sessionId is not a string', () => {
|
||||
const invalidResponse = {
|
||||
user: mockUser,
|
||||
sessionId: 123, // Should be string
|
||||
};
|
||||
|
||||
expect(isLoginResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when expiresAt is not a string', () => {
|
||||
const invalidResponse = {
|
||||
user: mockUser,
|
||||
expiresAt: new Date(), // Should be string
|
||||
};
|
||||
|
||||
expect(isLoginResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const responseWithExtra = {
|
||||
user: mockUser,
|
||||
sessionId: 'session123',
|
||||
expiresAt: '2024-01-01T12:00:00Z',
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isLoginResponse(responseWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for user with missing required fields', () => {
|
||||
const invalidUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
// Missing role, createdAt, lastWorkspaceId
|
||||
};
|
||||
|
||||
const invalidResponse = {
|
||||
user: invalidUser,
|
||||
};
|
||||
|
||||
expect(isLoginResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousObj = {
|
||||
user: mockUser,
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isLoginResponse(maliciousObj)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLookupResponse', () => {
|
||||
it('returns true for valid lookup response with paths', () => {
|
||||
const validLookupResponse: LookupResponse = {
|
||||
paths: ['path1.md', 'path2.md', 'folder/path3.md'],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(validLookupResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid lookup response with empty paths array', () => {
|
||||
const validLookupResponse: LookupResponse = {
|
||||
paths: [],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(validLookupResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for single path', () => {
|
||||
const validLookupResponse: LookupResponse = {
|
||||
paths: ['single-path.md'],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(validLookupResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isLookupResponse(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isLookupResponse(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isLookupResponse('string')).toBe(false);
|
||||
expect(isLookupResponse(123)).toBe(false);
|
||||
expect(isLookupResponse(true)).toBe(false);
|
||||
expect(isLookupResponse([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isLookupResponse({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when paths field is missing', () => {
|
||||
const invalidResponse = {
|
||||
otherField: 'value',
|
||||
};
|
||||
|
||||
expect(isLookupResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when paths is not an array', () => {
|
||||
const invalidResponse = {
|
||||
paths: 'not-an-array',
|
||||
};
|
||||
|
||||
expect(isLookupResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when paths contains non-string values', () => {
|
||||
const invalidResponse = {
|
||||
paths: ['valid-path.md', 123, 'another-path.md'],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when paths contains null values', () => {
|
||||
const invalidResponse = {
|
||||
paths: ['path1.md', null, 'path2.md'],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when paths contains undefined values', () => {
|
||||
const invalidResponse = {
|
||||
paths: ['path1.md', undefined, 'path2.md'],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const responseWithExtra = {
|
||||
paths: ['path1.md', 'path2.md'],
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isLookupResponse(responseWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousObj = {
|
||||
paths: ['path1.md', 'path2.md'],
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isLookupResponse(maliciousObj)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles complex path strings', () => {
|
||||
const validLookupResponse: LookupResponse = {
|
||||
paths: [
|
||||
'simple.md',
|
||||
'folder/nested.md',
|
||||
'deep/nested/path/file.md',
|
||||
'file with spaces.md',
|
||||
'special-chars_123.md',
|
||||
'unicode-文件.md',
|
||||
],
|
||||
};
|
||||
|
||||
expect(isLookupResponse(validLookupResponse)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSaveFileResponse', () => {
|
||||
it('returns true for valid save file response', () => {
|
||||
const validSaveFileResponse: SaveFileResponse = {
|
||||
filePath: 'documents/test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(validSaveFileResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for save file response with zero size', () => {
|
||||
const validSaveFileResponse: SaveFileResponse = {
|
||||
filePath: 'empty.md',
|
||||
size: 0,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(validSaveFileResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for save file response with large size', () => {
|
||||
const validSaveFileResponse: SaveFileResponse = {
|
||||
filePath: 'large-file.md',
|
||||
size: 999999999,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(validSaveFileResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isSaveFileResponse(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isSaveFileResponse(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isSaveFileResponse('string')).toBe(false);
|
||||
expect(isSaveFileResponse(123)).toBe(false);
|
||||
expect(isSaveFileResponse(true)).toBe(false);
|
||||
expect(isSaveFileResponse([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isSaveFileResponse({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when filePath is missing', () => {
|
||||
const invalidResponse = {
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when filePath is not a string', () => {
|
||||
const invalidResponse = {
|
||||
filePath: 123,
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when size is missing', () => {
|
||||
const invalidResponse = {
|
||||
filePath: 'test.md',
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when size is not a number', () => {
|
||||
const invalidResponse = {
|
||||
filePath: 'test.md',
|
||||
size: '1024',
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when updatedAt is missing', () => {
|
||||
const invalidResponse = {
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when updatedAt is not a string', () => {
|
||||
const invalidResponse = {
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for negative size', () => {
|
||||
const invalidResponse = {
|
||||
filePath: 'test.md',
|
||||
size: -1,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(invalidResponse)).toBe(true); // Note: Type guard doesn't validate negative numbers
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousObj = {
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(maliciousObj)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const responseWithExtra = {
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(responseWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles complex file paths', () => {
|
||||
const validSaveFileResponse: SaveFileResponse = {
|
||||
filePath: 'deep/nested/path/file with spaces & symbols.md',
|
||||
size: 2048,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(validSaveFileResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles various ISO date formats', () => {
|
||||
const dateFormats = [
|
||||
'2024-01-01T10:00:00Z',
|
||||
'2024-01-01T10:00:00.000Z',
|
||||
'2024-01-01T10:00:00+00:00',
|
||||
'2024-01-01T10:00:00.123456Z',
|
||||
];
|
||||
|
||||
dateFormats.forEach((dateString) => {
|
||||
const validResponse: SaveFileResponse = {
|
||||
filePath: 'test.md',
|
||||
size: 1024,
|
||||
updatedAt: dateString,
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(validResponse)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error conditions', () => {
|
||||
it('handles circular references gracefully', () => {
|
||||
const circularObj: { paths: string[]; self?: unknown } = { paths: [] };
|
||||
circularObj.self = circularObj;
|
||||
|
||||
// Should not throw an error
|
||||
expect(isLookupResponse(circularObj)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles deeply nested objects', () => {
|
||||
const deeplyNested = {
|
||||
user: {
|
||||
...mockUser,
|
||||
nested: {
|
||||
deep: {
|
||||
deeper: {
|
||||
value: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(isLoginResponse(deeplyNested)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles frozen objects', () => {
|
||||
const frozenResponse = Object.freeze({
|
||||
paths: Object.freeze(['path1.md', 'path2.md']),
|
||||
});
|
||||
|
||||
expect(isLookupResponse(frozenResponse)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects created with null prototype', () => {
|
||||
const nullProtoObj = Object.create(null) as Record<string, unknown>;
|
||||
nullProtoObj['filePath'] = 'test.md';
|
||||
nullProtoObj['size'] = 1024;
|
||||
nullProtoObj['updatedAt'] = '2024-01-01T10:00:00Z';
|
||||
|
||||
expect(isSaveFileResponse(nullProtoObj)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance with large data', () => {
|
||||
it('handles large paths arrays efficiently', () => {
|
||||
const largePaths = Array.from({ length: 10000 }, (_, i) => `path${i}.md`);
|
||||
const largeResponse = {
|
||||
paths: largePaths,
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
const result = isLookupResponse(largeResponse);
|
||||
const end = performance.now();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(end - start).toBeLessThan(100); // Should complete in under 100ms
|
||||
});
|
||||
|
||||
it('handles very long file paths', () => {
|
||||
const longPath = 'a'.repeat(10000);
|
||||
const responseWithLongPath: SaveFileResponse = {
|
||||
filePath: longPath,
|
||||
size: 1024,
|
||||
updatedAt: '2024-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
expect(isSaveFileResponse(responseWithLongPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
867
app/src/types/models.test.ts
Normal file
867
app/src/types/models.test.ts
Normal file
@@ -0,0 +1,867 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isUser,
|
||||
isUserRole,
|
||||
isWorkspace,
|
||||
isFileNode,
|
||||
isSystemStats,
|
||||
UserRole,
|
||||
Theme,
|
||||
type User,
|
||||
type Workspace,
|
||||
type FileNode,
|
||||
type SystemStats,
|
||||
} from './models';
|
||||
|
||||
describe('Models Type Guards', () => {
|
||||
describe('isUserRole', () => {
|
||||
it('returns true for valid admin role', () => {
|
||||
expect(isUserRole(UserRole.Admin)).toBe(true);
|
||||
expect(isUserRole('admin')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid editor role', () => {
|
||||
expect(isUserRole(UserRole.Editor)).toBe(true);
|
||||
expect(isUserRole('editor')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid viewer role', () => {
|
||||
expect(isUserRole(UserRole.Viewer)).toBe(true);
|
||||
expect(isUserRole('viewer')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid role strings', () => {
|
||||
expect(isUserRole('invalid')).toBe(false);
|
||||
expect(isUserRole('Administrator')).toBe(false);
|
||||
expect(isUserRole('ADMIN')).toBe(false);
|
||||
expect(isUserRole('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-string values', () => {
|
||||
expect(isUserRole(123)).toBe(false);
|
||||
expect(isUserRole(null)).toBe(false);
|
||||
expect(isUserRole(undefined)).toBe(false);
|
||||
expect(isUserRole({})).toBe(false);
|
||||
expect(isUserRole([])).toBe(false);
|
||||
expect(isUserRole(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousRole = 'editor';
|
||||
Object.setPrototypeOf(maliciousRole, {
|
||||
toString: () => 'malicious',
|
||||
valueOf: () => 'malicious',
|
||||
});
|
||||
|
||||
expect(isUserRole(maliciousRole)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUser', () => {
|
||||
const validUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
it('returns true for valid user with all fields', () => {
|
||||
expect(isUser(validUser)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid user without optional displayName', () => {
|
||||
const userWithoutDisplayName: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
expect(isUser(userWithoutDisplayName)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid user with empty displayName', () => {
|
||||
const userWithEmptyDisplayName = {
|
||||
...validUser,
|
||||
displayName: '',
|
||||
};
|
||||
|
||||
expect(isUser(userWithEmptyDisplayName)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isUser(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isUser(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isUser('string')).toBe(false);
|
||||
expect(isUser(123)).toBe(false);
|
||||
expect(isUser(true)).toBe(false);
|
||||
expect(isUser([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isUser({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when id is missing', () => {
|
||||
const { id: _id, ...userWithoutId } = validUser;
|
||||
expect(isUser(userWithoutId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when id is not a number', () => {
|
||||
const userWithInvalidId = { ...validUser, id: '1' };
|
||||
expect(isUser(userWithInvalidId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when email is missing', () => {
|
||||
const { email: _email, ...userWithoutEmail } = validUser;
|
||||
expect(isUser(userWithoutEmail)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when email is not a string', () => {
|
||||
const userWithInvalidEmail = { ...validUser, email: 123 };
|
||||
expect(isUser(userWithInvalidEmail)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when displayName is not a string', () => {
|
||||
const userWithInvalidDisplayName = { ...validUser, displayName: 123 };
|
||||
expect(isUser(userWithInvalidDisplayName)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when role is missing', () => {
|
||||
const { role: _role, ...userWithoutRole } = validUser;
|
||||
expect(isUser(userWithoutRole)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when role is invalid', () => {
|
||||
const userWithInvalidRole = { ...validUser, role: 'invalid' };
|
||||
expect(isUser(userWithInvalidRole)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when createdAt is missing', () => {
|
||||
const { createdAt: _createdAt, ...userWithoutCreatedAt } = validUser;
|
||||
expect(isUser(userWithoutCreatedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when createdAt is not a string', () => {
|
||||
const userWithInvalidCreatedAt = { ...validUser, createdAt: new Date() };
|
||||
expect(isUser(userWithInvalidCreatedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when lastWorkspaceId is missing', () => {
|
||||
const {
|
||||
lastWorkspaceId: _lastWorkspaceId,
|
||||
...userWithoutLastWorkspaceId
|
||||
} = validUser;
|
||||
expect(isUser(userWithoutLastWorkspaceId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when lastWorkspaceId is not a number', () => {
|
||||
const userWithInvalidLastWorkspaceId = {
|
||||
...validUser,
|
||||
lastWorkspaceId: '1',
|
||||
};
|
||||
expect(isUser(userWithInvalidLastWorkspaceId)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const userWithExtra = {
|
||||
...validUser,
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isUser(userWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isUser(maliciousUser)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles different user roles', () => {
|
||||
expect(isUser({ ...validUser, role: UserRole.Admin })).toBe(true);
|
||||
expect(isUser({ ...validUser, role: UserRole.Editor })).toBe(true);
|
||||
expect(isUser({ ...validUser, role: UserRole.Viewer })).toBe(true);
|
||||
});
|
||||
|
||||
it('handles various email formats', () => {
|
||||
const emailFormats = [
|
||||
'simple@example.com',
|
||||
'user.name@example.com',
|
||||
'user+tag@example.com',
|
||||
'user123@example-domain.com',
|
||||
'very.long.email.address@very.long.domain.name.com',
|
||||
];
|
||||
|
||||
emailFormats.forEach((email) => {
|
||||
expect(isUser({ ...validUser, email })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspace', () => {
|
||||
const validWorkspace: Workspace = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'test-workspace',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
theme: Theme.Light,
|
||||
autoSave: false,
|
||||
showHiddenFiles: false,
|
||||
gitEnabled: false,
|
||||
gitUrl: '',
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
gitCommitName: '',
|
||||
gitCommitEmail: '',
|
||||
};
|
||||
|
||||
it('returns true for valid workspace with all fields', () => {
|
||||
expect(isWorkspace(validWorkspace)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for workspace without optional id and userId', () => {
|
||||
const {
|
||||
id: _id,
|
||||
userId: _userId,
|
||||
...workspaceWithoutIds
|
||||
} = validWorkspace;
|
||||
expect(isWorkspace(workspaceWithoutIds)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for workspace with numeric createdAt', () => {
|
||||
const workspaceWithNumericCreatedAt = {
|
||||
...validWorkspace,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(isWorkspace(workspaceWithNumericCreatedAt)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for workspace with dark theme', () => {
|
||||
const darkWorkspace = {
|
||||
...validWorkspace,
|
||||
theme: Theme.Dark,
|
||||
};
|
||||
|
||||
expect(isWorkspace(darkWorkspace)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for workspace with git enabled', () => {
|
||||
const gitWorkspace = {
|
||||
...validWorkspace,
|
||||
gitEnabled: true,
|
||||
gitUrl: 'https://github.com/user/repo.git',
|
||||
gitUser: 'username',
|
||||
gitToken: 'token123',
|
||||
gitAutoCommit: true,
|
||||
gitCommitMsgTemplate: 'auto: ${action} ${filename}',
|
||||
gitCommitName: 'Git User',
|
||||
gitCommitEmail: 'git@example.com',
|
||||
};
|
||||
|
||||
expect(isWorkspace(gitWorkspace)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isWorkspace(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isWorkspace(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isWorkspace('string')).toBe(false);
|
||||
expect(isWorkspace(123)).toBe(false);
|
||||
expect(isWorkspace(true)).toBe(false);
|
||||
expect(isWorkspace([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isWorkspace({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when name is missing', () => {
|
||||
const { name: _name, ...workspaceWithoutName } = validWorkspace;
|
||||
expect(isWorkspace(workspaceWithoutName)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when name is not a string', () => {
|
||||
const workspaceWithInvalidName = { ...validWorkspace, name: 123 };
|
||||
expect(isWorkspace(workspaceWithInvalidName)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when createdAt is missing', () => {
|
||||
const { createdAt: _createdAt, ...workspaceWithoutCreatedAt } =
|
||||
validWorkspace;
|
||||
expect(isWorkspace(workspaceWithoutCreatedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when createdAt is neither string nor number', () => {
|
||||
const workspaceWithInvalidCreatedAt = {
|
||||
...validWorkspace,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
expect(isWorkspace(workspaceWithInvalidCreatedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when theme is missing', () => {
|
||||
const { theme: _theme, ...workspaceWithoutTheme } = validWorkspace;
|
||||
expect(isWorkspace(workspaceWithoutTheme)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when theme is not a string', () => {
|
||||
const workspaceWithInvalidTheme = { ...validWorkspace, theme: 123 };
|
||||
expect(isWorkspace(workspaceWithInvalidTheme)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when boolean fields are not boolean', () => {
|
||||
const booleanFields = [
|
||||
'autoSave',
|
||||
'showHiddenFiles',
|
||||
'gitEnabled',
|
||||
'gitAutoCommit',
|
||||
];
|
||||
|
||||
booleanFields.forEach((field) => {
|
||||
const workspaceWithInvalidBoolean = {
|
||||
...validWorkspace,
|
||||
[field]: 'true',
|
||||
};
|
||||
expect(isWorkspace(workspaceWithInvalidBoolean)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when string fields are not strings', () => {
|
||||
const stringFields = [
|
||||
'gitUrl',
|
||||
'gitUser',
|
||||
'gitToken',
|
||||
'gitCommitMsgTemplate',
|
||||
'gitCommitName',
|
||||
'gitCommitEmail',
|
||||
];
|
||||
|
||||
stringFields.forEach((field) => {
|
||||
const workspaceWithInvalidString = { ...validWorkspace, [field]: 123 };
|
||||
expect(isWorkspace(workspaceWithInvalidString)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const workspaceWithExtra = {
|
||||
...validWorkspace,
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isWorkspace(workspaceWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousWorkspace = {
|
||||
...validWorkspace,
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isWorkspace(maliciousWorkspace)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles various workspace names', () => {
|
||||
const workspaceNames = [
|
||||
'simple',
|
||||
'workspace-with-dashes',
|
||||
'workspace_with_underscores',
|
||||
'workspace with spaces',
|
||||
'workspace123',
|
||||
'very-long-workspace-name-with-many-characters',
|
||||
'unicode-工作区',
|
||||
];
|
||||
|
||||
workspaceNames.forEach((name) => {
|
||||
expect(isWorkspace({ ...validWorkspace, name })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFileNode', () => {
|
||||
const validFileNode: FileNode = {
|
||||
id: '1',
|
||||
name: 'test.md',
|
||||
path: 'documents/test.md',
|
||||
};
|
||||
|
||||
const validFolderNode: FileNode = {
|
||||
id: '2',
|
||||
name: 'documents',
|
||||
path: 'documents',
|
||||
children: [
|
||||
{
|
||||
id: '3',
|
||||
name: 'nested.md',
|
||||
path: 'documents/nested.md',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('returns true for valid file node without children', () => {
|
||||
expect(isFileNode(validFileNode)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid folder node with children', () => {
|
||||
expect(isFileNode(validFolderNode)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for node with empty children array', () => {
|
||||
const nodeWithEmptyChildren = {
|
||||
...validFileNode,
|
||||
children: [],
|
||||
};
|
||||
|
||||
expect(isFileNode(nodeWithEmptyChildren)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for node with null children', () => {
|
||||
const nodeWithNullChildren = {
|
||||
...validFileNode,
|
||||
children: null,
|
||||
};
|
||||
|
||||
expect(isFileNode(nodeWithNullChildren)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for node with undefined children', () => {
|
||||
const nodeWithUndefinedChildren = {
|
||||
...validFileNode,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
expect(isFileNode(nodeWithUndefinedChildren)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isFileNode(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isFileNode(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isFileNode('string')).toBe(false);
|
||||
expect(isFileNode(123)).toBe(false);
|
||||
expect(isFileNode(true)).toBe(false);
|
||||
expect(isFileNode([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isFileNode({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when id is missing', () => {
|
||||
const { id: _id, ...nodeWithoutId } = validFileNode;
|
||||
expect(isFileNode(nodeWithoutId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when id is not a string', () => {
|
||||
const nodeWithInvalidId = { ...validFileNode, id: 123 };
|
||||
expect(isFileNode(nodeWithInvalidId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when name is missing', () => {
|
||||
const { name: _name, ...nodeWithoutName } = validFileNode;
|
||||
expect(isFileNode(nodeWithoutName)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when name is not a string', () => {
|
||||
const nodeWithInvalidName = { ...validFileNode, name: 123 };
|
||||
expect(isFileNode(nodeWithInvalidName)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when path is missing', () => {
|
||||
const { path: _path, ...nodeWithoutPath } = validFileNode;
|
||||
expect(isFileNode(nodeWithoutPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when path is not a string', () => {
|
||||
const nodeWithInvalidPath = { ...validFileNode, path: 123 };
|
||||
expect(isFileNode(nodeWithInvalidPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when children is not an array', () => {
|
||||
const nodeWithInvalidChildren = {
|
||||
...validFileNode,
|
||||
children: 'not-an-array',
|
||||
};
|
||||
|
||||
expect(isFileNode(nodeWithInvalidChildren)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles nested file structures', () => {
|
||||
const deeplyNestedNode: FileNode = {
|
||||
id: '1',
|
||||
name: 'root',
|
||||
path: 'root',
|
||||
children: [
|
||||
{
|
||||
id: '2',
|
||||
name: 'level1',
|
||||
path: 'root/level1',
|
||||
children: [
|
||||
{
|
||||
id: '3',
|
||||
name: 'level2',
|
||||
path: 'root/level1/level2',
|
||||
children: [
|
||||
{
|
||||
id: '4',
|
||||
name: 'deep-file.md',
|
||||
path: 'root/level1/level2/deep-file.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(isFileNode(deeplyNestedNode)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles various file and folder names', () => {
|
||||
const names = [
|
||||
'simple.md',
|
||||
'file-with-dashes.md',
|
||||
'file_with_underscores.md',
|
||||
'file with spaces.md',
|
||||
'file.with.dots.md',
|
||||
'UPPERCASE.MD',
|
||||
'MixedCase.Md',
|
||||
'unicode-文件.md',
|
||||
'no-extension',
|
||||
'.hidden-file',
|
||||
'folder',
|
||||
];
|
||||
|
||||
names.forEach((name) => {
|
||||
const node = {
|
||||
id: '1',
|
||||
name,
|
||||
path: name,
|
||||
};
|
||||
expect(isFileNode(node)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const nodeWithExtra = {
|
||||
...validFileNode,
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isFileNode(nodeWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousNode = {
|
||||
...validFileNode,
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isFileNode(maliciousNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSystemStats', () => {
|
||||
const validSystemStats: SystemStats = {
|
||||
totalUsers: 10,
|
||||
totalWorkspaces: 5,
|
||||
activeUsers: 8,
|
||||
totalFiles: 100,
|
||||
totalSize: 1024000,
|
||||
};
|
||||
|
||||
it('returns true for valid system stats', () => {
|
||||
expect(isSystemStats(validSystemStats)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for stats with zero values', () => {
|
||||
const statsWithZeros = {
|
||||
totalUsers: 0,
|
||||
totalWorkspaces: 0,
|
||||
activeUsers: 0,
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
};
|
||||
|
||||
expect(isSystemStats(statsWithZeros)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for stats with large numbers', () => {
|
||||
const statsWithLargeNumbers = {
|
||||
totalUsers: 999999,
|
||||
totalWorkspaces: 888888,
|
||||
activeUsers: 777777,
|
||||
totalFiles: 666666,
|
||||
totalSize: 555555555555,
|
||||
};
|
||||
|
||||
expect(isSystemStats(statsWithLargeNumbers)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isSystemStats(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isSystemStats(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object values', () => {
|
||||
expect(isSystemStats('string')).toBe(false);
|
||||
expect(isSystemStats(123)).toBe(false);
|
||||
expect(isSystemStats(true)).toBe(false);
|
||||
expect(isSystemStats([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty object', () => {
|
||||
expect(isSystemStats({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalUsers is missing', () => {
|
||||
const { totalUsers: _totalUsers, ...statsWithoutTotalUsers } =
|
||||
validSystemStats;
|
||||
expect(isSystemStats(statsWithoutTotalUsers)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalUsers is not a number', () => {
|
||||
const statsWithInvalidTotalUsers = {
|
||||
...validSystemStats,
|
||||
totalUsers: '10',
|
||||
};
|
||||
expect(isSystemStats(statsWithInvalidTotalUsers)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalWorkspaces is missing', () => {
|
||||
const {
|
||||
totalWorkspaces: _totalWorkspaces,
|
||||
...statsWithoutTotalWorkspaces
|
||||
} = validSystemStats;
|
||||
expect(isSystemStats(statsWithoutTotalWorkspaces)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalWorkspaces is not a number', () => {
|
||||
const statsWithInvalidTotalWorkspaces = {
|
||||
...validSystemStats,
|
||||
totalWorkspaces: '5',
|
||||
};
|
||||
expect(isSystemStats(statsWithInvalidTotalWorkspaces)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when activeUsers is missing', () => {
|
||||
const { activeUsers: _activeUsers, ...statsWithoutActiveUsers } =
|
||||
validSystemStats;
|
||||
expect(isSystemStats(statsWithoutActiveUsers)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when activeUsers is not a number', () => {
|
||||
const statsWithInvalidActiveUsers = {
|
||||
...validSystemStats,
|
||||
activeUsers: '8',
|
||||
};
|
||||
expect(isSystemStats(statsWithInvalidActiveUsers)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalFiles is missing', () => {
|
||||
const { totalFiles: _totalFiles, ...statsWithoutTotalFiles } =
|
||||
validSystemStats;
|
||||
expect(isSystemStats(statsWithoutTotalFiles)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalFiles is not a number', () => {
|
||||
const statsWithInvalidTotalFiles = {
|
||||
...validSystemStats,
|
||||
totalFiles: '100',
|
||||
};
|
||||
expect(isSystemStats(statsWithInvalidTotalFiles)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalSize is missing', () => {
|
||||
const { totalSize: _totalSize, ...statsWithoutTotalSize } =
|
||||
validSystemStats;
|
||||
expect(isSystemStats(statsWithoutTotalSize)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when totalSize is not a number', () => {
|
||||
const statsWithInvalidTotalSize = {
|
||||
...validSystemStats,
|
||||
totalSize: '1024000',
|
||||
};
|
||||
expect(isSystemStats(statsWithInvalidTotalSize)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles objects with extra properties', () => {
|
||||
const statsWithExtra = {
|
||||
...validSystemStats,
|
||||
extraField: 'should be ignored',
|
||||
};
|
||||
|
||||
expect(isSystemStats(statsWithExtra)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects with prototype pollution attempts', () => {
|
||||
const maliciousStats = {
|
||||
...validSystemStats,
|
||||
__proto__: { malicious: true },
|
||||
constructor: { prototype: { polluted: true } },
|
||||
};
|
||||
|
||||
expect(isSystemStats(maliciousStats)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles floating point numbers', () => {
|
||||
const statsWithFloats = {
|
||||
totalUsers: 10.5,
|
||||
totalWorkspaces: 5.7,
|
||||
activeUsers: 8.2,
|
||||
totalFiles: 100.9,
|
||||
totalSize: 1024000.123,
|
||||
};
|
||||
|
||||
expect(isSystemStats(statsWithFloats)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles negative numbers', () => {
|
||||
const statsWithNegatives = {
|
||||
totalUsers: -10,
|
||||
totalWorkspaces: -5,
|
||||
activeUsers: -8,
|
||||
totalFiles: -100,
|
||||
totalSize: -1024000,
|
||||
};
|
||||
|
||||
expect(isSystemStats(statsWithNegatives)).toBe(true); // Type guard doesn't validate ranges
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error conditions', () => {
|
||||
it('handles circular references gracefully', () => {
|
||||
const circularUser: Record<string, unknown> = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
circularUser['self'] = circularUser;
|
||||
|
||||
expect(isUser(circularUser)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles deeply nested file structures', () => {
|
||||
let deepNode: FileNode = {
|
||||
id: '1',
|
||||
name: 'root',
|
||||
path: 'root',
|
||||
};
|
||||
|
||||
// Create a deeply nested structure
|
||||
for (let i = 2; i <= 100; i++) {
|
||||
deepNode = {
|
||||
id: i.toString(),
|
||||
name: `level${i}`,
|
||||
path: `root/level${i}`,
|
||||
children: [deepNode],
|
||||
};
|
||||
}
|
||||
|
||||
expect(isFileNode(deepNode)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles frozen objects', () => {
|
||||
const frozenUser = Object.freeze({
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
});
|
||||
|
||||
expect(isUser(frozenUser)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles objects created with null prototype', () => {
|
||||
const nullProtoStats = Object.create(null) as Record<string, unknown>;
|
||||
nullProtoStats['totalUsers'] = 10;
|
||||
nullProtoStats['totalWorkspaces'] = 5;
|
||||
nullProtoStats['activeUsers'] = 8;
|
||||
nullProtoStats['totalFiles'] = 100;
|
||||
nullProtoStats['totalSize'] = 1024000;
|
||||
|
||||
expect(isSystemStats(nullProtoStats)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance with large data', () => {
|
||||
it('handles large file trees efficiently', () => {
|
||||
const largeChildren = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i.toString(),
|
||||
name: `file${i}.md`,
|
||||
path: `folder/file${i}.md`,
|
||||
}));
|
||||
|
||||
const largeFileTree: FileNode = {
|
||||
id: 'root',
|
||||
name: 'folder',
|
||||
path: 'folder',
|
||||
children: largeChildren,
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
const result = isFileNode(largeFileTree);
|
||||
const end = performance.now();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(end - start).toBeLessThan(100); // Should complete in under 100ms
|
||||
});
|
||||
|
||||
it('handles very long strings efficiently', () => {
|
||||
const longString = 'a'.repeat(100000);
|
||||
const userWithLongEmail = {
|
||||
id: 1,
|
||||
email: longString,
|
||||
role: UserRole.Editor,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
lastWorkspaceId: 1,
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
const result = isUser(userWithLongEmail);
|
||||
const end = performance.now();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(end - start).toBeLessThan(10); // Should be very fast
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Parent } from 'unist';
|
||||
|
||||
/**
|
||||
* User model from the API
|
||||
*/
|
||||
@@ -206,12 +208,46 @@ export interface WorkspaceStats {
|
||||
fileCountStats?: FileCountStats;
|
||||
}
|
||||
|
||||
// isWorkspaceStats checks if the given object is a valid WorkspaceStats object
|
||||
export function isWorkspaceStats(obj: unknown): obj is WorkspaceStats {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
'userID' in obj &&
|
||||
typeof (obj as WorkspaceStats).userID === 'number' &&
|
||||
'userEmail' in obj &&
|
||||
typeof (obj as WorkspaceStats).userEmail === 'string' &&
|
||||
'workspaceID' in obj &&
|
||||
typeof (obj as WorkspaceStats).workspaceID === 'number' &&
|
||||
'workspaceName' in obj &&
|
||||
typeof (obj as WorkspaceStats).workspaceName === 'string' &&
|
||||
'workspaceCreatedAt' in obj &&
|
||||
typeof (obj as WorkspaceStats).workspaceCreatedAt === 'string' &&
|
||||
(!('fileCountStats' in obj) ||
|
||||
(obj as WorkspaceStats).fileCountStats === undefined ||
|
||||
(obj as WorkspaceStats).fileCountStats === null ||
|
||||
isFileCountStats((obj as WorkspaceStats).fileCountStats))
|
||||
);
|
||||
}
|
||||
|
||||
// Define FileCountStats based on the Go struct definition of storage.FileCountStats
|
||||
export interface FileCountStats {
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
// isFileCountStats checks if the given object is a valid FileCountStats object
|
||||
export function isFileCountStats(obj: unknown): obj is FileCountStats {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
'totalFiles' in obj &&
|
||||
typeof (obj as FileCountStats).totalFiles === 'number' &&
|
||||
'totalSize' in obj &&
|
||||
typeof (obj as FileCountStats).totalSize === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalUsers: number;
|
||||
totalWorkspaces: number;
|
||||
@@ -285,3 +321,66 @@ export interface SettingsAction<T> {
|
||||
type: SettingsActionType;
|
||||
payload?: T;
|
||||
}
|
||||
|
||||
// WikiLinks
|
||||
|
||||
/**
|
||||
* Represents a wiki link match from the regex
|
||||
*/
|
||||
export interface WikiLinkMatch {
|
||||
fullMatch: string;
|
||||
isImage: boolean; // Changed from string to boolean
|
||||
fileName: string;
|
||||
displayText: string;
|
||||
heading?: string | undefined;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node replacement information for processing
|
||||
*/
|
||||
export interface ReplacementInfo {
|
||||
matches: WikiLinkMatch[];
|
||||
parent: Parent;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for link nodes
|
||||
*/
|
||||
export interface LinkNodeProps {
|
||||
style?: {
|
||||
color?: string;
|
||||
textDecoration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Link node with data properties
|
||||
*/
|
||||
export interface LinkNode extends Node {
|
||||
type: 'link';
|
||||
url: string;
|
||||
children: Node[];
|
||||
data?: {
|
||||
hProperties?: LinkNodeProps;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Image node
|
||||
*/
|
||||
export interface ImageNode extends Node {
|
||||
type: 'image';
|
||||
url: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text node
|
||||
*/
|
||||
export interface TextNode extends Node {
|
||||
type: 'text';
|
||||
value: string;
|
||||
}
|
||||
|
||||
167
app/src/utils/fileHelpers.test.ts
Normal file
167
app/src/utils/fileHelpers.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { isImageFile, getFileUrl } from './fileHelpers';
|
||||
|
||||
describe('fileHelpers', () => {
|
||||
beforeEach(() => {
|
||||
// Ensure API_BASE_URL is set for tests
|
||||
window.API_BASE_URL = 'http://localhost:8080/api/v1';
|
||||
});
|
||||
|
||||
describe('isImageFile', () => {
|
||||
it('returns true for supported image file extensions', () => {
|
||||
expect(isImageFile('image.jpg')).toBe(true);
|
||||
expect(isImageFile('image.jpeg')).toBe(true);
|
||||
expect(isImageFile('image.png')).toBe(true);
|
||||
expect(isImageFile('image.gif')).toBe(true);
|
||||
expect(isImageFile('image.webp')).toBe(true);
|
||||
expect(isImageFile('image.svg')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for uppercase image file extensions', () => {
|
||||
expect(isImageFile('image.JPG')).toBe(true);
|
||||
expect(isImageFile('image.JPEG')).toBe(true);
|
||||
expect(isImageFile('image.PNG')).toBe(true);
|
||||
expect(isImageFile('image.GIF')).toBe(true);
|
||||
expect(isImageFile('image.WEBP')).toBe(true);
|
||||
expect(isImageFile('image.SVG')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mixed case image file extensions', () => {
|
||||
expect(isImageFile('image.JpG')).toBe(true);
|
||||
expect(isImageFile('image.JpEg')).toBe(true);
|
||||
expect(isImageFile('image.PnG')).toBe(true);
|
||||
expect(isImageFile('screenshot.WeBp')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-image file extensions', () => {
|
||||
expect(isImageFile('document.md')).toBe(false);
|
||||
expect(isImageFile('document.txt')).toBe(false);
|
||||
expect(isImageFile('document.pdf')).toBe(false);
|
||||
expect(isImageFile('document.docx')).toBe(false);
|
||||
expect(isImageFile('script.js')).toBe(false);
|
||||
expect(isImageFile('style.css')).toBe(false);
|
||||
expect(isImageFile('data.json')).toBe(false);
|
||||
expect(isImageFile('archive.zip')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for files without extensions', () => {
|
||||
expect(isImageFile('README')).toBe(false);
|
||||
expect(isImageFile('Dockerfile')).toBe(false);
|
||||
expect(isImageFile('LICENSE')).toBe(false);
|
||||
expect(isImageFile('Makefile')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles complex file paths correctly', () => {
|
||||
expect(isImageFile('path/to/image.jpg')).toBe(true);
|
||||
expect(isImageFile('./relative/path/image.png')).toBe(true);
|
||||
expect(isImageFile('/absolute/path/image.gif')).toBe(true);
|
||||
expect(isImageFile('../../parent/image.svg')).toBe(true);
|
||||
expect(isImageFile('path/to/document.md')).toBe(false);
|
||||
expect(isImageFile('./config/settings.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles files with multiple dots in filename', () => {
|
||||
expect(isImageFile('my.image.file.jpg')).toBe(true);
|
||||
expect(isImageFile('config.backup.json')).toBe(false);
|
||||
expect(isImageFile('version.1.2.png')).toBe(true);
|
||||
expect(isImageFile('app.config.local.js')).toBe(false);
|
||||
expect(isImageFile('test.component.spec.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles edge cases', () => {
|
||||
expect(isImageFile('')).toBe(false);
|
||||
expect(isImageFile('.')).toBe(false);
|
||||
expect(isImageFile('.jpg')).toBe(true);
|
||||
expect(isImageFile('.hidden.png')).toBe(true);
|
||||
expect(isImageFile('file.')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileUrl', () => {
|
||||
it('constructs correct file URL with simple parameters', () => {
|
||||
const workspaceName = 'my-workspace';
|
||||
const filePath = 'folder/file.md';
|
||||
|
||||
const expectedUrl =
|
||||
'http://localhost:8080/api/v1/workspaces/my-workspace/files/folder%2Ffile.md';
|
||||
const actualUrl = getFileUrl(workspaceName, filePath);
|
||||
|
||||
expect(actualUrl).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('properly encodes workspace name with special characters', () => {
|
||||
const workspaceName = 'my workspace with spaces';
|
||||
const filePath = 'file.md';
|
||||
|
||||
const expectedUrl =
|
||||
'http://localhost:8080/api/v1/workspaces/my%20workspace%20with%20spaces/files/file.md';
|
||||
const actualUrl = getFileUrl(workspaceName, filePath);
|
||||
|
||||
expect(actualUrl).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('properly encodes file path with special characters', () => {
|
||||
const workspaceName = 'workspace';
|
||||
const filePath = 'folder with spaces/file with spaces.md';
|
||||
|
||||
const expectedUrl =
|
||||
'http://localhost:8080/api/v1/workspaces/workspace/files/folder%20with%20spaces%2Ffile%20with%20spaces.md';
|
||||
const actualUrl = getFileUrl(workspaceName, filePath);
|
||||
|
||||
expect(actualUrl).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('handles special URL characters that need encoding', () => {
|
||||
const workspaceName = 'test&workspace';
|
||||
const filePath = 'file?name=test.md';
|
||||
|
||||
const expectedUrl =
|
||||
'http://localhost:8080/api/v1/workspaces/test%26workspace/files/file%3Fname%3Dtest.md';
|
||||
const actualUrl = getFileUrl(workspaceName, filePath);
|
||||
|
||||
expect(actualUrl).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('handles Unicode characters', () => {
|
||||
const workspaceName = 'プロジェクト';
|
||||
const filePath = 'ファイル.md';
|
||||
|
||||
const expectedUrl =
|
||||
'http://localhost:8080/api/v1/workspaces/%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88/files/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md';
|
||||
const actualUrl = getFileUrl(workspaceName, filePath);
|
||||
|
||||
expect(actualUrl).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('handles nested folder structures', () => {
|
||||
const workspaceName = 'docs';
|
||||
const filePath = 'projects/2024/q1/report.md';
|
||||
|
||||
const expectedUrl =
|
||||
'http://localhost:8080/api/v1/workspaces/docs/files/projects%2F2024%2Fq1%2Freport.md';
|
||||
const actualUrl = getFileUrl(workspaceName, filePath);
|
||||
|
||||
expect(actualUrl).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('handles edge cases with empty strings', () => {
|
||||
expect(getFileUrl('', '')).toBe(
|
||||
'http://localhost:8080/api/v1/workspaces//files/'
|
||||
);
|
||||
expect(getFileUrl('workspace', '')).toBe(
|
||||
'http://localhost:8080/api/v1/workspaces/workspace/files/'
|
||||
);
|
||||
expect(getFileUrl('', 'file.md')).toBe(
|
||||
'http://localhost:8080/api/v1/workspaces//files/file.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the API base URL correctly', () => {
|
||||
const url = getFileUrl('test', 'file.md');
|
||||
expect(url).toBe(
|
||||
'http://localhost:8080/api/v1/workspaces/test/files/file.md'
|
||||
);
|
||||
expect(url).toContain(window.API_BASE_URL);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
app/src/utils/formatBytes.test.ts
Normal file
124
app/src/utils/formatBytes.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatBytes } from './formatBytes';
|
||||
|
||||
describe('formatBytes', () => {
|
||||
describe('bytes formatting', () => {
|
||||
it('formats small byte values correctly', () => {
|
||||
expect(formatBytes(0)).toBe('0.0 B');
|
||||
expect(formatBytes(1)).toBe('1.0 B');
|
||||
expect(formatBytes(512)).toBe('512.0 B');
|
||||
expect(formatBytes(1023)).toBe('1023.0 B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kilobytes formatting', () => {
|
||||
it('formats kilobyte values correctly', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
expect(formatBytes(5120)).toBe('5.0 KB');
|
||||
expect(formatBytes(1048575)).toBe('1024.0 KB'); // Just under 1MB
|
||||
});
|
||||
|
||||
it('handles fractional kilobytes', () => {
|
||||
expect(formatBytes(1433)).toBe('1.4 KB');
|
||||
expect(formatBytes(1587)).toBe('1.5 KB');
|
||||
expect(formatBytes(1741)).toBe('1.7 KB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('megabytes formatting', () => {
|
||||
it('formats megabyte values correctly', () => {
|
||||
expect(formatBytes(1048576)).toBe('1.0 MB'); // 1024^2
|
||||
expect(formatBytes(1572864)).toBe('1.5 MB');
|
||||
expect(formatBytes(2097152)).toBe('2.0 MB');
|
||||
expect(formatBytes(5242880)).toBe('5.0 MB');
|
||||
expect(formatBytes(1073741823)).toBe('1024.0 MB'); // Just under 1GB
|
||||
});
|
||||
|
||||
it('handles fractional megabytes', () => {
|
||||
expect(formatBytes(1638400)).toBe('1.6 MB');
|
||||
expect(formatBytes(2621440)).toBe('2.5 MB');
|
||||
expect(formatBytes(10485760)).toBe('10.0 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gigabytes formatting', () => {
|
||||
it('formats gigabyte values correctly', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1.0 GB'); // 1024^3
|
||||
expect(formatBytes(1610612736)).toBe('1.5 GB');
|
||||
expect(formatBytes(2147483648)).toBe('2.0 GB');
|
||||
expect(formatBytes(5368709120)).toBe('5.0 GB');
|
||||
});
|
||||
|
||||
it('handles fractional gigabytes', () => {
|
||||
expect(formatBytes(1288490188.8)).toBe('1.2 GB');
|
||||
expect(formatBytes(3221225472)).toBe('3.0 GB');
|
||||
expect(formatBytes(10737418240)).toBe('10.0 GB');
|
||||
});
|
||||
|
||||
it('handles very large gigabyte values', () => {
|
||||
expect(formatBytes(1099511627776)).toBe('1024.0 GB'); // 1TB but capped at GB
|
||||
expect(formatBytes(2199023255552)).toBe('2048.0 GB'); // 2TB but capped at GB
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimal precision', () => {
|
||||
it('always shows one decimal place', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(1048576)).toBe('1.0 MB');
|
||||
expect(formatBytes(1073741824)).toBe('1.0 GB');
|
||||
});
|
||||
|
||||
it('rounds to one decimal place correctly', () => {
|
||||
expect(formatBytes(1126)).toBe('1.1 KB'); // 1126 / 1024 = 1.099...
|
||||
expect(formatBytes(1177)).toBe('1.1 KB'); // 1177 / 1024 = 1.149...
|
||||
expect(formatBytes(1229)).toBe('1.2 KB'); // 1229 / 1024 = 1.200...
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles exact unit boundaries', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
expect(formatBytes(1048576)).toBe('1.0 MB');
|
||||
expect(formatBytes(1073741824)).toBe('1.0 GB');
|
||||
});
|
||||
|
||||
it('handles very small decimal values', () => {
|
||||
expect(formatBytes(0.1)).toBe('0.1 B');
|
||||
expect(formatBytes(0.9)).toBe('0.9 B');
|
||||
});
|
||||
|
||||
it('handles negative values (edge case)', () => {
|
||||
expect(() => formatBytes(-1024)).toThrowError(
|
||||
'Byte size cannot be negative'
|
||||
);
|
||||
expect(() => formatBytes(-1048576)).toThrowError(
|
||||
'Byte size cannot be negative'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles extremely large values', () => {
|
||||
const largeValue = Number.MAX_SAFE_INTEGER;
|
||||
const result = formatBytes(largeValue);
|
||||
expect(result).toContain('GB');
|
||||
expect(result).toMatch(/^\d+\.\d GB$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unit progression', () => {
|
||||
it('uses the correct unit for each range', () => {
|
||||
expect(formatBytes(500)).toContain('B');
|
||||
expect(formatBytes(5000)).toContain('KB');
|
||||
expect(formatBytes(5000000)).toContain('MB');
|
||||
expect(formatBytes(5000000000)).toContain('GB');
|
||||
});
|
||||
|
||||
it('stops at GB unit (does not go to TB)', () => {
|
||||
const oneTerabyte = 1024 * 1024 * 1024 * 1024;
|
||||
expect(formatBytes(oneTerabyte)).toContain('GB');
|
||||
expect(formatBytes(oneTerabyte)).not.toContain('TB');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,9 @@ const UNITS: readonly ByteUnit[] = ['B', 'KB', 'MB', 'GB'] as const;
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
let size: number = bytes;
|
||||
let unitIndex: number = 0;
|
||||
if (size < 0) {
|
||||
throw new Error('Byte size cannot be negative');
|
||||
}
|
||||
while (size >= 1024 && unitIndex < UNITS.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
|
||||
324
app/src/utils/remarkWikiLinks.test.ts
Normal file
324
app/src/utils/remarkWikiLinks.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
import { remarkWikiLinks } from './remarkWikiLinks';
|
||||
import * as fileApi from '@/api/file';
|
||||
|
||||
// Mock the file API
|
||||
vi.mock('@/api/file');
|
||||
|
||||
// Mock window.API_BASE_URL
|
||||
const mockApiBaseUrl = 'http://localhost:8080/api/v1';
|
||||
|
||||
describe('remarkWikiLinks', () => {
|
||||
beforeEach(() => {
|
||||
window.API_BASE_URL = mockApiBaseUrl;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createProcessor = (workspaceName: string) => {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkWikiLinks, workspaceName)
|
||||
.use(remarkStringify);
|
||||
};
|
||||
|
||||
describe('basic wiki link processing', () => {
|
||||
it('converts existing file links correctly', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['docs/test.md']);
|
||||
|
||||
const processor = createProcessor('test-workspace');
|
||||
const markdown = 'Check out [[test]] for more info.';
|
||||
|
||||
const result = await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test.md'
|
||||
);
|
||||
expect(result.toString()).toContain('test');
|
||||
});
|
||||
|
||||
it('handles non-existent files with not found links', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue([]);
|
||||
|
||||
const processor = createProcessor('test-workspace');
|
||||
const markdown = 'This [[nonexistent]] file does not exist.';
|
||||
|
||||
const result = await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'nonexistent.md'
|
||||
);
|
||||
expect(result.toString()).toContain('nonexistent');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const processor = createProcessor('test-workspace');
|
||||
const markdown = 'This [[error-file]] causes an error.';
|
||||
|
||||
// Should not throw
|
||||
const result = await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'error-file.md'
|
||||
);
|
||||
expect(result.toString()).toContain('error-file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiki link syntax variations', () => {
|
||||
it('handles basic wiki links', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['basic.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[basic]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'basic.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles wiki links with display text', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['file.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[file|Display Text]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'file.md');
|
||||
});
|
||||
|
||||
it('handles wiki links with headings', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['file.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[file#section]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'file.md');
|
||||
});
|
||||
|
||||
it('handles wiki links with both headings and display text', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['file.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[file#section|Custom Display]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'file.md');
|
||||
});
|
||||
|
||||
it('handles image wiki links', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['image.png']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '![[image.png]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'image.png'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles image wiki links with alt text', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['photo.jpg']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '![[photo.jpg|Alt text for photo]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'photo.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file extension handling', () => {
|
||||
it('adds .md extension to files without extensions', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['notes.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[notes]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'notes.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing file extensions', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['document.txt']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[document.txt]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'document.txt'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles image files without adding .md extension', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['screenshot.png']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '![[screenshot.png]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'screenshot.png'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple wiki links', () => {
|
||||
it('processes multiple wiki links in the same text', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName
|
||||
.mockResolvedValueOnce(['first.md'])
|
||||
.mockResolvedValueOnce(['second.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = 'See [[first]] and [[second]] for details.';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledTimes(2);
|
||||
expect(mockLookupFileByName).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'workspace',
|
||||
'first.md'
|
||||
);
|
||||
expect(mockLookupFileByName).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'workspace',
|
||||
'second.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles mix of existing and non-existing files', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName
|
||||
.mockResolvedValueOnce(['exists.md'])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = 'Check [[exists]] but not [[missing]].';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledTimes(2);
|
||||
expect(mockLookupFileByName).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'workspace',
|
||||
'exists.md'
|
||||
);
|
||||
expect(mockLookupFileByName).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'workspace',
|
||||
'missing.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles text without wiki links', async () => {
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = 'Just regular text with no wiki links.';
|
||||
|
||||
const result = await processor.process(markdown);
|
||||
|
||||
expect(result.toString()).toBe('Just regular text with no wiki links.\n');
|
||||
expect(fileApi.lookupFileByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles wiki links with only spaces', async () => {
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = 'Spaces [[ ]] link.';
|
||||
|
||||
const result = await processor.process(markdown);
|
||||
|
||||
expect(result.toString()).toContain('Spaces');
|
||||
// Should not call API for empty/whitespace-only links
|
||||
expect(fileApi.lookupFileByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles nested brackets', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['test.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[test]] and some [regular](link) text.';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'test.md');
|
||||
});
|
||||
|
||||
it('handles special characters in file names', async () => {
|
||||
const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName);
|
||||
mockLookupFileByName.mockResolvedValue(['file with spaces & symbols.md']);
|
||||
|
||||
const processor = createProcessor('workspace');
|
||||
const markdown = '[[file with spaces & symbols]]';
|
||||
|
||||
await processor.process(markdown);
|
||||
|
||||
expect(mockLookupFileByName).toHaveBeenCalledWith(
|
||||
'workspace',
|
||||
'file with spaces & symbols.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace handling', () => {
|
||||
it('handles empty workspace name gracefully', async () => {
|
||||
const processor = createProcessor('');
|
||||
const markdown = '[[test]]';
|
||||
|
||||
const result = await processor.process(markdown);
|
||||
|
||||
expect(result.toString()).toContain('test');
|
||||
// Should not call API when workspace is empty
|
||||
expect(fileApi.lookupFileByName).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -233,6 +233,13 @@ export function remarkWikiLinks(workspaceName: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Skip API call for empty or whitespace-only filenames
|
||||
if (!match.fileName.trim()) {
|
||||
newNodes.push(createTextNode(match.fullMatch));
|
||||
lastIndex = match.index + match.fullMatch.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookupFileName: string = match.isImage
|
||||
? match.fileName
|
||||
: addMarkdownExtension(match.fileName);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user