132 Commits

Author SHA1 Message Date
76ab168c6e Merge pull request #68 from lordmathis/feat/user-theme
Add user theme setting
2025-11-03 23:43:23 +01:00
6117f7a58f Add theme property to mock user in ProfileSettings tests 2025-11-03 23:35:54 +01:00
ab6cb47047 Add theme attribute to test user creation 2025-11-03 20:53:54 +01:00
4acba662b6 Refactor theme toggle layout in ProfileSettings component 2025-11-03 19:11:50 +01:00
054d9da867 Fix user type guard to validate theme 2025-11-03 17:52:00 +01:00
efdc42cbd7 Add theme support to user settings and related components 2025-10-28 23:14:45 +01:00
3926954b74 Add theme to user preferences 2025-10-28 20:05:12 +01:00
6c408fdfbe Merge pull request #67 from lordmathis/fix/file-upload
Fix file upload
2025-10-28 19:19:01 +01:00
6753f32520 Remove test case for uploading with missing file_path parameter 2025-10-28 19:09:46 +01:00
de06939b01 Fix file upload 2025-10-28 18:58:10 +01:00
c11d956ced Merge pull request #66 from lordmathis/fix/head-request
Handle HEAD requests with static router
2025-10-23 20:35:47 +02:00
9a232819a8 Handle HEAD requests with static router 2025-10-23 20:26:34 +02:00
f9ce8b9e9f Merge pull request #65 from lordmathis/fix/code-highlight
Fix code highlight theme change
2025-10-23 19:24:14 +02:00
a6d2663a7d Add type declarations for CSS imports with ?inline modifier 2025-10-23 19:19:27 +02:00
071e99f4da Remove documentation.md 2025-10-23 19:17:51 +02:00
b13ee987c7 Fix code highlight theme change 2025-10-23 19:12:09 +02:00
543dbe6ffe Merge pull request #64 from lordmathis/fix/file-rename
Fix move file on frontend
2025-10-22 21:55:52 +02:00
d0842c515f Change move file endpoint from PUT to POST and add integration tests for file moving and renaming 2025-10-22 21:50:37 +02:00
01d9a984fc Merge pull request #63 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-7372c7abf2
Bump vite from 6.3.6 to 6.4.1 in /app in the npm_and_yarn group across 1 directory
2025-10-21 10:21:46 +02:00
dependabot[bot]
a3975c9acd Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.6 to 6.4.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 05:07:05 +00:00
e82f25a2ed Merge pull request #62 from lordmathis/fix/image-preview
Fix get image url
2025-10-11 22:56:49 +02:00
ec89f95880 Fix get image url 2025-10-11 22:51:08 +02:00
f101376bef Merge pull request #61 from lordmathis/fix/syntax-highlight
Replace react-syntax-highlighter with rehype-highlight
2025-10-11 22:49:21 +02:00
d582b1a1e9 Add syntax highlighting themes for markdown preview 2025-10-11 22:26:21 +02:00
aca127e52e Move rate limiting for authentication endpoints to the public routes group 2025-10-11 22:16:15 +02:00
9ca8a46093 Replace react-syntax-highlighter with rehype-highlight 2025-10-11 22:02:02 +02:00
2370043986 Merge pull request #60 from lordmathis/fix/prism-js
Update deps to fix prism-js vulnerability
2025-10-11 21:45:25 +02:00
386ac2f15f Update react-syntax-highlighter to version 15.6.6 in package.json and package-lock.json 2025-10-11 21:40:17 +02:00
cf95bf4862 Merge pull request #59 from lordmathis/fix/signing-key
Generate signing and encryption key to a file
2025-10-11 21:31:56 +02:00
e5ad02df6d Update launch configurations 2025-10-11 21:27:54 +02:00
a904a0d1a3 Improve admin credentials error messages with setup instructions 2025-10-11 21:22:38 +02:00
62605b3689 Refactor encryption key handling: auto-generate if not provided, update README and tests 2025-10-11 21:18:24 +02:00
d40321685e Remove system settings functionality and related database migrations 2025-10-11 21:04:01 +02:00
8920027a9c Load or generate signing key from file 2025-10-11 20:55:44 +02:00
c0bcb3069b Merge pull request #58 from lordmathis/fix/theme-switching
Fix/theme switching
2025-10-11 20:27:17 +02:00
4ec019f2b7 Fix typescript type check issues 2025-10-11 20:23:10 +02:00
769385d8c7 Fix workspace selection logic and update settings initialization conditions 2025-10-11 19:04:08 +02:00
bcc5d2588a Streamline theme management and improve AppearanceSettings component 2025-10-11 17:46:12 +02:00
8bd9eb2236 Merge pull request #57 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-73ea615029
Bump vite from 6.3.4 to 6.3.6 in /app in the npm_and_yarn group across 1 directory
2025-09-14 10:53:14 +02:00
dependabot[bot]
399dfda6ea Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.4 to 6.3.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 01:38:31 +00:00
f710e32659 Merge pull request #56 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-e04d5d616f
Bump form-data from 4.0.2 to 4.0.4 in /app in the npm_and_yarn group across 1 directory
2025-07-22 22:07:10 +02:00
dependabot[bot]
49c1a06d98 Bump form-data in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [form-data](https://github.com/form-data/form-data).


Updates `form-data` from 4.0.2 to 4.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 07:45:26 +00:00
14adcf2765 Merge pull request #55 from lordmathis/feat/file-actions
Implement file upload, move and rename
2025-07-12 15:49:52 +02:00
69fa105291 Add rename file modal visibility state and handler to CreateWorkspaceModal and WorkspaceSwitcher tests 2025-07-12 15:44:26 +02:00
005288b3d8 Add file size validation in UploadFile handler to prevent excessive memory allocation 2025-07-12 15:06:01 +02:00
9f01c64e5e Enhance file upload functionality to support multiple files; update related tests and response structure 2025-07-12 14:58:32 +02:00
491d056dd4 Add isUploadFilesResponse type guard and related tests; remove prototype pollution tests 2025-07-12 14:40:07 +02:00
41d526af4c Remove redundant CSRF token tests and clean up related assertions in apiCall tests 2025-07-12 14:34:04 +02:00
ff4d1de2b7 Refactor uploadFile to support multiple file uploads and update related types and handlers 2025-07-12 14:25:03 +02:00
51c6f62c44 Enhance uploadFile to support multiple files and update handleMove to process file paths for moving files 2025-07-12 13:59:30 +02:00
1a7c97fb08 Refactor file API endpoints for consistency and add moveFile functionality 2025-07-12 12:39:51 +02:00
4ea24f07d1 Refactor file routes and update API endpoints for consistency 2025-07-12 12:34:17 +02:00
f4d8a354c4 Add parameters for source and destination paths in moveFile endpoint 2025-07-12 11:58:50 +02:00
3ac657486c Implement MoveFile functionality 2025-07-12 11:52:51 +02:00
0b0cd9160e Refactor WorkspaceDataContext and useFileOperations to remove settings 2025-07-11 23:53:37 +02:00
e1760ccd82 Fix FileActions, FileTree, and MainContent tests by mocking context providers 2025-07-11 23:35:00 +02:00
9bb95f603c Implement MoveFile functionality in FileManager and corresponding tests 2025-07-11 19:49:08 +02:00
5a6895ecdc Enhance integration tests with file upload functionality and support for multipart requests 2025-07-10 22:02:03 +02:00
ae4e9c8db2 Add upload and move file functionalities to API with appropriate routes 2025-07-10 21:07:20 +02:00
4b8ad359a4 Refactor API endpoints to include "_op" prefix for file and workspace operations 2025-07-08 20:28:44 +02:00
48d42a92c9 Refactor API routes to include "_op" prefix for last workspace and file operations 2025-07-08 20:16:46 +02:00
a789c62a68 Add rename file functionality with modal support 2025-07-07 20:18:36 +02:00
4a3df3a040 Implement file upload functionality in FileActions and FileTree components 2025-07-07 19:13:25 +02:00
b10591ee60 Add file upload functionality to FileActions and FileTree components 2025-07-07 18:14:14 +02:00
70cd67f8bb Merge pull request #54 from lordmathis/chore/frontend-test
Add frontend tests
2025-07-06 01:44:38 +02:00
fa86a950fd Update README.md to show fe tests badge 2025-07-06 01:37:54 +02:00
520f58435c Refactor tests to remove redundant settings mock data 2025-07-06 01:32:02 +02:00
e5c34c25d7 Update tests to handle asynchronous loading and initialization states across multiple components 2025-07-06 01:29:55 +02:00
7a31bd4c76 Update WorkspaceDataContext tests to handle asynchronous loading states 2025-07-06 00:51:48 +02:00
d0cdc48f3e Update AuthContext tests to handle asynchronous initialization and loading states 2025-07-06 00:47:58 +02:00
cf554fbb6e Refactor workspace settings handling in tests and components to use currentWorkspace directly 2025-07-06 00:41:30 +02:00
7368797a11 Add tests for ContentView and MarkdownPreview components 2025-07-06 00:08:42 +02:00
2747f51293 Add tests for Header, Layout, MainContent, and Sidebar components 2025-07-05 17:39:23 +02:00
7742a04d9a Add tests for FileActions and FileTree components 2025-07-05 16:54:47 +02:00
fffd93afeb Add tests for UserMenu and WorkspaceSwitcher components 2025-07-04 22:44:19 +02:00
eaa37a262e Add tests for AdminDashboard, AdminStatsTab, AdminUsersTab, and AdminWorkspacesTab components 2025-07-04 20:24:56 +02:00
184d9fae15 Merge pull request #53 from lordmathis/dependabot/go_modules/server/go_modules-c5fc80f408
Bump github.com/go-chi/chi/v5 from 5.1.0 to 5.2.2 in /server in the go_modules group across 1 directory
2025-07-04 16:34:00 +02:00
dependabot[bot]
254bccb1d9 Bump github.com/go-chi/chi/v5
Bumps the go_modules group with 1 update in the /server directory: [github.com/go-chi/chi/v5](https://github.com/go-chi/chi).


Updates `github.com/go-chi/chi/v5` from 5.1.0 to 5.2.2
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.1.0...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 20:00:53 +00:00
d30c0a4b9e Merge pull request #52 from lordmathis/dependabot/go_modules/server/go_modules-d354d3216e
Bump github.com/cloudflare/circl from 1.3.7 to 1.6.1 in /server in the go_modules group across 1 directory
2025-07-03 21:59:47 +02:00
5c7edf40a8 Add tests for AppearanceSettings, DangerZoneSettings, EditorSettings, GeneralSettings, GitSettings, and WorkspaceSettings components 2025-07-03 21:58:14 +02:00
f4ec3af80c Fix handleSubmit invocation in AccountSettings component 2025-07-01 21:10:03 +02:00
e4b584e440 Add tests for AccountSettings, DangerZoneSettings, ProfileSettings, and SecuritySettings components 2025-07-01 21:01:53 +02:00
15486c584a Simplify AccordionControl tests 2025-07-01 20:40:04 +02:00
1a7ddc1a97 Refactor AccordionControl tests for improved structure, clarity, and error handling 2025-06-30 21:42:44 +02:00
32d03347fc Refactor modal tests for improved clarity and consistency in content and actions 2025-06-29 16:06:35 +02:00
8d9222d084 Refactor DeleteWorkspaceModal tests for improved clarity and consistency in assertions 2025-06-29 14:56:57 +02:00
6add442e03 Simplify user modals tests 2025-06-29 14:47:34 +02:00
e40aaff905 Refactor CommitMessageModal tests to improve clarity and remove redundant cases 2025-06-29 14:21:33 +02:00
d90f9968c5 Simplify CreateFileModal and DeleteFileModal tests 2025-06-29 13:58:43 +02:00
5980c308a2 Simplify account modals tests 2025-06-29 13:17:07 +02:00
b673d2ed2d Refactor LoginPage tests 2025-06-29 12:58:51 +02:00
dependabot[bot]
bc863bd8ef Bump github.com/cloudflare/circl
Bumps the go_modules group with 1 update in the /server directory: [github.com/cloudflare/circl](https://github.com/cloudflare/circl).


Updates `github.com/cloudflare/circl` from 1.3.7 to 1.6.1
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.3.7...v1.6.1)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 21:23:52 +00:00
734b98d286 Refactor ProfileSettings and SecuritySettings components to improve code clarity and add input types 2025-06-05 21:01:06 +02:00
3276fd98c2 Refactor ModalContext tests 2025-06-04 20:48:38 +02:00
73653c4271 Remove redundant tests from contexts 2025-06-04 20:44:31 +02:00
0fd87c072d Remove duplicate error handling tests from useWorkspaceOperations test 2025-06-04 19:22:21 +02:00
07af3f6e39 Refactor ThemeContext to ensure fallback to light scheme and update color scheme logic 2025-06-04 19:17:09 +02:00
54feefcd5c Remove redundant test in useProfileSettings 2025-06-04 18:54:48 +02:00
9854deb43b Add test for handling empty file path in saveLastOpenedFile function 2025-06-04 18:46:31 +02:00
1e80edd5ca Remove unnecessary test 2025-06-04 18:41:39 +02:00
d938c3b03b Add test for handling large file lists in useFileList hook 2025-06-04 18:22:11 +02:00
6a7736ea5b Improve useFileContent hook tests 2025-06-04 18:11:37 +02:00
dfd9d5b70c Simplify useAdminData tests 2025-06-04 17:54:57 +02:00
f37c024d65 Simplify theme tests 2025-06-04 17:03:24 +02:00
3f2aaa34e5 Add CreateWorkspaceModal and DeleteWorkspaceModal tests 2025-06-01 15:19:50 +02:00
bc17005eca Add DeleteUserModal and EditUserModal tests 2025-06-01 13:58:06 +02:00
9d7f312527 Implement CreateUserModal tests 2025-06-01 11:49:05 +02:00
8deededc05 Add DeleteAccountModal and EmailPasswordModal tests 2025-05-31 21:59:00 +02:00
e642b73556 Add data-testid attributes for improved testing in modals 2025-05-31 20:23:15 +02:00
e279cd4535 Add DeleteFileModal and CommitMessageModal tests with accessibility improvements 2025-05-31 20:00:34 +02:00
2964963f98 Add tests for CreateFileModal component and improve form handling 2025-05-29 22:03:28 +02:00
e01ae5b815 Add tests for LoginPage component and improve accessibility features 2025-05-29 20:04:58 +02:00
57b9d4cc89 Add frontend tests workflow and update paths for Go and TypeScript workflows 2025-05-29 17:12:42 +02:00
37c49dc0cc Add tests for WorkspaceDataContext 2025-05-29 16:52:03 +02:00
33d45568ec Add tests for AuthContext, ModalContext and ThemeContext 2025-05-29 16:25:42 +02:00
19771dd094 Add tests for API and Models type guards 2025-05-29 13:10:20 +02:00
2211f85193 Add tests for apiCall function 2025-05-29 11:57:50 +02:00
5ed3e96350 Refactor admin API types and add validation functions for WorkspaceStats and FileCountStats 2025-05-29 11:15:53 +02:00
d814c365ea Refactor hooks and hook tests error handling and state management 2025-05-28 22:01:27 +02:00
907dffe362 Refactor tests in useGitOperations and useWorkspaceOperations to handle undefined values gracefully 2025-05-28 21:04:43 +02:00
b38792a47f Add ESLint rule override for test files 2025-05-28 20:59:59 +02:00
6a8b359c84 Add tests for useWorkspaceOperations hook functionality 2025-05-28 20:41:12 +02:00
3e482c546c Add tests for useUserAdmin hook functionality 2025-05-28 19:44:00 +02:00
ae35172c2a Add tests for useFileNavigation hook functionality 2025-05-28 18:39:41 +02:00
05c3111f8b Fix error message casing for email already exists in useProfileSettings tests 2025-05-28 18:39:33 +02:00
1532896b27 Add tests for useProfileSettings hook functionality 2025-05-27 21:52:21 +02:00
00edb9e5a6 Add tests for useLastOpenedFile hook functionality 2025-05-27 21:43:20 +02:00
9cefe12872 Improve tests and add useGitOps hook test 2025-05-27 21:33:02 +02:00
942ff17c4f Add tests for useFileContent and useFileOperations hooks 2025-05-26 21:53:52 +02:00
e9abe14364 Add tests for remarkWikiLinks functionality 2025-05-26 20:58:26 +02:00
49cac03db8 Implement utils tests 2025-05-26 20:35:02 +02:00
e5569fc4a5 Initial vitest setup 2025-05-26 20:02:53 +02:00
156 changed files with 21865 additions and 2510 deletions

39
.github/workflows/frontend-tests.yml vendored Normal file
View 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

View File

@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- "*" - "*"
paths:
- "server/**"
pull_request: pull_request:
branches: branches:
- main - main

View File

@@ -7,6 +7,8 @@ on:
push: push:
branches: branches:
- "*" - "*"
paths:
- "app/**"
pull_request: pull_request:
branches: branches:
- main - main

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ go.work.sum
main main
*.db *.db
data data
# Feature specifications
spec.md

26
.vscode/launch.json vendored
View File

@@ -2,13 +2,35 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Launch Lemma Server", "name": "Launch Backend",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/server/cmd/server/main.go", "program": "${workspaceFolder}/server/cmd/server/main.go",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/server/.env" "envFile": "${workspaceFolder}/.env.local"
},
{
"name": "Launch Frontend",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["start"],
"cwd": "${workspaceFolder}/app",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env.local"
}
],
"compounds": [
{
"name": "Launch Backend + Frontend",
"configurations": ["Launch Backend", "Launch Frontend"],
"presentation": {
"hidden": false,
"group": "",
"order": 1
},
"stopAll": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
# Lemma # Lemma
![Build](https://github.com/lordmathis/lemma/actions/workflows/build-and-release.yml/badge.svg) ![Go Tests](https://github.com/lordmathis/lemma/actions/workflows/go-test.yml/badge.svg) ![Typescript Check](https://github.com/lordmathis/lemma/actions/workflows/typescript.yml/badge.svg) ![Build](https://github.com/lordmathis/lemma/actions/workflows/build-and-release.yml/badge.svg) ![Backend Tests](https://github.com/lordmathis/lemma/actions/workflows/go-test.yml/badge.svg) ![Frontend Tests](https://github.com/lordmathis/lemma/actions/workflows/frontend-tests.yml/badge.svg)
Yet another markdown editor. Work in progress Yet another markdown editor. Work in progress
@@ -28,7 +28,6 @@ Lemma can be configured using environment variables. Here are the available conf
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account - `LEMMA_ADMIN_EMAIL`: Email address for the admin account
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account - `LEMMA_ADMIN_PASSWORD`: Password for the admin account
- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data
### Optional Environment Variables ### Optional Environment Variables
@@ -39,21 +38,17 @@ Lemma can be configured using environment variables. Here are the available conf
- `LEMMA_PORT`: Port to run the server on (default: "8080") - `LEMMA_PORT`: Port to run the server on (default: "8080")
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication - `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins - `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens - `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/encryption_key`
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/jwt_signing_key`
- `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production) - `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production)
- `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100) - `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m) - `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
### Generating Encryption Keys ### Security Keys
The encryption key must be a base64-encoded 32-byte value. You can generate a secure encryption key using OpenSSL: Both the encryption key and JWT signing key are automatically generated on first startup if not provided via environment variables. The keys are stored in `{LEMMA_WORKDIR}/secrets/` with restrictive file permissions (0600).
```bash **Important**: Back up the `secrets` directory! If these keys are lost, encrypted data will become inaccessible and all users will need to re-authenticate.
# Generate a random 32-byte key and encode it as base64
openssl rand -base64 32
```
Store the generated key securely - it will be needed to decrypt any data encrypted by the application. If the key is lost or changed, previously encrypted data will become inaccessible.
## Running the backend server ## Running the backend server

View File

@@ -104,4 +104,11 @@ export default defineConfig([
'@typescript-eslint/no-non-null-assertion': 'warn', '@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',
},
},
]); ]);

2093
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@
"preview": "vite preview", "preview": "vite preview",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx", "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": { "repository": {
"type": "git", "type": "git",
@@ -41,9 +44,8 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.5.0", "rehype-highlight": "^7.0.2",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^6.0.0",
"rehype-prism": "^2.3.3",
"rehype-react": "^8.0.0", "rehype-react": "^8.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
@@ -54,24 +56,28 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@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/babel__core": "^7.20.5",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^18.3.20", "@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.1.4",
"eslint": "^9.27.0", "eslint": "^9.27.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4", "sass": "^1.80.4",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.2.4", "vite": "^6.4.1",
"vite-plugin-compression2": "^1.3.0" "vite-plugin-compression2": "^1.3.0",
"vitest": "^3.1.4"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@@ -1,5 +1,9 @@
import React from 'react'; import React from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core'; import {
MantineProvider,
ColorSchemeScript,
localStorageColorSchemeManager,
} from '@mantine/core';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import Layout from './components/layout/Layout'; import Layout from './components/layout/Layout';
@@ -39,11 +43,18 @@ const AuthenticatedContent: React.FC<AuthenticatedContentProps> = () => {
type AppProps = object; type AppProps = object;
const colorSchemeManager = localStorageColorSchemeManager({
key: 'mantine-color-scheme',
});
const App: React.FC<AppProps> = () => { const App: React.FC<AppProps> = () => {
return ( return (
<> <>
<ColorSchemeScript defaultColorScheme="light" /> <ColorSchemeScript defaultColorScheme="light" />
<MantineProvider defaultColorScheme="light"> <MantineProvider
defaultColorScheme="light"
colorSchemeManager={colorSchemeManager}
>
<Notifications /> <Notifications />
<ModalsProvider> <ModalsProvider>
<AuthProvider> <AuthProvider>

View File

@@ -7,10 +7,10 @@ import { apiCall } from './api';
import { import {
isSystemStats, isSystemStats,
isUser, isUser,
isWorkspace, isWorkspaceStats,
type SystemStats, type SystemStats,
type User, type User,
type Workspace, type WorkspaceStats,
} from '@/types/models'; } from '@/types/models';
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
@@ -101,18 +101,18 @@ export const updateUser = async (
/** /**
* Fetches all workspaces from the API * 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 * @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 response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
const data: unknown = await response.json(); const data: unknown = await response.json();
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
throw new Error('Invalid workspaces response received from API'); throw new Error('Invalid workspaces response received from API');
} }
return data.map((workspace) => { return data.map((workspace) => {
if (!isWorkspace(workspace)) { if (!isWorkspaceStats(workspace)) {
throw new Error('Invalid workspace object received from API'); throw new Error('Invalid workspace stats object received from API');
} }
return workspace; return workspace;
}); });

469
app/src/api/api.test.ts Normal file
View File

@@ -0,0 +1,469 @@
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 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
},
});
});
});
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',
},
});
}
});
});
describe('edge cases', () => {
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);
});
});
});

View File

@@ -29,10 +29,16 @@ export const apiCall = async (
// Set up headers with CSRF token for non-GET requests // Set up headers with CSRF token for non-GET requests
const method = options.method || 'GET'; const method = options.method || 'GET';
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
// Only set Content-Type to application/json if not already set and body is not FormData
// FormData requires the browser to set Content-Type with the boundary parameter
const isFormData = options.body instanceof FormData;
if (!headers['Content-Type'] && !isFormData) {
headers['Content-Type'] = 'application/json';
}
// Add CSRF token for non-GET methods // Add CSRF token for non-GET methods
if (method !== 'GET') { if (method !== 'GET') {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
@@ -41,11 +47,18 @@ export const apiCall = async (
} }
} }
// For FormData, don't include Content-Type in headers - let the browser set it
const fetchHeaders = isFormData
? Object.fromEntries(
Object.entries(headers).filter(([key]) => key !== 'Content-Type')
)
: headers;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
// Include credentials to send/receive cookies // Include credentials to send/receive cookies
credentials: 'include', credentials: 'include',
headers, headers: fetchHeaders,
}); });
console.debug(`Response status: ${response.status} for URL: ${url}`); console.debug(`Response status: ${response.status} for URL: ${url}`);

View File

@@ -4,7 +4,9 @@ import {
API_BASE_URL, API_BASE_URL,
isLookupResponse, isLookupResponse,
isSaveFileResponse, isSaveFileResponse,
isUploadFilesResponse,
type SaveFileResponse, type SaveFileResponse,
type UploadFilesResponse,
} from '@/types/api'; } from '@/types/api';
/** /**
@@ -40,6 +42,10 @@ export const lookupFileByName = async (
workspaceName: string, workspaceName: string,
filename: string filename: string
): Promise<string[]> => { ): Promise<string[]> => {
if (!filename || typeof filename !== 'string') {
throw new Error('Invalid filename provided for lookup');
}
const response = await apiCall( const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent( `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName workspaceName
@@ -67,7 +73,7 @@ export const getFileContent = async (
const response = await apiCall( const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent( `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName workspaceName
)}/files/${encodeURIComponent(filePath)}` )}/files/content?file_path=${encodeURIComponent(filePath)}`
); );
return response.text(); return response.text();
}; };
@@ -88,7 +94,7 @@ export const saveFile = async (
const response = await apiCall( const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent( `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName workspaceName
)}/files/${encodeURIComponent(filePath)}`, )}/files?file_path=${encodeURIComponent(filePath)}`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -114,7 +120,7 @@ export const deleteFile = async (workspaceName: string, filePath: string) => {
await apiCall( await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent( `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName workspaceName
)}/files/${encodeURIComponent(filePath)}`, )}/files?file_path=${encodeURIComponent(filePath)}`,
{ {
method: 'DELETE', method: 'DELETE',
} }
@@ -157,13 +163,75 @@ export const updateLastOpenedFile = async (
await apiCall( await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent( `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName workspaceName
)}/files/last`, )}/files/last?file_path=${encodeURIComponent(filePath)}`,
{ {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
} }
); );
}; };
/**
* moveFile moves a file to a new location in a workspace
* @param workspaceName - The name of the workspace
* @param srcPath - The source path of the file to move
* @param destPath - The destination path for the file
* @returns {Promise<SaveFileResponse>} A promise that resolves to the move file response
* @throws {Error} If the API call fails or returns an invalid response
*/
export const moveFile = async (
workspaceName: string,
srcPath: string,
destPath: string
): Promise<SaveFileResponse> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/move?src_path=${encodeURIComponent(
srcPath
)}&dest_path=${encodeURIComponent(destPath)}`,
{
method: 'POST',
}
);
const data: unknown = await response.json();
if (!isSaveFileResponse(data)) {
throw new Error('Invalid move file response received from API');
}
return data;
};
/**
* uploadFile uploads multiple files to a workspace
* @param workspaceName - The name of the workspace
* @param directoryPath - The directory path where files should be uploaded
* @param files - Multiple files to upload
* @returns {Promise<UploadFilesResponse>} A promise that resolves to the upload file response
* @throws {Error} If the API call fails or returns an invalid response
*/
export const uploadFile = async (
workspaceName: string,
directoryPath: string,
files: FileList
): Promise<UploadFilesResponse> => {
const formData = new FormData();
// Add all files to the form data
Array.from(files).forEach((file) => {
formData.append('files', file);
});
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/upload?file_path=${encodeURIComponent(directoryPath)}`,
{
method: 'POST',
body: formData,
}
);
const data: unknown = await response.json();
if (!isUploadFilesResponse(data)) {
throw new Error('Invalid upload file response received from API');
}
return data;
};

View File

@@ -121,7 +121,7 @@ export const deleteWorkspace = async (
* @throws {Error} If the API call fails or returns an invalid response * @throws {Error} If the API call fails or returns an invalid response
*/ */
export const getLastWorkspaceName = async (): Promise<string> => { export const getLastWorkspaceName = async (): Promise<string> => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`); const response = await apiCall(`${API_BASE_URL}/workspaces/_op/last`);
const data: unknown = await response.json(); const data: unknown = await response.json();
if ( if (
typeof data !== 'object' || typeof data !== 'object' ||
@@ -139,7 +139,7 @@ export const getLastWorkspaceName = async (): Promise<string> => {
* @throws {Error} If the API call fails or returns an invalid response * @throws {Error} If the API call fails or returns an invalid response
*/ */
export const updateLastWorkspaceName = async (workspaceName: string) => { export const updateLastWorkspaceName = async (workspaceName: string) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`, { const response = await apiCall(`${API_BASE_URL}/workspaces/_op/last`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View 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
);
});
});
});
});

View File

@@ -37,11 +37,13 @@ const LoginPage: React.FC = () => {
</Text> </Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md"> <Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} role="form">
<Stack> <Stack>
<TextInput <TextInput
type="email"
label="Email" label="Email"
placeholder="your@email.com" placeholder="your@email.com"
data-testid="email-input"
required required
value={email} value={email}
onChange={(event) => setEmail(event.currentTarget.value)} onChange={(event) => setEmail(event.currentTarget.value)}
@@ -50,12 +52,13 @@ const LoginPage: React.FC = () => {
<PasswordInput <PasswordInput
label="Password" label="Password"
placeholder="Your password" placeholder="Your password"
data-testid="password-input"
required required
value={password} value={password}
onChange={(event) => setPassword(event.currentTarget.value)} onChange={(event) => setPassword(event.currentTarget.value)}
/> />
<Button type="submit" loading={loading}> <Button type="submit" loading={loading} data-testid="login-button">
Sign in Sign in
</Button> </Button>
</Stack> </Stack>

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

View File

@@ -0,0 +1,347 @@
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('renders code blocks with correct structure for theme switching', async () => {
const content = '```javascript\nconst hello = "world";\n```';
render(
<MarkdownPreview
content={content}
handleFileSelect={mockHandleFileSelect}
/>
);
await waitFor(() => {
// Check that rehype-highlight generates the correct structure
const preElement = screen
.getByRole('code', { hidden: true })
.closest('pre');
const codeElement = preElement?.querySelector('code');
expect(preElement).toBeInTheDocument();
expect(codeElement).toBeInTheDocument();
// The code element should have hljs class for theme switching to work
expect(codeElement).toHaveClass('hljs');
// Should also have language class
expect(codeElement).toHaveClass('language-javascript');
});
});
it('handles image loading errors gracefully', async () => {
const content = '![Test Image](invalid-image.jpg)';
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'
);
});
});
});

View File

@@ -1,15 +1,16 @@
import React, { useState, useEffect, useMemo, type ReactNode } from 'react'; import React, { useState, useEffect, useMemo, type ReactNode } from 'react';
import { unified, type Preset } from 'unified'; import { unified } from 'unified';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype'; import remarkRehype from 'remark-rehype';
import rehypeMathjax from 'rehype-mathjax'; import rehypeMathjax from 'rehype-mathjax';
import rehypeReact, { type Options } from 'rehype-react'; import rehypeReact, { type Options } from 'rehype-react';
import rehypePrism from 'rehype-prism'; import rehypeHighlight from 'rehype-highlight';
import * as prod from 'react/jsx-runtime'; import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../hooks/useWorkspace'; import { useWorkspace } from '../../hooks/useWorkspace';
import { useHighlightTheme } from '../../hooks/useHighlightTheme';
interface MarkdownPreviewProps { interface MarkdownPreviewProps {
content: string; content: string;
@@ -28,12 +29,6 @@ interface MarkdownLinkProps {
[key: string]: unknown; [key: string]: unknown;
} }
interface MarkdownCodeProps {
children: ReactNode;
className?: string;
[key: string]: unknown;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
content, content,
handleFileSelect, handleFileSelect,
@@ -42,7 +37,10 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
null null
); );
const baseUrl = window.API_BASE_URL; const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace(); const { currentWorkspace, colorScheme } = useWorkspace();
// Use the highlight theme hook
useHighlightTheme(colorScheme === 'auto' ? 'light' : colorScheme);
const processor = useMemo(() => { const processor = useMemo(() => {
const handleLinkClick = ( const handleLinkClick = (
@@ -82,7 +80,7 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
.use(remarkMath) .use(remarkMath)
.use(remarkRehype) .use(remarkRehype)
.use(rehypeMathjax) .use(rehypeMathjax)
.use(rehypePrism as Preset) .use(rehypeHighlight)
.use(rehypeReact, { .use(rehypeReact, {
jsx: prod.jsx, jsx: prod.jsx,
jsxs: prod.jsxs, jsxs: prod.jsxs,
@@ -107,13 +105,6 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
{children} {children}
</a> </a>
), ),
code: ({ children, className, ...props }: MarkdownCodeProps) => {
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
}, },
} as Options); } as Options);
}, [currentWorkspace?.name, baseUrl, handleFileSelect]); }, [currentWorkspace?.name, baseUrl, handleFileSelect]);
@@ -135,7 +126,11 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
void processContent(); void processContent();
}, [content, processor, currentWorkspace]); }, [content, processor, currentWorkspace]);
return <div className="markdown-preview">{processedContent}</div>; return (
<div className="markdown-preview" data-testid="markdown-preview">
{processedContent}
</div>
);
}; };
export default MarkdownPreview; export default MarkdownPreview;

View File

@@ -0,0 +1,258 @@
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';
import { ModalProvider } from '../../contexts/ModalContext';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
// Mock the contexts and hooks
vi.mock('../../contexts/ModalContext', () => ({
useModalContext: vi.fn(),
ModalProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock('../../hooks/useWorkspace', () => ({
useWorkspace: vi.fn(),
}));
// Mock contexts
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({
colorScheme: 'light',
updateColorScheme: vi.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock('../../contexts/WorkspaceDataContext', () => ({
useWorkspaceData: () => ({
currentWorkspace: { name: 'test-workspace', path: '/test' },
workspaces: [],
settings: {},
loading: false,
loadWorkspaces: vi.fn(),
loadWorkspaceData: vi.fn(),
setCurrentWorkspace: vi.fn(),
}),
WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>
<WorkspaceDataProvider>
<ModalProvider>{children}</ModalProvider>
</WorkspaceDataProvider>
</ThemeProvider>
);
describe('FileActions', () => {
const mockHandlePullChanges = vi.fn();
const mockLoadFileList = 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,
renameFileModalVisible: false,
setRenameFileModalVisible: vi.fn(),
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}
loadFileList={mockLoadFileList}
/>
</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"
loadFileList={mockLoadFileList}
/>
</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}
loadFileList={mockLoadFileList}
/>
</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"
loadFileList={mockLoadFileList}
/>
</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"
loadFileList={mockLoadFileList}
/>
</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"
loadFileList={mockLoadFileList}
/>
</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"
loadFileList={mockLoadFileList}
/>
</TestWrapper>
);
const commitButton = getByTestId('commit-push-button');
expect(commitButton).toBeDisabled();
});
});

View File

@@ -1,42 +1,114 @@
import React from 'react'; import React, { useRef } from 'react';
import { ActionIcon, Tooltip, Group } from '@mantine/core'; import { ActionIcon, Tooltip, Group } from '@mantine/core';
import { import {
IconPlus, IconPlus,
IconTrash, IconTrash,
IconGitPullRequest, IconGitPullRequest,
IconGitCommit, IconGitCommit,
IconUpload,
IconEdit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useModalContext } from '../../contexts/ModalContext'; import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../hooks/useWorkspace'; import { useWorkspace } from '../../hooks/useWorkspace';
import { useFileOperations } from '../../hooks/useFileOperations';
interface FileActionsProps { interface FileActionsProps {
handlePullChanges: () => Promise<boolean>; handlePullChanges: () => Promise<boolean>;
selectedFile: string | null; selectedFile: string | null;
loadFileList: () => Promise<void>;
} }
const FileActions: React.FC<FileActionsProps> = ({ const FileActions: React.FC<FileActionsProps> = ({
handlePullChanges, handlePullChanges,
selectedFile, selectedFile,
loadFileList,
}) => { }) => {
const { settings } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { const {
setNewFileModalVisible, setNewFileModalVisible,
setDeleteFileModalVisible, setDeleteFileModalVisible,
setCommitMessageModalVisible, setCommitMessageModalVisible,
setRenameFileModalVisible,
} = useModalContext(); } = useModalContext();
const { handleUpload } = useFileOperations();
// Hidden file input for upload
const fileInputRef = useRef<HTMLInputElement>(null);
const handleCreateFile = (): void => setNewFileModalVisible(true); const handleCreateFile = (): void => setNewFileModalVisible(true);
const handleDeleteFile = (): void => setDeleteFileModalVisible(true); const handleDeleteFile = (): void => setDeleteFileModalVisible(true);
const handleRenameFile = (): void => setRenameFileModalVisible(true);
const handleCommitAndPush = (): void => setCommitMessageModalVisible(true); const handleCommitAndPush = (): void => setCommitMessageModalVisible(true);
const handleUploadClick = (): void => {
fileInputRef.current?.click();
};
const handleFileInputChange = (
event: React.ChangeEvent<HTMLInputElement>
): void => {
const files = event.target.files;
if (files && files.length > 0) {
const uploadFiles = async () => {
try {
const success = await handleUpload(files);
if (success) {
await loadFileList();
}
} catch (error) {
console.error('Error uploading files:', error);
}
};
void uploadFiles();
// Reset the input so the same file can be selected again
event.target.value = '';
}
};
return ( return (
<Group gap="xs"> <Group gap="xs">
<Tooltip label="Create new file"> <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} /> <IconPlus size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Upload files">
<ActionIcon
variant="default"
size="md"
onClick={handleUploadClick}
aria-label="Upload files"
data-testid="upload-files-button"
>
<IconUpload size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={selectedFile ? 'Rename current file' : 'No file selected'}
>
<ActionIcon
variant="default"
size="md"
onClick={handleRenameFile}
disabled={!selectedFile}
aria-label="Rename current file"
data-testid="rename-file-button"
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip <Tooltip
label={selectedFile ? 'Delete current file' : 'No file selected'} label={selectedFile ? 'Delete current file' : 'No file selected'}
> >
@@ -46,6 +118,8 @@ const FileActions: React.FC<FileActionsProps> = ({
onClick={handleDeleteFile} onClick={handleDeleteFile}
disabled={!selectedFile} disabled={!selectedFile}
color="red" color="red"
aria-label="Delete current file"
data-testid="delete-file-button"
> >
<IconTrash size={16} /> <IconTrash size={16} />
</ActionIcon> </ActionIcon>
@@ -53,7 +127,7 @@ const FileActions: React.FC<FileActionsProps> = ({
<Tooltip <Tooltip
label={ label={
settings.gitEnabled currentWorkspace?.gitEnabled
? 'Pull changes from remote' ? 'Pull changes from remote'
: 'Git is not enabled' : 'Git is not enabled'
} }
@@ -66,7 +140,9 @@ const FileActions: React.FC<FileActionsProps> = ({
console.error('Error pulling changes:', error); 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} /> <IconGitPullRequest size={16} />
</ActionIcon> </ActionIcon>
@@ -74,9 +150,9 @@ const FileActions: React.FC<FileActionsProps> = ({
<Tooltip <Tooltip
label={ label={
!settings.gitEnabled !currentWorkspace?.gitEnabled
? 'Git is not enabled' ? 'Git is not enabled'
: settings.gitAutoCommit : currentWorkspace.gitAutoCommit
? 'Auto-commit is enabled' ? 'Auto-commit is enabled'
: 'Commit and push changes' : 'Commit and push changes'
} }
@@ -85,11 +161,25 @@ const FileActions: React.FC<FileActionsProps> = ({
variant="default" variant="default"
size="md" size="md"
onClick={handleCommitAndPush} 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} /> <IconGitCommit size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileInputChange}
multiple
aria-label="File upload input"
/>
</Group> </Group>
); );
}; };

View File

@@ -0,0 +1,288 @@
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';
import { ModalProvider } from '../../contexts/ModalContext';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
// 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(),
}));
// Mock contexts
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({
colorScheme: 'light',
updateColorScheme: vi.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock('../../contexts/WorkspaceDataContext', () => ({
useWorkspaceData: () => ({
currentWorkspace: { name: 'test-workspace', path: '/test' },
workspaces: [],
settings: {},
loading: false,
loadWorkspaces: vi.fn(),
loadWorkspaceData: vi.fn(),
setCurrentWorkspace: vi.fn(),
}),
WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock('../../contexts/ModalContext', () => ({
useModalContext: () => ({
newFileModalVisible: false,
setNewFileModalVisible: vi.fn(),
deleteFileModalVisible: false,
setDeleteFileModalVisible: vi.fn(),
renameFileModalVisible: false,
setRenameFileModalVisible: vi.fn(),
commitMessageModalVisible: false,
setCommitMessageModalVisible: vi.fn(),
settingsModalVisible: false,
setSettingsModalVisible: vi.fn(),
switchWorkspaceModalVisible: false,
setSwitchWorkspaceModalVisible: vi.fn(),
createWorkspaceModalVisible: false,
setCreateWorkspaceModalVisible: vi.fn(),
}),
ModalProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock('../../hooks/useFileOperations', () => ({
useFileOperations: () => ({
handleSave: vi.fn(),
handleCreate: vi.fn(),
handleDelete: vi.fn(),
handleUpload: vi.fn(),
handleMove: vi.fn(),
handleRename: vi.fn(),
}),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>
<WorkspaceDataProvider>
<ModalProvider>{children}</ModalProvider>
</WorkspaceDataProvider>
</ThemeProvider>
);
describe('FileTree', () => {
const mockHandleFileSelect = vi.fn();
const mockLoadFileList = 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}
loadFileList={mockLoadFileList}
/>
</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}
loadFileList={mockLoadFileList}
/>
</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}
loadFileList={mockLoadFileList}
/>
</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}
loadFileList={mockLoadFileList}
/>
</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}
loadFileList={mockLoadFileList}
/>
</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}
loadFileList={mockLoadFileList}
/>
</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();
});
});
});

View File

@@ -1,8 +1,14 @@
import React, { useRef, useState, useLayoutEffect } from 'react'; import React, { useRef, useState, useLayoutEffect, useCallback } from 'react';
import { Tree, type NodeApi } from 'react-arborist'; import { Tree, type NodeApi } from 'react-arborist';
import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react'; import {
import { Tooltip } from '@mantine/core'; IconFile,
IconFolder,
IconFolderOpen,
IconUpload,
} from '@tabler/icons-react';
import { Tooltip, Text, Box } from '@mantine/core';
import useResizeObserver from '@react-hook/resize-observer'; import useResizeObserver from '@react-hook/resize-observer';
import { useFileOperations } from '../../hooks/useFileOperations';
import type { FileNode } from '@/types/models'; import type { FileNode } from '@/types/models';
interface Size { interface Size {
@@ -14,6 +20,7 @@ interface FileTreeProps {
files: FileNode[]; files: FileNode[];
handleFileSelect: (filePath: string | null) => Promise<void>; handleFileSelect: (filePath: string | null) => Promise<void>;
showHiddenFiles: boolean; showHiddenFiles: boolean;
loadFileList: () => Promise<void>;
} }
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => { const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
@@ -40,7 +47,7 @@ const FileIcon = ({ node }: { node: NodeApi<FileNode> }) => {
); );
}; };
// Define a Node component that matches what React-Arborist expects // Enhanced Node component with drag handle
function Node({ function Node({
node, node,
style, style,
@@ -52,7 +59,6 @@ function Node({
style: React.CSSProperties; style: React.CSSProperties;
dragHandle?: React.Ref<HTMLDivElement>; dragHandle?: React.Ref<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void; onNodeClick?: (node: NodeApi<FileNode>) => void;
// Accept any extra props from Arborist, but do not use an index signature
} & Record<string, unknown>) { } & Record<string, unknown>) {
const handleClick = () => { const handleClick = () => {
if (node.isInternal) { if (node.isInternal) {
@@ -65,7 +71,7 @@ function Node({
return ( return (
<Tooltip label={node.data.name} openDelay={500}> <Tooltip label={node.data.name} openDelay={500}>
<div <div
ref={dragHandle} ref={dragHandle} // This enables dragging for the node
style={{ style={{
...style, ...style,
paddingLeft: `${node.level * 20}px`, paddingLeft: `${node.level * 20}px`,
@@ -74,6 +80,8 @@ function Node({
cursor: 'pointer', cursor: 'pointer',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
// Add visual feedback when being dragged
opacity: node.state?.isDragging ? 0.5 : 1,
}} }}
onClick={handleClick} onClick={handleClick}
{...rest} {...rest}
@@ -95,13 +103,60 @@ function Node({
); );
} }
const FileTree: React.FC<FileTreeProps> = ({ // Utility function to recursively find file paths by IDs
const findFilePathsById = (files: FileNode[], ids: string[]): string[] => {
const paths: string[] = [];
const searchFiles = (nodes: FileNode[]) => {
for (const node of nodes) {
if (ids.includes(node.id)) {
paths.push(node.path);
}
if (node.children) {
searchFiles(node.children);
}
}
};
searchFiles(files);
return paths;
};
// Utility function to find parent path by ID
const findParentPathById = (
files: FileNode[],
parentId: string | null
): string => {
if (!parentId) return '';
const searchFiles = (nodes: FileNode[]): string | null => {
for (const node of nodes) {
if (node.id === parentId) {
return node.path;
}
if (node.children) {
const result = searchFiles(node.children);
if (result) return result;
}
}
return null;
};
return searchFiles(files) || '';
};
export const FileTree: React.FC<FileTreeProps> = ({
files, files,
handleFileSelect, handleFileSelect,
showHiddenFiles, showHiddenFiles,
loadFileList,
}) => { }) => {
const target = useRef<HTMLDivElement>(null); const target = useRef<HTMLDivElement>(null);
const size = useSize(target); const size = useSize(target);
const { handleMove, handleUpload } = useFileOperations();
// State for drag and drop overlay
const [isDragOver, setIsDragOver] = useState(false);
const filteredFiles = files.filter((file) => { const filteredFiles = files.filter((file) => {
if (file.name.startsWith('.') && !showHiddenFiles) { if (file.name.startsWith('.') && !showHiddenFiles) {
@@ -118,11 +173,130 @@ const FileTree: React.FC<FileTreeProps> = ({
} }
}; };
// Handle file movement within the tree
const handleTreeMove = useCallback(
async ({
dragIds,
parentId,
index,
}: {
dragIds: string[];
parentId: string | null;
index: number;
}) => {
try {
// Map dragged file IDs to their corresponding paths
const dragPaths = findFilePathsById(filteredFiles, dragIds);
// Find the parent path where files will be moved
const targetParentPath = findParentPathById(filteredFiles, parentId);
// Move files to the new location
const success = await handleMove(dragPaths, targetParentPath, index);
if (success) {
await loadFileList();
}
} catch (error) {
console.error('Error moving files:', error);
}
},
[handleMove, loadFileList, filteredFiles]
);
// External file drag and drop handlers
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if drag contains files (not internal tree nodes)
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only hide overlay when leaving the container itself
if (e.currentTarget === e.target) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Set the drop effect to indicate this is a valid drop target
e.dataTransfer.dropEffect = 'copy';
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const { files } = e.dataTransfer;
if (files && files.length > 0) {
const uploadFiles = async () => {
try {
const success = await handleUpload(files);
if (success) {
await loadFileList();
}
} catch (error) {
console.error('Error uploading files:', error);
}
};
void uploadFiles();
}
},
[handleUpload, loadFileList]
);
return ( return (
<div <div
ref={target} ref={target}
style={{ height: 'calc(100vh - 140px)', marginTop: '20px' }} style={{
height: 'calc(100vh - 140px)',
marginTop: '20px',
position: 'relative',
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
> >
{/* Drag overlay */}
{isDragOver && (
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 123, 255, 0.1)',
border: '2px dashed var(--mantine-color-blue-6)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
zIndex: 1000,
pointerEvents: 'none',
}}
>
<IconUpload size={48} color="var(--mantine-color-blue-6)" />
<Text size="lg" fw={500} c="blue" mt="md">
Drop files here to upload
</Text>
</Box>
)}
{size && ( {size && (
<Tree <Tree
data={filteredFiles} data={filteredFiles}
@@ -131,6 +305,7 @@ const FileTree: React.FC<FileTreeProps> = ({
height={size.height} height={size.height}
indent={24} indent={24}
rowHeight={28} rowHeight={28}
onMove={handleTreeMove}
onActivate={(node) => { onActivate={(node) => {
const fileNode = node.data; const fileNode = node.data;
if (!node.isInternal) { if (!node.isInternal) {

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

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

View File

@@ -14,7 +14,11 @@ const Layout: React.FC = () => {
if (workspaceLoading) { if (workspaceLoading) {
return ( return (
<Center style={{ height: '100vh' }}> <Center
style={{ height: '100vh' }}
role="status"
aria-label="Loading workspace"
>
<Loader size="xl" /> <Loader size="xl" />
</Center> </Center>
); );

View File

@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from '../../test/utils';
import MainContent from './MainContent';
import { ModalProvider } from '../../contexts/ModalContext';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
// 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 contexts
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({
colorScheme: 'light',
updateColorScheme: vi.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock('../../contexts/WorkspaceDataContext', () => ({
useWorkspaceData: () => ({
currentWorkspace: { name: 'test-workspace', path: '/test' },
workspaces: [],
settings: {},
loading: false,
loadWorkspaces: vi.fn(),
loadWorkspaceData: vi.fn(),
setCurrentWorkspace: vi.fn(),
}),
WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</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 }) => (
<ThemeProvider>
<WorkspaceDataProvider>
<ModalProvider>{children}</ModalProvider>
</WorkspaceDataProvider>
</ThemeProvider>
);
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 mockHandleUpload = vi.fn();
const mockHandleMove = vi.fn();
const mockHandleRename = 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,
handleUpload: mockHandleUpload,
handleMove: mockHandleMove,
handleRename: mockHandleRename,
});
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');
});
});

View File

@@ -5,11 +5,13 @@ import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
import ContentView from '../editor/ContentView'; import ContentView from '../editor/ContentView';
import CreateFileModal from '../modals/file/CreateFileModal'; import CreateFileModal from '../modals/file/CreateFileModal';
import DeleteFileModal from '../modals/file/DeleteFileModal'; import DeleteFileModal from '../modals/file/DeleteFileModal';
import RenameFileModal from '../modals/file/RenameFileModal';
import CommitMessageModal from '../modals/git/CommitMessageModal'; import CommitMessageModal from '../modals/git/CommitMessageModal';
import { useFileContent } from '../../hooks/useFileContent'; import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations'; import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations'; import { useGitOperations } from '../../hooks/useGitOperations';
import { useModalContext } from '../../contexts/ModalContext';
type ViewTab = 'source' | 'preview'; type ViewTab = 'source' | 'preview';
@@ -31,8 +33,10 @@ const MainContent: React.FC<MainContentProps> = ({
setHasUnsavedChanges, setHasUnsavedChanges,
handleContentChange, handleContentChange,
} = useFileContent(selectedFile); } = useFileContent(selectedFile);
const { handleSave, handleCreate, handleDelete } = useFileOperations(); const { handleSave, handleCreate, handleDelete, handleRename } =
useFileOperations();
const { handleCommitAndPush } = useGitOperations(); const { handleCommitAndPush } = useGitOperations();
const { setRenameFileModalVisible } = useModalContext();
const handleTabChange = useCallback((value: string | null): void => { const handleTabChange = useCallback((value: string | null): void => {
if (value) { if (value) {
@@ -73,14 +77,50 @@ const MainContent: React.FC<MainContentProps> = ({
[handleDelete, handleFileSelect, loadFileList] [handleDelete, handleFileSelect, loadFileList]
); );
const handleRenameFile = useCallback(
async (oldPath: string, newPath: string): Promise<void> => {
const success = await handleRename(oldPath, newPath);
if (success) {
await loadFileList();
// If we renamed the currently selected file, update the selection
if (selectedFile === oldPath) {
await handleFileSelect(newPath);
}
}
},
[handleRename, handleFileSelect, loadFileList, selectedFile]
);
const handleBreadcrumbClick = useCallback(() => {
if (selectedFile) {
setRenameFileModalVisible(true);
}
}, [selectedFile, setRenameFileModalVisible]);
const renderBreadcrumbs = useMemo(() => { const renderBreadcrumbs = useMemo(() => {
if (!selectedFile) return null; if (!selectedFile) return null;
const pathParts = selectedFile.split('/'); const pathParts = selectedFile.split('/');
const items = pathParts.map((part, index) => ( const items = pathParts.map((part, index) => {
<Text key={index} size="sm"> // Make the filename (last part) clickable for rename
{part} const isFileName = index === pathParts.length - 1;
</Text> return (
)); <Text
key={index}
size="sm"
style={{
cursor: isFileName ? 'pointer' : 'default',
...(isFileName && {
textDecoration: 'underline',
textDecorationStyle: 'dotted',
}),
}}
onClick={isFileName ? handleBreadcrumbClick : undefined}
title={isFileName ? 'Click to rename file' : undefined}
>
{part}
</Text>
);
});
return ( return (
<Group> <Group>
@@ -93,7 +133,7 @@ const MainContent: React.FC<MainContentProps> = ({
)} )}
</Group> </Group>
); );
}, [selectedFile, hasUnsavedChanges]); }, [selectedFile, hasUnsavedChanges, handleBreadcrumbClick]);
return ( return (
<Box <Box
@@ -128,6 +168,10 @@ const MainContent: React.FC<MainContentProps> = ({
onDeleteFile={handleDeleteFile} onDeleteFile={handleDeleteFile}
selectedFile={selectedFile} selectedFile={selectedFile}
/> />
<RenameFileModal
onRenameFile={handleRenameFile}
selectedFile={selectedFile}
/>
<CommitMessageModal onCommitAndPush={handleCommitAndPush} /> <CommitMessageModal onCommitAndPush={handleCommitAndPush} />
</Box> </Box>
); );

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

View File

@@ -19,7 +19,7 @@ const Sidebar: React.FC<SidebarProps> = ({
files, files,
loadFileList, loadFileList,
}) => { }) => {
const { settings } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { handlePull } = useGitOperations(); const { handlePull } = useGitOperations();
useEffect(() => { useEffect(() => {
@@ -37,11 +37,16 @@ const Sidebar: React.FC<SidebarProps> = ({
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<FileActions handlePullChanges={handlePull} selectedFile={selectedFile} /> <FileActions
handlePullChanges={handlePull}
selectedFile={selectedFile}
loadFileList={loadFileList}
/>
<FileTree <FileTree
files={files} files={files}
handleFileSelect={handleFileSelect} handleFileSelect={handleFileSelect}
showHiddenFiles={settings.showHiddenFiles || false} showHiddenFiles={currentWorkspace?.showHiddenFiles || false}
loadFileList={loadFileList}
/> />
</Box> </Box>
); );

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

View File

@@ -21,6 +21,20 @@ const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
}) => { }) => {
const [password, setPassword] = useState<string>(''); 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 ( return (
<Modal <Modal
opened={opened} opened={opened}
@@ -39,22 +53,25 @@ const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
<PasswordInput <PasswordInput
label="Current Password" label="Current Password"
placeholder="Enter your current password" placeholder="Enter your current password"
data-testid="delete-account-password-input"
value={password} value={password}
onChange={(e) => setPassword(e.currentTarget.value)} onChange={(e) => setPassword(e.currentTarget.value)}
required required
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}> <Button
variant="default"
onClick={onClose}
data-testid="cancel-delete-account-button"
>
Cancel Cancel
</Button> </Button>
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => void handleConfirm()}
void onConfirm(password); data-testid="confirm-delete-account-button"
setPassword('');
}}
> >
Delete Account Delete
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

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

View File

@@ -11,7 +11,7 @@ import {
interface EmailPasswordModalProps { interface EmailPasswordModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (password: string) => Promise<void>; onConfirm: (password: string) => Promise<boolean>;
email: string; email: string;
} }
@@ -23,6 +23,27 @@ const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
}) => { }) => {
const [password, setPassword] = useState<string>(''); 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 ( return (
<Modal <Modal
opened={opened} opened={opened}
@@ -32,25 +53,30 @@ const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
size="sm" size="sm"
> >
<Stack> <Stack>
<Text size="sm"> <Text size="sm" data-testid="email-password-message">
Please enter your password to confirm changing your email to: {email} {`Please enter your password to confirm changing your email to: ${email}`}
</Text> </Text>
<PasswordInput <PasswordInput
label="Current Password" label="Current Password"
placeholder="Enter your current password" placeholder="Enter your current password"
data-testid="email-password-input"
value={password} value={password}
onKeyDown={handleKeyDown}
onChange={(e) => setPassword(e.currentTarget.value)} onChange={(e) => setPassword(e.currentTarget.value)}
required required
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}> <Button
variant="default"
onClick={onClose}
data-testid="cancel-email-password-button"
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => void handleConfirm()}
void onConfirm(password); data-testid="confirm-email-password-button"
setPassword(''); disabled={!password.trim()}
}}
> >
Confirm Confirm
</Button> </Button>

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

View File

@@ -12,12 +12,19 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
if (fileName) { if (fileName) {
await onCreateFile(fileName); await onCreateFile(fileName.trim());
setFileName(''); setFileName('');
setNewFileModalVisible(false); setNewFileModalVisible(false);
} }
}; };
const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
};
return ( return (
<Modal <Modal
opened={newFileModalVisible} opened={newFileModalVisible}
@@ -29,9 +36,12 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
<Box maw={400} mx="auto"> <Box maw={400} mx="auto">
<TextInput <TextInput
label="File Name" label="File Name"
type="text"
placeholder="Enter file name" placeholder="Enter file name"
data-testid="file-name-input"
value={fileName} value={fileName}
onChange={(event) => setFileName(event.currentTarget.value)} onChange={(event) => setFileName(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md" mb="md"
w="100%" w="100%"
/> />
@@ -39,10 +49,17 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
<Button <Button
variant="default" variant="default"
onClick={() => setNewFileModalVisible(false)} onClick={() => setNewFileModalVisible(false)}
data-testid="cancel-create-file-button"
> >
Cancel Cancel
</Button> </Button>
<Button onClick={() => void handleSubmit()}>Create</Button> <Button
onClick={() => void handleSubmit()}
data-testid="confirm-create-file-button"
disabled={!fileName.trim()}
>
Create
</Button>
</Group> </Group>
</Box> </Box>
</Modal> </Modal>

View 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');
});
});
});

View File

@@ -33,10 +33,15 @@ const DeleteFileModal: React.FC<DeleteFileModalProps> = ({
<Button <Button
variant="default" variant="default"
onClick={() => setDeleteFileModalVisible(false)} onClick={() => setDeleteFileModalVisible(false)}
data-testid="cancel-delete-file-button"
> >
Cancel Cancel
</Button> </Button>
<Button color="red" onClick={() => void handleConfirm()}> <Button
color="red"
onClick={() => void handleConfirm()}
data-testid="confirm-delete-file-button"
>
Delete Delete
</Button> </Button>
</Group> </Group>

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext';
interface RenameFileModalProps {
onRenameFile: (oldPath: string, newPath: string) => Promise<void>;
selectedFile: string | null;
}
const RenameFileModal: React.FC<RenameFileModalProps> = ({
onRenameFile,
selectedFile,
}) => {
const [newFileName, setNewFileName] = useState<string>('');
const { renameFileModalVisible, setRenameFileModalVisible } =
useModalContext();
// Extract just the filename from the full path for editing
const getCurrentFileName = (filePath: string | null): string => {
if (!filePath) return '';
const parts = filePath.split('/');
return parts[parts.length - 1] || '';
};
// Get the directory path (everything except the filename)
const getDirectoryPath = (filePath: string | null): string => {
if (!filePath) return '';
const parts = filePath.split('/');
return parts.slice(0, -1).join('/');
};
// Set the current filename when modal opens or selectedFile changes
useEffect(() => {
if (renameFileModalVisible && selectedFile) {
setNewFileName(getCurrentFileName(selectedFile));
}
}, [renameFileModalVisible, selectedFile]);
const handleSubmit = async (): Promise<void> => {
if (newFileName && selectedFile) {
const directoryPath = getDirectoryPath(selectedFile);
const newPath = directoryPath
? `${directoryPath}/${newFileName.trim()}`
: newFileName.trim();
await onRenameFile(selectedFile, newPath);
setNewFileName('');
setRenameFileModalVisible(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
};
const handleClose = (): void => {
setNewFileName('');
setRenameFileModalVisible(false);
};
return (
<Modal
opened={renameFileModalVisible}
onClose={handleClose}
title="Rename File"
centered
size="sm"
>
<Box maw={400} mx="auto">
<TextInput
label="File Name"
type="text"
placeholder="Enter new file name"
data-testid="rename-file-input"
value={newFileName}
onChange={(event) => setNewFileName(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md"
w="100%"
autoFocus
/>
<Group justify="flex-end" mt="xl">
<Button
variant="default"
onClick={handleClose}
data-testid="cancel-rename-file-button"
>
Cancel
</Button>
<Button
onClick={() => void handleSubmit()}
data-testid="confirm-rename-file-button"
disabled={
!newFileName.trim() ||
newFileName.trim() === getCurrentFileName(selectedFile)
}
>
Rename
</Button>
</Group>
</Box>
</Modal>
);
};
export default RenameFileModal;

View 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');
});
});
});

View File

@@ -14,13 +14,21 @@ const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
useModalContext(); useModalContext();
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
if (message) { const commitMessage = message.trim();
await onCommitAndPush(message); if (commitMessage) {
await onCommitAndPush(commitMessage);
setMessage(''); setMessage('');
setCommitMessageModalVisible(false); setCommitMessageModalVisible(false);
} }
}; };
const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
};
return ( return (
<Modal <Modal
opened={commitMessageModalVisible} opened={commitMessageModalVisible}
@@ -31,10 +39,13 @@ const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
> >
<Box maw={400} mx="auto"> <Box maw={400} mx="auto">
<TextInput <TextInput
type="text"
label="Commit Message" label="Commit Message"
data-testid="commit-message-input"
placeholder="Enter commit message" placeholder="Enter commit message"
value={message} value={message}
onChange={(event) => setMessage(event.currentTarget.value)} onChange={(event) => setMessage(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md" mb="md"
w="100%" w="100%"
/> />
@@ -42,10 +53,17 @@ const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
<Button <Button
variant="default" variant="default"
onClick={() => setCommitMessageModalVisible(false)} onClick={() => setCommitMessageModalVisible(false)}
data-testid="cancel-commit-message-button"
> >
Cancel Cancel
</Button> </Button>
<Button onClick={() => void handleSubmit()}>Commit</Button> <Button
onClick={() => void handleSubmit()}
data-testid="confirm-commit-message-button"
disabled={!message.trim()}
>
Commit
</Button>
</Group> </Group>
</Box> </Box>
</Modal> </Modal>

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

View File

@@ -54,12 +54,14 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
label="Email" label="Email"
required required
value={email} value={email}
data-testid="create-user-email-input"
onChange={(e) => setEmail(e.currentTarget.value)} onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="user@example.com" placeholder="user@example.com"
/> />
<TextInput <TextInput
label="Display Name" label="Display Name"
value={displayName} value={displayName}
data-testid="create-user-display-name-input"
onChange={(e) => setDisplayName(e.currentTarget.value)} onChange={(e) => setDisplayName(e.currentTarget.value)}
placeholder="John Doe" placeholder="John Doe"
/> />
@@ -67,6 +69,7 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
label="Password" label="Password"
required required
value={password} value={password}
data-testid="create-user-password-input"
onChange={(e) => setPassword(e.currentTarget.value)} onChange={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter password" placeholder="Enter password"
/> />
@@ -74,6 +77,7 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
label="Role" label="Role"
required required
value={role} value={role}
data-testid="create-user-role-select"
onChange={(value) => value && setRole(value as UserRole)} onChange={(value) => value && setRole(value as UserRole)}
data={[ data={[
{ value: UserRole.Admin, label: 'Admin' }, { value: UserRole.Admin, label: 'Admin' },
@@ -82,10 +86,18 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
]} ]}
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}> <Button
variant="default"
onClick={onClose}
data-testid="cancel-create-user-button"
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => void handleSubmit} loading={loading}> <Button
onClick={() => void handleSubmit()}
loading={loading}
data-testid="confirm-create-user-button"
>
Create User Create User
</Button> </Button>
</Group> </Group>

View File

@@ -0,0 +1,194 @@
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, Theme, 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,
theme: Theme.Dark,
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();
});
});
});

View File

@@ -31,11 +31,20 @@ const DeleteUserModal: React.FC<DeleteUserModalProps> = ({
deleted. deleted.
</Text> </Text>
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}> <Button
variant="default"
onClick={onClose}
data-testid="cancel-delete-user-button"
>
Cancel Cancel
</Button> </Button>
<Button color="red" onClick={() => void onConfirm()} loading={loading}> <Button
Delete User color="red"
onClick={() => void onConfirm()}
loading={loading}
data-testid="confirm-delete-user-button"
>
Delete
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -0,0 +1,418 @@
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, Theme, 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,
theme: Theme.Dark,
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,
theme: Theme.Dark,
};
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();
});
});
});

View File

@@ -72,6 +72,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
label="Email" label="Email"
required required
value={formData.email} value={formData.email}
data-testid="edit-user-email-input"
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, email: e.currentTarget.value }) setFormData({ ...formData, email: e.currentTarget.value })
} }
@@ -80,6 +81,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
<TextInput <TextInput
label="Display Name" label="Display Name"
value={formData.displayName} value={formData.displayName}
data-testid="edit-user-display-name-input"
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, displayName: e.currentTarget.value }) setFormData({ ...formData, displayName: e.currentTarget.value })
} }
@@ -89,6 +91,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
label="Role" label="Role"
required required
value={formData.role ? formData.role.toString() : null} value={formData.role ? formData.role.toString() : null}
data-testid="edit-user-role-select"
onChange={(value) => onChange={(value) =>
setFormData({ ...formData, role: value as UserRole }) setFormData({ ...formData, role: value as UserRole })
} }
@@ -101,6 +104,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
<PasswordInput <PasswordInput
label="New Password" label="New Password"
value={formData.password} value={formData.password}
data-testid="edit-user-password-input"
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, password: e.currentTarget.value }) setFormData({ ...formData, password: e.currentTarget.value })
} }
@@ -110,10 +114,18 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
Leave password empty to keep the current password Leave password empty to keep the current password
</Text> </Text>
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}> <Button
variant="default"
onClick={onClose}
data-testid="cancel-edit-user-button"
>
Cancel Cancel
</Button> </Button>
<Button onClick={() => void handleSubmit} loading={loading}> <Button
onClick={() => void handleSubmit()}
loading={loading}
data-testid="confirm-edit-user-button"
>
Save Changes Save Changes
</Button> </Button>
</Group> </Group>

View File

@@ -0,0 +1,413 @@
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(),
renameFileModalVisible: false,
setRenameFileModalVisible: 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');
});
});
});

View File

@@ -18,7 +18,8 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
useModalContext(); useModalContext();
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
if (!name.trim()) { const trimmedName = name.trim();
if (!trimmedName) {
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
message: 'Workspace name is required', message: 'Workspace name is required',
@@ -29,7 +30,7 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
setLoading(true); setLoading(true);
try { try {
const workspace = await createWorkspace(name); const workspace = await createWorkspace(trimmedName);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Workspace created successfully', message: 'Workspace created successfully',
@@ -61,8 +62,10 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
> >
<Box maw={400} mx="auto"> <Box maw={400} mx="auto">
<TextInput <TextInput
type="text"
label="Workspace Name" label="Workspace Name"
placeholder="Enter workspace name" placeholder="Enter workspace name"
data-testid="workspace-name-input"
value={name} value={name}
onChange={(event) => setName(event.currentTarget.value)} onChange={(event) => setName(event.currentTarget.value)}
mb="md" mb="md"
@@ -74,10 +77,15 @@ const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
variant="default" variant="default"
onClick={() => setCreateWorkspaceModalVisible(false)} onClick={() => setCreateWorkspaceModalVisible(false)}
disabled={loading} disabled={loading}
data-testid="cancel-create-workspace-button"
> >
Cancel Cancel
</Button> </Button>
<Button onClick={() => void handleSubmit} loading={loading}> <Button
onClick={() => void handleSubmit()}
loading={loading}
data-testid="confirm-create-workspace-button"
>
Create Create
</Button> </Button>
</Group> </Group>

View File

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

View File

@@ -28,11 +28,19 @@ const DeleteWorkspaceModal: React.FC<DeleteUserModalProps> = ({
permanently deleted. permanently deleted.
</Text> </Text>
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}> <Button
variant="default"
onClick={onClose}
data-testid="cancel-delete-workspace-button"
>
Cancel Cancel
</Button> </Button>
<Button color="red" onClick={() => void onConfirm}> <Button
Delete Workspace color="red"
onClick={() => void onConfirm()}
data-testid="confirm-delete-workspace-button"
>
Delete
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -0,0 +1,180 @@
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, Theme } 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,
theme: Theme.Dark,
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(),
updateProfile: 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(),
updateProfile: 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,
theme: mockUser.theme,
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(),
updateProfile: 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();
});
});
});

View File

@@ -47,6 +47,10 @@ const UserMenu: React.FC = () => {
radius="xl" radius="xl"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => setOpened((o) => !o)} onClick={() => setOpened((o) => !o)}
aria-label="User menu"
aria-expanded={opened}
aria-haspopup="menu"
role="button"
> >
<IconUser size={24} /> <IconUser size={24} />
</Avatar> </Avatar>

View File

@@ -0,0 +1,232 @@
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(),
renameFileModalVisible: false,
setRenameFileModalVisible: 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);
});
});

View File

@@ -95,6 +95,7 @@ const WorkspaceSwitcher: React.FC = () => {
<ActionIcon <ActionIcon
variant="default" variant="default"
size="md" size="md"
aria-label="Create New Workspace"
onClick={handleCreateWorkspace} onClick={handleCreateWorkspace}
> >
<IconFolderPlus size={16} /> <IconFolderPlus size={16} />
@@ -109,10 +110,10 @@ const WorkspaceSwitcher: React.FC = () => {
</Center> </Center>
) : ( ) : (
workspaces.map((workspace) => { workspaces.map((workspace) => {
const isSelected = workspace.name === currentWorkspace?.name; const isSelected = workspace.id === currentWorkspace?.id;
return ( return (
<Paper <Paper
key={workspace.name} key={workspace.id}
p="xs" p="xs"
withBorder withBorder
style={(theme) => style={(theme) =>
@@ -152,6 +153,7 @@ const WorkspaceSwitcher: React.FC = () => {
variant="subtle" variant="subtle"
size="lg" size="lg"
color={getConditionalColor(theme, true)} color={getConditionalColor(theme, true)}
aria-label="Workspace Settings"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSettingsModalVisible(true); setSettingsModalVisible(true);

View 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);
});
});
});

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

View File

@@ -89,6 +89,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
email: user.email, email: user.email,
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
theme: user.theme,
}; };
dispatch({ dispatch({
type: SettingsActionType.INIT_SETTINGS, type: SettingsActionType.INIT_SETTINGS,
@@ -107,6 +108,13 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
}); });
}; };
const handleThemeChange = (theme: string): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { theme } as UserProfileSettings,
});
};
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
const updates: UserProfileSettings = {}; const updates: UserProfileSettings = {};
const needsPasswordConfirmation = const needsPasswordConfirmation =
@@ -117,6 +125,14 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
updates.displayName = state.localSettings.displayName || ''; updates.displayName = state.localSettings.displayName || '';
} }
// Add theme if changed
if (
state.localSettings.theme &&
state.localSettings.theme !== state.initialSettings.theme
) {
updates.theme = state.localSettings.theme;
}
// Handle password change // Handle password change
if (state.localSettings.newPassword) { if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) { if (!state.localSettings.currentPassword) {
@@ -153,7 +169,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
} }
}; };
const handleEmailConfirm = async (password: string): Promise<void> => { const handleEmailConfirm = async (password: string): Promise<boolean> => {
const updates: UserProfileSettings = { const updates: UserProfileSettings = {
...state.localSettings, ...state.localSettings,
currentPassword: password, currentPassword: password,
@@ -181,6 +197,11 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
dispatch({ type: SettingsActionType.MARK_SAVED }); dispatch({ type: SettingsActionType.MARK_SAVED });
setEmailModalOpened(false); setEmailModalOpened(false);
onClose(); onClose();
return true;
} else {
// TODO: Handle errors appropriately
// notifications.show({...
return false;
} }
}; };
@@ -211,6 +232,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
<ProfileSettings <ProfileSettings
settings={state.localSettings} settings={state.localSettings}
onInputChange={handleInputChange} onInputChange={handleInputChange}
onThemeChange={handleThemeChange}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
@@ -238,7 +260,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => void handleSubmit} onClick={() => void handleSubmit()}
loading={loading} loading={loading}
disabled={!state.hasUnsavedChanges} disabled={!state.hasUnsavedChanges}
> >

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

View File

@@ -0,0 +1,132 @@
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';
import { Theme, UserRole, type User } from '@/types/models';
// Mock user for AuthContext
const mockUser: User = {
id: 1,
email: 'test@example.com',
displayName: 'Test User',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
};
// Mock the auth context
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
}),
}));
// 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();
});
});

View File

@@ -1,32 +1,66 @@
import React from 'react'; import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core'; import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models'; import { useAuth } from '@/contexts/AuthContext';
import { Theme, type UserProfileSettings } from '@/types/models';
interface ProfileSettingsProps { interface ProfileSettingsProps {
settings: UserProfileSettings; settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void; onInputChange: (key: keyof UserProfileSettings, value: string) => void;
onThemeChange?: (theme: Theme) => void;
} }
const ProfileSettingsComponent: React.FC<ProfileSettingsProps> = ({ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
settings, settings,
onInputChange, onInputChange,
}) => ( onThemeChange,
<Box> }) => {
<Stack gap="md"> const { user } = useAuth();
<TextInput const currentTheme = settings.theme || user?.theme || Theme.Dark;
label="Display Name"
value={settings.displayName || ''}
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
placeholder="Enter display name"
/>
<TextInput
label="Email"
value={settings.email || ''}
onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email"
/>
</Stack>
</Box>
);
export default ProfileSettingsComponent; const handleThemeToggle = () => {
const newTheme = currentTheme === Theme.Dark ? Theme.Light : Theme.Dark;
if (onThemeChange) {
onThemeChange(newTheme);
}
};
return (
<Box>
<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"
/>
<Box mb="md">
<Group justify="space-between" align="center">
<div>
<Text size="sm">Default Dark Mode</Text>
<Text size="xs" c="dimmed">
Sets the default theme for new workspaces
</Text>
</div>
<Switch
checked={currentTheme === Theme.Dark}
onChange={handleThemeToggle}
data-testid="theme-toggle"
/>
</Group>
</Box>
</Stack>
</Box>
);
};
export default ProfileSettings;

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

View File

@@ -41,6 +41,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({
<Stack gap="md"> <Stack gap="md">
<PasswordInput <PasswordInput
label="Current Password" label="Current Password"
type="password"
value={settings.currentPassword || ''} value={settings.currentPassword || ''}
onChange={(e) => onChange={(e) =>
handlePasswordChange('currentPassword', e.currentTarget.value) handlePasswordChange('currentPassword', e.currentTarget.value)
@@ -49,6 +50,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({
/> />
<PasswordInput <PasswordInput
label="New Password" label="New Password"
type="password"
value={settings.newPassword || ''} value={settings.newPassword || ''}
onChange={(e) => onChange={(e) =>
handlePasswordChange('newPassword', e.currentTarget.value) handlePasswordChange('newPassword', e.currentTarget.value)
@@ -57,6 +59,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({
/> />
<PasswordInput <PasswordInput
label="Confirm New Password" label="Confirm New Password"
type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => onChange={(e) =>
handlePasswordChange('confirmNewPassword', e.currentTarget.value) handlePasswordChange('confirmNewPassword', e.currentTarget.value)

View File

@@ -0,0 +1,111 @@
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, Theme, type User } from '@/types/models';
// Mock the auth context
const mockCurrentUser: User = {
id: 1,
email: 'admin@example.com',
displayName: 'Admin User',
role: UserRole.Admin,
theme: Theme.Dark,
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();
});
});

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

View File

@@ -0,0 +1,291 @@
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, Theme, 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,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
};
const mockUsers: User[] = [
mockCurrentUser,
{
id: 2,
email: 'editor@example.com',
displayName: 'Editor User',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-15T00:00:00Z',
lastWorkspaceId: 2,
},
{
id: 3,
email: 'viewer@example.com',
displayName: 'Viewer User',
role: UserRole.Viewer,
theme: Theme.Dark,
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();
});
});

View File

@@ -86,6 +86,7 @@ const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
<Group gap="xs" justify="flex-end"> <Group gap="xs" justify="flex-end">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
aria-label="Edit user"
color="blue" color="blue"
onClick={() => setEditModalData(user)} onClick={() => setEditModalData(user)}
> >
@@ -93,6 +94,7 @@ const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
aria-label="Delete user"
color="red" color="red"
onClick={() => handleDeleteClick(user)} onClick={() => handleDeleteClick(user)}
disabled={user.id === currentUser.id} disabled={user.id === currentUser.id}
@@ -125,6 +127,7 @@ const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
</Text> </Text>
<Button <Button
leftSection={<IconPlus size={16} />} leftSection={<IconPlus size={16} />}
aria-label="Create user"
onClick={() => setCreateModalOpened(true)} onClick={() => setCreateModalOpened(true)}
> >
Create User Create User

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

View File

@@ -0,0 +1,76 @@
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', () => {
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 />);
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 />);
const toggle = screen.getByRole('switch');
expect(toggle).toBeChecked();
});
it('toggles theme from light to dark', () => {
render(<AppearanceSettings />);
const toggle = screen.getByRole('switch');
fireEvent.click(toggle);
expect(mockUpdateColorScheme).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 />);
const toggle = screen.getByRole('switch');
fireEvent.click(toggle);
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
});
});

View File

@@ -1,21 +1,14 @@
import React from 'react'; import React from 'react';
import { Text, Switch, Group, Box } from '@mantine/core'; import { Text, Switch, Group, Box } from '@mantine/core';
import { useTheme } from '../../../contexts/ThemeContext';
import { Theme } from '@/types/models'; import { Theme } from '@/types/models';
import { useTheme } from '../../../contexts/ThemeContext';
interface AppearanceSettingsProps { const AppearanceSettings: React.FC = () => {
onThemeChange: (newTheme: Theme) => void;
}
const AppearanceSettings: React.FC<AppearanceSettingsProps> = ({
onThemeChange,
}) => {
const { colorScheme, updateColorScheme } = useTheme(); const { colorScheme, updateColorScheme } = useTheme();
const handleThemeChange = (): void => { const handleThemeChange = (): void => {
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark; const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;
updateColorScheme(newTheme); updateColorScheme(newTheme);
onThemeChange(newTheme);
}; };
return ( return (

View File

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

View File

@@ -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);
});
});

View File

@@ -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');
});
});

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

View File

@@ -0,0 +1,203 @@
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();
const mockUpdateColorScheme = 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: () => (
<div data-testid="appearance-settings">
Appearance Settings
</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: mockUpdateColorScheme,
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('renders appearance settings', () => {
render(<WorkspaceSettings />);
expect(screen.getByTestId('appearance-settings')).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();
});
it('reverts theme when canceling', () => {
render(<WorkspaceSettings />);
// Click cancel
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
// Theme should be reverted to saved state (Light)
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,4 +1,4 @@
import React, { useReducer, useEffect, useCallback, useRef } from 'react'; import React, { useReducer, useEffect, useCallback } from 'react';
import { import {
Modal, Modal,
Badge, Badge,
@@ -72,14 +72,13 @@ function settingsReducer(
} }
const WorkspaceSettings: React.FC = () => { const WorkspaceSettings: React.FC = () => {
const { currentWorkspace, updateSettings } = useWorkspace(); const { currentWorkspace, updateSettings, updateColorScheme, colorScheme } =
useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState); const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef<boolean>(true);
useEffect(() => { useEffect(() => {
if (isInitialMount.current && currentWorkspace) { if (currentWorkspace && settingsModalVisible) {
isInitialMount.current = false;
const settings: Partial<Workspace> = { const settings: Partial<Workspace> = {
name: currentWorkspace.name, name: currentWorkspace.name,
theme: currentWorkspace.theme, theme: currentWorkspace.theme,
@@ -96,7 +95,7 @@ const WorkspaceSettings: React.FC = () => {
}; };
dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings }); dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings });
} }
}, [currentWorkspace]); }, [currentWorkspace, settingsModalVisible]);
const handleInputChange = useCallback( const handleInputChange = useCallback(
<K extends keyof Workspace>(key: K, value: Workspace[K]): void => { <K extends keyof Workspace>(key: K, value: Workspace[K]): void => {
@@ -118,7 +117,13 @@ const WorkspaceSettings: React.FC = () => {
return; return;
} }
await updateSettings(state.localSettings); // Save with current Mantine theme
const settingsToSave = {
...state.localSettings,
theme: colorScheme as Theme,
};
await updateSettings(settingsToSave);
dispatch({ type: SettingsActionType.MARK_SAVED }); dispatch({ type: SettingsActionType.MARK_SAVED });
notifications.show({ notifications.show({
message: 'Settings saved successfully', message: 'Settings saved successfully',
@@ -137,8 +142,12 @@ const WorkspaceSettings: React.FC = () => {
}; };
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
// Revert theme to saved state
if (state.initialSettings.theme) {
updateColorScheme(state.initialSettings.theme);
}
setSettingsModalVisible(false); setSettingsModalVisible(false);
}, [setSettingsModalVisible]); }, [setSettingsModalVisible, state.initialSettings.theme, updateColorScheme]);
return ( return (
<Modal <Modal
@@ -180,11 +189,7 @@ const WorkspaceSettings: React.FC = () => {
<Accordion.Item value="appearance"> <Accordion.Item value="appearance">
<AccordionControl>Appearance</AccordionControl> <AccordionControl>Appearance</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<AppearanceSettings <AppearanceSettings />
onThemeChange={(newTheme: string) =>
handleInputChange('theme', newTheme as Theme)
}
/>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
@@ -235,7 +240,7 @@ const WorkspaceSettings: React.FC = () => {
<Button variant="default" onClick={handleClose}> <Button variant="default" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={() => void handleSubmit}>Save Changes</Button> <Button onClick={() => void handleSubmit()}>Save Changes</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -0,0 +1,771 @@
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, Theme, 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,
theme: Theme.Dark,
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);
});
});
});

View File

@@ -12,7 +12,8 @@ import {
refreshToken as apiRefreshToken, refreshToken as apiRefreshToken,
getCurrentUser, getCurrentUser,
} from '@/api/auth'; } from '@/api/auth';
import type { User } from '@/types/models'; import { updateProfile as apiUpdateProfile } from '@/api/user';
import type { User, UserProfileSettings } from '@/types/models';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -22,6 +23,7 @@ interface AuthContextType {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshToken: () => Promise<boolean>; refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
updateProfile: (updates: UserProfileSettings) => Promise<User>;
} }
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
@@ -109,6 +111,31 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
} }
}, []); }, []);
const updateProfile = useCallback(
async (updates: UserProfileSettings): Promise<User> => {
try {
const updatedUser = await apiUpdateProfile(updates);
setUser(updatedUser);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return updatedUser;
} catch (error) {
console.error('Failed to update profile:', error);
notifications.show({
title: 'Error',
message:
error instanceof Error ? error.message : 'Failed to update profile',
color: 'red',
});
throw error;
}
},
[]
);
const value: AuthContextType = { const value: AuthContextType = {
user, user,
loading, loading,
@@ -117,6 +144,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
logout, logout,
refreshToken, refreshToken,
refreshUser, refreshUser,
updateProfile,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View 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);
});
});
});

View File

@@ -10,6 +10,8 @@ interface ModalContextType {
setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>; setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
deleteFileModalVisible: boolean; deleteFileModalVisible: boolean;
setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>; setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
renameFileModalVisible: boolean;
setRenameFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
commitMessageModalVisible: boolean; commitMessageModalVisible: boolean;
setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>; setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
settingsModalVisible: boolean; settingsModalVisible: boolean;
@@ -30,6 +32,7 @@ interface ModalProviderProps {
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => { export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [newFileModalVisible, setNewFileModalVisible] = useState(false); const [newFileModalVisible, setNewFileModalVisible] = useState(false);
const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false); const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false);
const [renameFileModalVisible, setRenameFileModalVisible] = useState(false);
const [commitMessageModalVisible, setCommitMessageModalVisible] = const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false); useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false);
@@ -43,6 +46,8 @@ export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
setNewFileModalVisible, setNewFileModalVisible,
deleteFileModalVisible, deleteFileModalVisible,
setDeleteFileModalVisible, setDeleteFileModalVisible,
renameFileModalVisible,
setRenameFileModalVisible,
commitMessageModalVisible, commitMessageModalVisible,
setCommitMessageModalVisible, setCommitMessageModalVisible,
settingsModalVisible, settingsModalVisible,

View 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');
});
});
});

View File

@@ -2,6 +2,7 @@ import React, {
createContext, createContext,
useContext, useContext,
useCallback, useCallback,
useMemo,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core'; import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core';
@@ -22,15 +23,26 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const updateColorScheme = useCallback( const updateColorScheme = useCallback(
(newTheme: MantineColorScheme): void => { (newTheme: MantineColorScheme): void => {
setColorScheme(newTheme); if (newTheme === 'light' || newTheme === 'dark') {
if (setColorScheme) {
setColorScheme(newTheme);
}
}
}, },
[setColorScheme] [setColorScheme]
); );
const value: ThemeContextType = { // Ensure colorScheme is never undefined by falling back to light theme
colorScheme, const normalizedColorScheme =
updateColorScheme, colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : 'light';
};
const value: ThemeContextType = useMemo(
() => ({
colorScheme: normalizedColorScheme,
updateColorScheme,
}),
[normalizedColorScheme, updateColorScheme]
);
return ( return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>

View File

@@ -0,0 +1,750 @@
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 { 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([]);
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(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([]);
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(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);
});
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();
});
});
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.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.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);
});
});
});

View File

@@ -7,7 +7,7 @@ import React, {
useCallback, useCallback,
} from 'react'; } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models'; import { type Workspace } from '@/types/models';
import { import {
getWorkspace, getWorkspace,
listWorkspaces, listWorkspaces,
@@ -19,7 +19,6 @@ import { useTheme } from './ThemeContext';
interface WorkspaceDataContextType { interface WorkspaceDataContextType {
currentWorkspace: Workspace | null; currentWorkspace: Workspace | null;
workspaces: Workspace[]; workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
loading: boolean; loading: boolean;
loadWorkspaces: () => Promise<Workspace[]>; loadWorkspaces: () => Promise<Workspace[]>;
loadWorkspaceData: (workspaceName: string) => Promise<void>; loadWorkspaceData: (workspaceName: string) => Promise<void>;
@@ -75,7 +74,8 @@ export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
}); });
} }
}, },
[updateColorScheme] // eslint-disable-next-line react-hooks/exhaustive-deps
[]
); );
const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => { const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => {
@@ -121,7 +121,6 @@ export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
const value: WorkspaceDataContextType = { const value: WorkspaceDataContextType = {
currentWorkspace, currentWorkspace,
workspaces, workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
loading, loading,
loadWorkspaces, loadWorkspaces,
loadWorkspaceData, loadWorkspaceData,

View File

@@ -0,0 +1,580 @@
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,
Theme,
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,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
},
{
id: 2,
email: 'editor@example.com',
displayName: 'Editor User',
role: UserRole.Editor,
theme: Theme.Dark,
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);
});
});
});

View File

@@ -28,7 +28,7 @@ export const useAdminData = <T extends AdminDataType>(
type: T type: T
): AdminDataResult<T> => { ): AdminDataResult<T> => {
// Initialize with the appropriate empty type // Initialize with the appropriate empty type
const getInitialData = (): AdminData<T> => { const getInitialData = useCallback((): AdminData<T> => {
if (type === 'stats') { if (type === 'stats') {
return {} as SystemStats as AdminData<T>; return {} as SystemStats as AdminData<T>;
} else if (type === 'workspaces') { } else if (type === 'workspaces') {
@@ -38,12 +38,18 @@ export const useAdminData = <T extends AdminDataType>(
} else { } else {
return [] as unknown as AdminData<T>; return [] as unknown as AdminData<T>;
} }
}; }, [type]);
const [data, setData] = useState<AdminData<T>>(getInitialData()); const [data, setData] = useState<AdminData<T>>(getInitialData());
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Reset data when type changes
useEffect(() => {
setData(getInitialData());
setError(null);
}, [type, getInitialData]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);

View 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
);
});
});
});

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

View 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);
});
});
});
});

View File

@@ -17,35 +17,55 @@ export const useFileNavigation = (): UseFileNavigationResult => {
const handleFileSelect = useCallback( const handleFileSelect = useCallback(
async (filePath: string | null): Promise<void> => { async (filePath: string | null): Promise<void> => {
const newPath = filePath || DEFAULT_FILE.path; // Consider empty string as null
setSelectedFile(newPath); const effectiveFilePath = filePath === '' ? null : filePath;
setIsNewFile(!filePath);
if (filePath) { if (effectiveFilePath) {
await saveLastOpenedFile(filePath); 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 // Load last opened file when workspace changes
useEffect(() => { useEffect(() => {
const initializeFile = async (): Promise<void> => { const initializeFile = async (): Promise<void> => {
setSelectedFile(DEFAULT_FILE.path); try {
setIsNewFile(true); setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
const lastFile = await loadLastOpenedFile(); const lastFile = await loadLastOpenedFile();
if (lastFile) {
await handleFileSelect(lastFile); if (lastFile) {
} else { setSelectedFile(lastFile);
await handleFileSelect(null); setIsNewFile(false);
}
} catch (err) {
console.error('Failed to load last opened file:', err);
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
} }
}; };
if (currentWorkspace) { if (currentWorkspace) {
void initializeFile(); void initializeFile();
} else {
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
} }
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]); }, [currentWorkspace, loadLastOpenedFile, saveLastOpenedFile]);
return { selectedFile, isNewFile, handleFileSelect }; return { selectedFile, isNewFile, handleFileSelect };
}; };

View File

@@ -0,0 +1,535 @@
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;
gitAutoCommit?: boolean;
gitEnabled?: boolean;
gitCommitMsgTemplate?: string;
} | null;
} = {
currentWorkspace: {
id: 1,
name: 'test-workspace',
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',
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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = false;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
mockWorkspaceData.currentWorkspace!.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.currentWorkspace!.gitAutoCommit = true;
mockWorkspaceData.currentWorkspace!.gitEnabled = true;
mockWorkspaceData.currentWorkspace!.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);
});
});
});

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { saveFile, deleteFile } from '../api/file'; import { saveFile, deleteFile, uploadFile, moveFile } from '../api/file';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useGitOperations } from './useGitOperations'; import { useGitOperations } from './useGitOperations';
import { FileAction } from '@/types/models'; import { FileAction } from '@/types/models';
@@ -9,16 +9,24 @@ interface UseFileOperationsResult {
handleSave: (filePath: string, content: string) => Promise<boolean>; handleSave: (filePath: string, content: string) => Promise<boolean>;
handleDelete: (filePath: string) => Promise<boolean>; handleDelete: (filePath: string) => Promise<boolean>;
handleCreate: (fileName: string, initialContent?: string) => Promise<boolean>; handleCreate: (fileName: string, initialContent?: string) => Promise<boolean>;
handleUpload: (files: FileList, targetPath?: string) => Promise<boolean>;
handleMove: (
filePaths: string[],
destinationParentPath: string,
index?: number
) => Promise<boolean>;
handleRename: (oldPath: string, newPath: string) => Promise<boolean>;
} }
export const useFileOperations = (): UseFileOperationsResult => { export const useFileOperations = (): UseFileOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData(); const { currentWorkspace } = useWorkspaceData();
const { handleCommitAndPush } = useGitOperations(); const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback( const autoCommit = useCallback(
async (filePath: string, action: FileAction): Promise<void> => { async (filePath: string, action: FileAction): Promise<void> => {
if (settings.gitAutoCommit && settings.gitEnabled) { if (!currentWorkspace || !currentWorkspace.gitEnabled) return;
let commitMessage = settings.gitCommitMsgTemplate if (currentWorkspace.gitAutoCommit && currentWorkspace.gitEnabled) {
let commitMessage = currentWorkspace.gitCommitMsgTemplate
.replace('${filename}', filePath) .replace('${filename}', filePath)
.replace('${action}', action); .replace('${action}', action);
@@ -28,7 +36,7 @@ export const useFileOperations = (): UseFileOperationsResult => {
await handleCommitAndPush(commitMessage); await handleCommitAndPush(commitMessage);
} }
}, },
[settings, handleCommitAndPush] [currentWorkspace, handleCommitAndPush]
); );
const handleSave = useCallback( const handleSave = useCallback(
@@ -109,5 +117,117 @@ export const useFileOperations = (): UseFileOperationsResult => {
[currentWorkspace, autoCommit] [currentWorkspace, autoCommit]
); );
return { handleSave, handleDelete, handleCreate }; const handleUpload = useCallback(
async (files: FileList, targetPath?: string): Promise<boolean> => {
if (!currentWorkspace) return false;
try {
// Default to '.' (root directory) if no target path is provided
await uploadFile(currentWorkspace.name, targetPath || '.', files);
notifications.show({
title: 'Success',
message: `Successfully uploaded ${files.length} file(s)`,
color: 'green',
});
// Auto-commit if enabled
await autoCommit('multiple files', FileAction.Create);
return true;
} catch (error) {
console.error('Error uploading files:', error);
notifications.show({
title: 'Error',
message: 'Failed to upload files',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleMove = useCallback(
async (
filePaths: string[],
destinationParentPath: string,
_index?: number
): Promise<boolean> => {
if (!currentWorkspace) return false;
try {
// Move each file to the destination directory
const movePromises = filePaths.map(async (filePath) => {
// Extract the filename from the path
const fileName = filePath.split('/').pop() || '';
// Construct the destination path
const destinationPath = destinationParentPath
? `${destinationParentPath}/${fileName}`
: fileName;
// Call the API to move the file
await moveFile(currentWorkspace.name, filePath, destinationPath);
});
await Promise.all(movePromises);
notifications.show({
title: 'Success',
message: `Successfully moved ${filePaths.length} file(s)`,
color: 'green',
});
// Auto-commit if enabled
await autoCommit('multiple files', FileAction.Update);
return true;
} catch (error) {
console.error('Error moving files:', error);
notifications.show({
title: 'Error',
message: 'Failed to move files',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleRename = useCallback(
async (oldPath: string, newPath: string): Promise<boolean> => {
if (!currentWorkspace) return false;
try {
// Use moveFile API for renaming (rename is essentially a move operation)
await moveFile(currentWorkspace.name, oldPath, newPath);
notifications.show({
title: 'Success',
message: 'File renamed successfully',
color: 'green',
});
await autoCommit(newPath, FileAction.Update);
return true;
} catch (error) {
console.error('Error renaming file:', error);
notifications.show({
title: 'Error',
message: 'Failed to rename file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
return {
handleSave,
handleDelete,
handleCreate,
handleUpload,
handleMove,
handleRename,
};
}; };

View File

@@ -0,0 +1,412 @@
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; gitEnabled: boolean } | null;
} = {
currentWorkspace: {
id: 1,
name: 'test-workspace',
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',
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.currentWorkspace!.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.currentWorkspace!.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',
gitEnabled: true,
};
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.currentWorkspace!.gitEnabled).toBe(true);
// Disable git
mockWorkspaceData.currentWorkspace!.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!,
gitEnabled: true,
};
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.currentWorkspace = {
id: 1,
name: 'test-workspace',
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();
});
});
});

View File

@@ -10,16 +10,21 @@ interface UseGitOperationsResult {
} }
export const useGitOperations = (): UseGitOperationsResult => { export const useGitOperations = (): UseGitOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData(); const { currentWorkspace } = useWorkspaceData();
const handlePull = useCallback(async (): Promise<boolean> => { const handlePull = useCallback(async (): Promise<boolean> => {
if (!currentWorkspace || !settings.gitEnabled) return false; if (
!currentWorkspace ||
!currentWorkspace.gitEnabled ||
!currentWorkspace.name
)
return false;
try { try {
await pullChanges(currentWorkspace.name); const message = await pullChanges(currentWorkspace.name);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully pulled latest changes', message: message || 'Successfully pulled latest changes',
color: 'green', color: 'green',
}); });
return true; return true;
@@ -32,16 +37,17 @@ export const useGitOperations = (): UseGitOperationsResult => {
}); });
return false; return false;
} }
}, [currentWorkspace, settings.gitEnabled]); }, [currentWorkspace]);
const handleCommitAndPush = useCallback( const handleCommitAndPush = useCallback(
async (message: string): Promise<void> => { async (message: string): Promise<void> => {
if (!currentWorkspace || !settings.gitEnabled) return; if (!currentWorkspace || !currentWorkspace.gitEnabled) return;
const commitHash: CommitHash = await commitAndPush(
currentWorkspace.name,
message
);
try { try {
const commitHash: CommitHash = await commitAndPush(
currentWorkspace.name,
message
);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully committed and pushed changes ' + commitHash, message: 'Successfully committed and pushed changes ' + commitHash,
@@ -58,7 +64,7 @@ export const useGitOperations = (): UseGitOperationsResult => {
return; return;
} }
}, },
[currentWorkspace, settings.gitEnabled] [currentWorkspace]
); );
return { handlePull, handleCommitAndPush }; return { handlePull, handleCommitAndPush };

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
// Import theme CSS as text that will be bundled
import atomOneLightTheme from 'highlight.js/styles/atom-one-light.css?inline';
import atomOneDarkTheme from 'highlight.js/styles/atom-one-dark.css?inline';
export const useHighlightTheme = (colorScheme: 'light' | 'dark') => {
useEffect(() => {
// Remove existing highlight theme
const existingStylesheet = document.querySelector(
'style[data-highlight-theme]'
);
if (existingStylesheet) {
existingStylesheet.remove();
}
// Add new theme stylesheet using bundled CSS
const style = document.createElement('style');
style.setAttribute('data-highlight-theme', 'true');
if (colorScheme === 'dark') {
style.textContent = atomOneDarkTheme as string;
} else {
style.textContent = atomOneLightTheme as string;
}
document.head.appendChild(style);
return () => {
// Cleanup on unmount
const stylesheet = document.querySelector('style[data-highlight-theme]');
if (stylesheet) {
stylesheet.remove();
}
};
}, [colorScheme]);
};

View 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
);
});
});
});
});

View File

@@ -24,7 +24,7 @@ export const useLastOpenedFile = (): UseLastOpenedFileResult => {
const saveLastOpenedFile = useCallback( const saveLastOpenedFile = useCallback(
async (filePath: string): Promise<void> => { async (filePath: string): Promise<void> => {
if (!currentWorkspace) return; if (!currentWorkspace || !filePath.trim()) return;
try { try {
await updateLastOpenedFile(currentWorkspace.name, filePath); await updateLastOpenedFile(currentWorkspace.name, filePath);

View File

@@ -0,0 +1,501 @@
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, Theme, 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,
theme: Theme.Dark,
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({});
});
});
});

View File

@@ -0,0 +1,627 @@
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, Theme, 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,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
},
{
id: 2,
email: 'editor@example.com',
displayName: 'Editor User',
role: UserRole.Editor,
theme: Theme.Dark,
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,
theme: Theme.Dark,
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,
theme: Theme.Dark,
};
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,
theme: Theme.Dark,
};
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,
theme: Theme.Dark,
};
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,
theme: user.theme,
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,
theme: Theme.Dark,
createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId,
};
mockUpdateUser.mockResolvedValue(updatedUser);
const { result } = renderHook(() => useUserAdmin());
const updateRequest: UpdateUserRequest = {
email: 'newemail@example.com',
role: UserRole.Admin,
theme: Theme.Dark,
};
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,
theme: Theme.Dark,
});
});
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,
theme: Theme.Dark,
});
});
// 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,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1,
})
.mockResolvedValueOnce({
id: 4,
email: 'user2@example.com',
displayName: 'User 2',
role: UserRole.Editor,
theme: Theme.Dark,
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,
theme: Theme.Dark,
},
{
email: 'user2@example.com',
displayName: 'User 2',
password: 'pass2',
role: UserRole.Editor,
theme: Theme.Dark,
},
];
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,
theme: Theme.Dark,
},
{
email: 'fail@example.com',
displayName: 'Fail User',
password: 'pass2',
role: UserRole.Editor,
theme: Theme.Dark,
},
];
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');
});
});
});

Some files were not shown because too many files have changed in this diff Show More