diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c027f7..31ad89b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,4 +57,31 @@ jobs: - name: Run API tests run: npm --prefix apps/api run test env: - DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test \ No newline at end of file + DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test + + - name: Run Web tests + run: npm --prefix apps/web run test + env: + NEXT_PUBLIC_API_URL: http://localhost:8000/api + docker-build: + runs-on: ubuntu-latest + needs: check + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (web) + uses: docker/build-push-action@v5 + with: + context: . + file: apps/web/Dockerfile + push: false + build-args: | + NEXT_PUBLIC_API_URL=http://localhost:8000/api + tags: fintrack-web:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/apps/api/src/docs/definitions/admin.yml b/apps/api/src/docs/definitions/admin.yml new file mode 100644 index 0000000..5855203 --- /dev/null +++ b/apps/api/src/docs/definitions/admin.yml @@ -0,0 +1,85 @@ +paths: + /admin/error-logs/report: + post: + tags: [Admin] + summary: Report client/runtime error from authenticated user + responses: + '201': + description: Error report created + + /admin/users: + get: + tags: [Admin] + summary: List users (admin only) + responses: + '200': + description: Users list + + /admin/users/{userId}/role: + patch: + tags: [Admin] + summary: Update user role (admin only) + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: User role updated + + /admin/sessions/revoke-user/{userId}: + post: + tags: [Admin] + summary: Revoke active sessions for specific user (admin only) + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Revocation result + + /admin/sessions/revoke-all: + post: + tags: [Admin] + summary: Revoke active sessions for all users (admin only) + responses: + '200': + description: Revocation result + + /admin/stats: + get: + tags: [Admin] + summary: Get admin dashboard stats + responses: + '200': + description: Stats payload + + /admin/error-logs: + get: + tags: [Admin] + summary: List error logs (admin only) + responses: + '200': + description: Error logs list + + /admin/error-logs/{errorLogId}/resolve: + patch: + tags: [Admin] + summary: Mark error log resolved/unresolved (admin only) + parameters: + - in: path + name: errorLogId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Updated error log status diff --git a/apps/api/src/docs/definitions/ai.yml b/apps/api/src/docs/definitions/ai.yml index 50ca88e..7ea73a7 100644 --- a/apps/api/src/docs/definitions/ai.yml +++ b/apps/api/src/docs/definitions/ai.yml @@ -1,108 +1,105 @@ -# ================================== -# COMPONENTS — AI -# ================================== components: schemas: - # --- Schemas for Request Bodies --- AIRequestInput: type: object - description: Input data for AI-powered transaction analysis or insight generation. properties: model: type: string - description: Optional model identifier to use for inference. Defaults to `llama-3.1-8b-instant`. - example: "llama-3.1-8b-instant" prompt: type: string - description: Instruction or question for the AI model. - example: "Analyze the following transaction history and summarize spending habits." data: type: object - description: Additional structured data (such as transactions) to provide context for the AI model. - example: - transactions: - - title: "Groceries" - amount: 54.20 - type: "EXPENSE" - - title: "Salary" - amount: 1200.00 - type: "INCOME" - required: - - prompt - - data + required: [prompt, data] - # --- Schemas for Responses --- AIResponse: type: object - description: AI model response containing analytical text or generated insight. properties: model: type: string - description: The model used to generate the response. - example: "llama-3.1-8b-instant" result: type: string - description: The AI-generated text result. - example: "You spent 40% of your income on food and transportation last month." - # --- Schemas for Errors --- - Error: + AIHistoryItem: type: object - description: Standard error response. properties: - error: + id: type: string - description: Error message. - example: - error: "Something went wrong" + prompt: + type: string + result: + type: string + created_at: + type: string + format: date-time + + AIAccess: + type: object + properties: + role: + type: string + enum: [USER, ADMIN] + tier: + type: string + enum: [user, donor, admin] + donationStatus: + type: string + donationExpiresAt: + type: [string, 'null'] + format: date-time + aiAnalysisUsed: + type: integer + aiAnalysisLimit: + type: integer + remainingAttempts: + type: [integer, 'null'] + isUnlimited: + type: boolean -# ================================== -# PATHS — AI -# ================================== paths: /ai: post: - tags: - - AI + tags: [AI] summary: Analyze financial data with AI - description: - Sends structured transaction data and an instruction prompt to the AI model for automated insights or recommendations. - This endpoint requires authentication and may return text-based analysis. requestBody: required: true - description: Input data and analysis request for the AI model. content: application/json: schema: - $ref: "#/components/schemas/AIRequestInput" + $ref: '#/components/schemas/AIRequestInput' responses: - "200": - description: Successful AI-generated response. - content: - application/json: - schema: - $ref: "#/components/schemas/AIResponse" - "400": - description: Invalid input data or prompt. + '200': + description: AI response content: application/json: schema: - $ref: "#/components/schemas/Error" - example: - error: "Invalid input data or prompt" - "401": - description: Unauthorized (missing or invalid token). + $ref: '#/components/schemas/AIResponse' + '403': + description: AI limit reached + '503': + description: AI provider unavailable or key-related error + + /ai/history: + get: + tags: [AI] + summary: Get AI prompts/results history for current user + responses: + '200': + description: History list content: application/json: schema: - $ref: "#/components/schemas/Error" - example: - error: "Unauthorized" - "500": - description: AI service unavailable or all Groq API keys failed. + type: array + items: + $ref: '#/components/schemas/AIHistoryItem' + + /ai/access: + get: + tags: [AI] + summary: Get current AI access tier and limits + responses: + '200': + description: Access status content: application/json: schema: - $ref: "#/components/schemas/Error" - example: - error: "AI service unavailable" + $ref: '#/components/schemas/AIAccess' diff --git a/apps/api/src/docs/definitions/auth.yml b/apps/api/src/docs/definitions/auth.yml index 8028fb9..3976b30 100644 --- a/apps/api/src/docs/definitions/auth.yml +++ b/apps/api/src/docs/definitions/auth.yml @@ -1,188 +1,131 @@ -# ================================== -# COMPONENTS — AUTH -# ================================== components: schemas: - # --- Schemas for Request Bodies --- - LoginInput: + AuthLoginInput: type: object - description: User credentials for login via email and password. properties: email: type: string - description: Email address of the user. format: email password: type: string - description: User's password (minimum 8 characters). format: password - writeOnly: true - minLength: 8 - required: - - email - - password - example: - email: "ivan@gmail.com" - password: "password1234" + required: [email, password] - RefreshTokenInput: + AuthGoogleExchangeInput: type: object - description: Request body containing a valid refresh token. properties: - token: + idToken: type: string - description: Refresh token used to obtain a new access token. - required: - - token - example: - token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + required: [idToken] - LogoutInput: + AuthAuthenticatedResponse: type: object - description: Request body containing a refresh token to invalidate. properties: - token: - type: string - description: Refresh token to be revoked. - required: - - token - example: - token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - - # --- Schemas for Responses --- - Tokens: - type: object - description: Pair of access and refresh tokens returned after login. - properties: - accessToken: - type: string - description: Access token (valid for 30 minutes). - refreshToken: - type: string - description: Refresh token (valid for 7 days). - example: - accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - - AccessToken: - type: object - description: A new access token obtained via refresh token. - properties: - accessToken: - type: string - description: A new access token (valid for 30 minutes). - example: - accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + authenticated: + type: boolean + example: true - # --- Schemas for Errors --- - Error: + AuthError: type: object - description: Standard error response. properties: error: type: string - description: Error message. - example: - error: "Something went wrong" -# ================================== -# PATHS — AUTH -# ================================== paths: /auth/login: post: - tags: - - Auth - summary: Log in using email and password - description: Authenticates a user and returns access and refresh tokens. - This endpoint is **public** and does not require authentication. - security: [] # Public endpoint + tags: [Auth] + summary: Log in with email and password + security: [] requestBody: required: true - description: Email and password credentials for login. content: application/json: schema: - $ref: "#/components/schemas/LoginInput" + $ref: '#/components/schemas/AuthLoginInput' responses: - "200": - description: Login successful — tokens returned. - content: - application/json: - schema: - $ref: "#/components/schemas/Tokens" - "400": - description: Invalid credentials or validation error. + '200': + description: Authenticated and cookies set content: application/json: schema: - $ref: "#/components/schemas/Error" - example: - error: "Invalid credentials" - "404": - description: User with specified email not found. + $ref: '#/components/schemas/AuthAuthenticatedResponse' + '401': + description: Invalid credentials content: application/json: schema: - $ref: "#/components/schemas/Error" - example: - error: "Not found" + $ref: '#/components/schemas/AuthError' - /auth/token: + /auth/google/exchange: post: - tags: - - Auth - summary: Refresh access token - description: Accepts a valid refresh token and returns a new access token. - This endpoint is **public** and does not require authentication. + tags: [Auth] + summary: Exchange Google ID token for backend session security: [] requestBody: required: true - description: Refresh token payload. content: application/json: schema: - $ref: "#/components/schemas/RefreshTokenInput" + $ref: '#/components/schemas/AuthGoogleExchangeInput' responses: - "200": - description: Access token refreshed successfully. + '200': + description: Authenticated and cookies set content: application/json: schema: - $ref: "#/components/schemas/AccessToken" - "401": - description: Invalid or expired refresh token. + $ref: '#/components/schemas/AuthAuthenticatedResponse' + '400': + description: Invalid payload + + /auth/token: + post: + tags: [Auth] + summary: Rotate refresh token and issue a fresh session + security: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + responses: + '200': + description: Session rotated content: application/json: schema: - $ref: "#/components/schemas/Error" - example: - error: "Invalid or expired refresh token" + $ref: '#/components/schemas/AuthAuthenticatedResponse' + '401': + description: Invalid, expired or reused refresh token /auth/logout: delete: - tags: - - Auth - summary: Log out user and invalidate refresh token - description: - Removes the specified refresh token from the database, logging out the user. - This endpoint is **public** and does not require authentication. + tags: [Auth] + summary: Logout current refresh session security: [] requestBody: - required: true - description: The refresh token to revoke. + required: false content: application/json: schema: - $ref: "#/components/schemas/LogoutInput" + type: object + properties: + token: + type: string responses: - "204": - description: Logout successful (no content). - "401": - description: Invalid or missing refresh token. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - example: - error: "Invalid or missing refresh token" + '204': + description: Logged out + + /auth/logout-all: + post: + tags: [Auth] + summary: Logout from all sessions of current user + responses: + '204': + description: All sessions revoked + '401': + description: Unauthorized diff --git a/apps/api/src/docs/definitions/donations.yml b/apps/api/src/docs/definitions/donations.yml new file mode 100644 index 0000000..c565fd2 --- /dev/null +++ b/apps/api/src/docs/definitions/donations.yml @@ -0,0 +1,80 @@ +components: + schemas: + DonationCheckoutSession: + type: object + properties: + checkoutUrl: + type: string + format: uri + checkoutSessionId: + type: string + + DonationLeaderboardItem: + type: object + properties: + userId: + type: string + name: + type: string + photoUrl: + type: [string, 'null'] + totalAmountMinor: + type: integer + currency: + type: string + +paths: + /donations/checkout-session: + post: + tags: [Donation] + summary: Create Stripe checkout session for donation + parameters: + - in: header + name: x-idempotency-key + required: false + schema: + type: string + responses: + '201': + description: Checkout session created + content: + application/json: + schema: + $ref: '#/components/schemas/DonationCheckoutSession' + + /donations/leaderboard: + get: + tags: [Donation] + summary: Get donation leaderboard + responses: + '200': + description: Leaderboard items + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/DonationLeaderboardItem' + + /donations/webhook: + post: + tags: [Donation] + summary: Stripe webhook endpoint + security: [] + responses: + '200': + description: Webhook processed + content: + application/json: + schema: + type: object + properties: + received: + type: boolean + duplicate: + type: boolean + '400': + description: Invalid signature or payload diff --git a/apps/api/src/docs/definitions/transactions.yml b/apps/api/src/docs/definitions/transactions.yml index c0660ed..a9696a9 100644 --- a/apps/api/src/docs/definitions/transactions.yml +++ b/apps/api/src/docs/definitions/transactions.yml @@ -283,8 +283,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - example: - message: "Unauthorized" /transactions/{id}: get: @@ -320,24 +318,20 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - example: - message: "Transaction not found" patch: tags: - Transaction summary: Update a transaction - description: Updates title, type, amount, or location of an existing transaction. + description: Updates title, type, amount, currency or location. parameters: - in: path name: id required: true schema: type: string - description: UUID of the transaction to update. requestBody: required: true - description: Transaction data to update. content: application/json: schema: @@ -351,57 +345,101 @@ paths: $ref: "#/components/schemas/Transaction" "400": description: Invalid request body. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - example: - message: "Invalid request body" "401": description: Unauthorized. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - example: - message: "Unauthorized" "404": description: Transaction not found. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - example: - message: "Transaction not found" delete: tags: - Transaction summary: Delete a transaction - description: Permanently deletes a transaction by its ID. parameters: - in: path name: id required: true schema: type: string - description: UUID of the transaction to delete. + + /transactions/monobank/accounts: + post: + tags: + - Transaction + summary: Fetch Monobank accounts by token + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + required: [token] responses: - "204": - description: Transaction deleted successfully. + "200": + description: Monobank accounts loaded "401": - description: Unauthorized. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - example: - message: "Unauthorized" - "404": - description: Transaction not found. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - example: - message: "Transaction not found" + description: Unauthorized or invalid Monobank token + "429": + description: Cooldown/rate limit reached + + /transactions/monobank/fetch: + post: + tags: + - Transaction + summary: Fetch Monobank statement preview + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + accountId: + type: string + accountCurrencyCode: + type: integer + from: + type: integer + to: + type: integer + required: [token] + responses: + "200": + description: Statement preview returned + "400": + description: Invalid time range or payload + "429": + description: Cooldown/rate limit reached + + /transactions/monobank/import: + post: + tags: + - Transaction + summary: Import Monobank transactions into storage + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + transactions: + type: array + items: + type: object + responses: + "200": + description: Import stats + + /transactions/monobank: + delete: + tags: + - Transaction + summary: Delete all imported Monobank transactions for current user + responses: + "200": + description: Delete stats diff --git a/apps/api/src/docs/definitions/user-api-keys.yml b/apps/api/src/docs/definitions/user-api-keys.yml new file mode 100644 index 0000000..e56f418 --- /dev/null +++ b/apps/api/src/docs/definitions/user-api-keys.yml @@ -0,0 +1,101 @@ +components: + schemas: + UserApiKeyInput: + type: object + properties: + provider: + type: string + enum: [GROQ, GEMINI] + apiKey: + type: string + required: [provider, apiKey] + + UserApiKey: + type: object + properties: + id: + type: string + provider: + type: string + enum: [GROQ, GEMINI] + isActive: + type: boolean + maskedKey: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + +paths: + /user-api-keys: + get: + tags: [User API Keys] + summary: List current user API keys + responses: + '200': + description: Keys list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserApiKey' + + put: + tags: [User API Keys] + summary: Create or update a provider key + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserApiKeyInput' + responses: + '200': + description: Upserted key + content: + application/json: + schema: + $ref: '#/components/schemas/UserApiKey' + + /user-api-keys/{provider}: + delete: + tags: [User API Keys] + summary: Delete provider key + parameters: + - in: path + name: provider + required: true + schema: + type: string + enum: [GROQ, GEMINI] + responses: + '204': + description: Deleted + + /user-api-keys/{provider}/toggle: + patch: + tags: [User API Keys] + summary: Toggle provider key active state + parameters: + - in: path + name: provider + required: true + schema: + type: string + enum: [GROQ, GEMINI] + responses: + '200': + description: Updated state + content: + application/json: + schema: + type: object + properties: + isActive: + type: boolean + provider: + type: string diff --git a/apps/api/src/docs/swagger.ts b/apps/api/src/docs/swagger.ts index 3ca01d7..dac01d8 100644 --- a/apps/api/src/docs/swagger.ts +++ b/apps/api/src/docs/swagger.ts @@ -48,6 +48,18 @@ const options: swaggerJsdoc.Options = { name: "AI", description: "Integration with artificial intelligence for analysis", }, + { + name: "User API Keys", + description: "Manage per-user AI provider API keys", + }, + { + name: "Donation", + description: "Donation checkout, webhook and leaderboard", + }, + { + name: "Admin", + description: "Administration and runtime error management", + }, ], components: { securitySchemes: { diff --git a/apps/api/test/integration/auth.access-token.integration.test.ts b/apps/api/test/integration/auth.access-token.integration.test.ts new file mode 100644 index 0000000..ff8ffed --- /dev/null +++ b/apps/api/test/integration/auth.access-token.integration.test.ts @@ -0,0 +1,111 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import jwt from "jsonwebtoken"; + +import type { app as AppType } from "../../src/app.js"; +import type * as AuthServiceTypes from "../../src/modules/auth/service.js"; + +jest.unstable_mockModule("../../src/modules/auth/service.js", () => ({ + findSessionById: jest.fn(), + findSessionByTokenHash: jest.fn(), + revokeSessionFamily: jest.fn(), + revokeSession: jest.fn(), + rotateSession: jest.fn(), + createSession: jest.fn(), + logoutByTokenHash: jest.fn(), +})); + +jest.unstable_mockModule("../../src/modules/user/service.js", () => ({ + getUser: jest.fn(), +})); + +let app: typeof AppType; +let authService: typeof AuthServiceTypes; +let generateAccessToken: (payload: { + id: string; + email: string | null; + telegram_id: string | null; + role: "USER" | "ADMIN"; + isVerified: boolean; + sessionId: string; +}) => string; + +beforeAll(async () => { + ({ app } = await import("../../src/app.js")); + authService = await import("../../src/modules/auth/service.js"); + ({ generateAccessToken } = + await import("../../src/modules/auth/controller.js")); +}); + +describe("Access token lifecycle integration", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("returns 401 for token with invalid signature", async () => { + const payload = { + id: "f4f9d2eb-52d9-4a89-a2e1-7f3f6f1f0f11", + email: "user@test.dev", + telegram_id: null, + role: "USER" as const, + isVerified: true, + sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", + }; + + const invalidToken = jwt.sign(payload, "wrong_secret", { expiresIn: 60 }); + + const response = await request(app) + .get("/api/users/me") + .set("Cookie", [`fintrack_access_token=${invalidToken}`]); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("Invalid access token"); + }); + + it("returns 401 for expired access token", async () => { + const expiredToken = jwt.sign( + { + id: "f4f9d2eb-52d9-4a89-a2e1-7f3f6f1f0f11", + email: "user@test.dev", + telegram_id: null, + role: "USER", + isVerified: true, + sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", + }, + process.env.ACCESS_TOKEN_SECRET || "test_access_secret", + { expiresIn: -5 }, + ); + + const response = await request(app) + .get("/api/users/me") + .set("Cookie", [`fintrack_access_token=${expiredToken}`]); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("Access token expired"); + }); + + it("returns 401 when token is valid but session is revoked", async () => { + const validToken = generateAccessToken({ + id: "f4f9d2eb-52d9-4a89-a2e1-7f3f6f1f0f11", + email: "user@test.dev", + telegram_id: null, + role: "USER", + isVerified: true, + sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", + }); + + jest.mocked(authService.findSessionById).mockResolvedValue({ + sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", + userId: "f4f9d2eb-52d9-4a89-a2e1-7f3f6f1f0f11", + revokedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }); + + const response = await request(app) + .get("/api/users/me") + .set("Cookie", [`fintrack_access_token=${validToken}`]); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("Session revoked or expired"); + }); +}); diff --git a/apps/api/test/integration/auth.refresh.integration.test.ts b/apps/api/test/integration/auth.refresh.integration.test.ts new file mode 100644 index 0000000..2044c8f --- /dev/null +++ b/apps/api/test/integration/auth.refresh.integration.test.ts @@ -0,0 +1,81 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; + +import type { app as AppType } from "../../src/app.js"; +import type * as AuthServiceTypes from "../../src/modules/auth/service.js"; + +jest.unstable_mockModule("../../src/modules/auth/service.js", () => ({ + findSessionById: jest.fn(), + findSessionByTokenHash: jest.fn(), + revokeSessionFamily: jest.fn(), + revokeSession: jest.fn(), + rotateSession: jest.fn(), + createSession: jest.fn(), + logoutByTokenHash: jest.fn(), +})); + +let app: typeof AppType; +let authService: typeof AuthServiceTypes; + +beforeAll(async () => { + ({ app } = await import("../../src/app.js")); + authService = await import("../../src/modules/auth/service.js"); +}); + +describe("Auth Refresh Flow", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("returns 401 and revokes expired refresh session", async () => { + jest.mocked(authService.findSessionByTokenHash).mockResolvedValue({ + sessionId: "e5af2f58-5f09-4c64-8e13-f5b9323248d0", + tokenHash: "hash", + familyId: "family-1", + parentSessionId: null, + expiresAt: new Date(Date.now() - 1000), + revokedAt: null, + userAgent: "ua", + ip: "127.0.0.1", + lastUsedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + userId: "1772f0ba-450d-4b81-bb7c-df6f0a7483c3", + }); + + const response = await request(app) + .post("/api/auth/token") + .send({ token: "expired_token" }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("Refresh token expired"); + expect(authService.revokeSession).toHaveBeenCalledWith( + "e5af2f58-5f09-4c64-8e13-f5b9323248d0", + ); + }); + + it("revokes family when refresh token is already revoked", async () => { + jest.mocked(authService.findSessionByTokenHash).mockResolvedValue({ + sessionId: "e5af2f58-5f09-4c64-8e13-f5b9323248d0", + tokenHash: "hash", + familyId: "family-1", + parentSessionId: null, + expiresAt: new Date(Date.now() + 60_000), + revokedAt: new Date(), + userAgent: "ua", + ip: "127.0.0.1", + lastUsedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + userId: "1772f0ba-450d-4b81-bb7c-df6f0a7483c3", + }); + + const response = await request(app) + .post("/api/auth/token") + .send({ token: "valid_refresh_token" }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("Refresh token reuse detected"); + expect(authService.revokeSessionFamily).toHaveBeenCalledWith("family-1"); + }); +}); diff --git a/apps/api/test/integration/transaction.idor.integration.test.ts b/apps/api/test/integration/transaction.idor.integration.test.ts new file mode 100644 index 0000000..442ca81 --- /dev/null +++ b/apps/api/test/integration/transaction.idor.integration.test.ts @@ -0,0 +1,111 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; + +import type { app as AppType } from "../../src/app.js"; +import type * as AuthServiceTypes from "../../src/modules/auth/service.js"; +import type * as TransactionServiceTypes from "../../src/modules/transaction/service.js"; + +jest.unstable_mockModule("../../src/modules/auth/service.js", () => ({ + findSessionById: jest.fn(), + findSessionByTokenHash: jest.fn(), + revokeSessionFamily: jest.fn(), + revokeSession: jest.fn(), + rotateSession: jest.fn(), + createSession: jest.fn(), + logoutByTokenHash: jest.fn(), +})); + +jest.unstable_mockModule("../../src/modules/transaction/service.js", () => ({ + getAllTransactions: jest.fn(), + getTransactionsPerPage: jest.fn(), + getTransaction: jest.fn(), + createTransaction: jest.fn(), + updateTransaction: jest.fn(), + deleteTransaction: jest.fn(), + importMonobankTransactions: jest.fn(), + deleteAllMonobankTransactions: jest.fn(), +})); + +let app: typeof AppType; +let authService: typeof AuthServiceTypes; +let transactionService: typeof TransactionServiceTypes; +let generateAccessToken: (payload: { + id: string; + email: string | null; + telegram_id: string | null; + role: "USER" | "ADMIN"; + isVerified: boolean; + sessionId: string; +}) => string; + +beforeAll(async () => { + ({ app } = await import("../../src/app.js")); + authService = await import("../../src/modules/auth/service.js"); + transactionService = await import("../../src/modules/transaction/service.js"); + ({ generateAccessToken } = + await import("../../src/modules/auth/controller.js")); +}); + +describe("Transaction IDOR protection integration", () => { + const userId = "f4f9d2eb-52d9-4a89-a2e1-7f3f6f1f0f11"; + const transactionId = "0f98ef84-6e7d-4b88-9cb0-0e3b2d123456"; + + beforeEach(() => { + jest.resetAllMocks(); + + jest.mocked(authService.findSessionById).mockResolvedValue({ + sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", + userId, + revokedAt: null, + expiresAt: new Date(Date.now() + 60_000), + }); + + // Simulate transaction owned by another user -> service cannot find it for current user + jest.mocked(transactionService.getTransaction).mockResolvedValue(null); + }); + + function makeUserToken() { + return generateAccessToken({ + id: userId, + email: "user@test.dev", + telegram_id: null, + role: "USER", + isVerified: true, + sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", + }); + } + + it("returns 404 on GET /transactions/:id for foreign transaction", async () => { + const response = await request(app) + .get(`/api/transactions/${transactionId}`) + .set("Cookie", [`fintrack_access_token=${makeUserToken()}`]); + + expect(response.status).toBe(404); + expect(response.body.error).toBe("Not found"); + expect(transactionService.getTransaction).toHaveBeenCalledWith( + transactionId, + userId, + ); + }); + + it("returns 404 on PATCH /transactions/:id and does not update", async () => { + const response = await request(app) + .patch(`/api/transactions/${transactionId}`) + .set("Cookie", [`fintrack_access_token=${makeUserToken()}`]) + .send({ title: "Changed" }); + + expect(response.status).toBe(404); + expect(response.body.error).toBe("Not found"); + expect(transactionService.updateTransaction).not.toHaveBeenCalled(); + }); + + it("returns 404 on DELETE /transactions/:id and does not delete", async () => { + const response = await request(app) + .delete(`/api/transactions/${transactionId}`) + .set("Cookie", [`fintrack_access_token=${makeUserToken()}`]); + + expect(response.status).toBe(404); + expect(response.body.error).toBe("Not found"); + expect(transactionService.deleteTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/test/stress/auth.rotate.stress.test.ts b/apps/api/test/stress/auth.rotate.stress.test.ts new file mode 100644 index 0000000..3a5f85a --- /dev/null +++ b/apps/api/test/stress/auth.rotate.stress.test.ts @@ -0,0 +1,82 @@ +import { jest } from "@jest/globals"; + +describe("Auth rotateSession stress", () => { + beforeEach(() => { + jest.resetModules(); + }); + + it("allows only one successful rotation under concurrent reuse attempts", async () => { + const revoked = new Set(); + + jest.unstable_mockModule("../../src/prisma/client.js", () => ({ + prisma: { + $transaction: async ( + callback: (tx: { + session: { + updateMany: (args: { + where: { sessionId: string }; + data: unknown; + }) => Promise<{ count: number }>; + create: (args: { + data: { tokenHash: string }; + }) => Promise<{ sessionId: string }>; + }; + }) => Promise, + ) => { + const tx = { + session: { + updateMany: async (args: { where: { sessionId: string } }) => { + const sessionId = args.where.sessionId; + if (revoked.has(sessionId)) { + return { count: 0 }; + } + + revoked.add(sessionId); + // Add jitter to increase race pressure after acquiring the lock + await new Promise((resolve) => + setTimeout(resolve, Math.floor(Math.random() * 5)), + ); + return { count: 1 }; + }, + create: async (args: { data: { tokenHash: string } }) => ({ + sessionId: `new-${args.data.tokenHash}`, + }), + }, + }; + + return callback(tx); + }, + }, + })); + + const { rotateSession } = await import("../../src/modules/auth/service.js"); + + const attempts = 40; + const results = await Promise.allSettled( + Array.from({ length: attempts }, (_, index) => + rotateSession("root-session", { + tokenHash: `token-${index}`, + familyId: "family-1", + expiresAt: new Date(Date.now() + 60_000), + userId: "user-1", + }), + ), + ); + + const successCount = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected") as Array<{ + status: "rejected"; + reason: { message?: string; statusCode?: number }; + }>; + + expect(successCount).toBe(1); + expect(failed.length).toBe(attempts - 1); + expect( + failed.every( + (r) => + r.reason?.message === "Refresh token reuse detected" && + r.reason?.statusCode === 401, + ), + ).toBe(true); + }); +}); diff --git a/apps/api/test/stress/donation.webhook.stress.test.ts b/apps/api/test/stress/donation.webhook.stress.test.ts new file mode 100644 index 0000000..44cb820 --- /dev/null +++ b/apps/api/test/stress/donation.webhook.stress.test.ts @@ -0,0 +1,98 @@ +import { jest } from "@jest/globals"; + +class MockKnownRequestError extends Error { + code: string; + + constructor(code: string) { + super(code); + this.code = code; + } +} + +describe("Stripe webhook idempotency stress", () => { + beforeEach(() => { + jest.resetModules(); + }); + + it("handles duplicate webhook bursts without failing", async () => { + const seen = new Set(); + + jest.unstable_mockModule("@prisma/client", () => ({ + Prisma: { + PrismaClientKnownRequestError: MockKnownRequestError, + }, + })); + + jest.unstable_mockModule("../../src/config/env.js", () => ({ + ENV: { + STRIPE_SECRET_KEY: "sk_test_123", + STRIPE_WEBHOOK_SECRET: "whsec_123", + STRIPE_DONATION_PRICE_ID: "", + STRIPE_DONATION_SUCCESS_URL: "https://app.fintrack.dev/success", + STRIPE_DONATION_CANCEL_URL: "https://app.fintrack.dev/cancel", + STRIPE_DONATION_CURRENCY: "usd", + STRIPE_DONATION_AMOUNT: 500, + STRIPE_DONATION_DURATION_DAYS: 30, + }, + })); + + jest.unstable_mockModule("../../src/utils/authSecurity.js", () => ({ + logSecurityEvent: jest.fn(), + })); + + jest.unstable_mockModule("../../src/prisma/client.js", () => ({ + prisma: { + stripeWebhookEvent: { + create: async (args: { data: { stripeEventId: string } }) => { + const id = args.data.stripeEventId; + if (seen.has(id)) { + throw new MockKnownRequestError("P2002"); + } + + seen.add(id); + // Add jitter to create interleaving after the first write + await new Promise((resolve) => + setTimeout(resolve, Math.floor(Math.random() * 5)), + ); + return { id }; + }, + }, + }, + })); + + jest.unstable_mockModule("stripe", () => ({ + default: class Stripe { + webhooks = { + constructEvent: () => ({ + id: "evt_same_id", + type: "product.created", + data: { + object: {}, + }, + }), + }; + + constructor() { + // no-op + } + }, + })); + + const { processStripeWebhook } = + await import("../../src/modules/donation/service.js"); + + const runs = 30; + const results = await Promise.all( + Array.from({ length: runs }, () => + processStripeWebhook(Buffer.from("{}"), "sig_123"), + ), + ); + + const duplicateTrue = results.filter((r) => r.duplicate === true).length; + const duplicateFalse = results.filter((r) => r.duplicate === false).length; + + expect(duplicateFalse).toBe(1); + expect(duplicateTrue).toBe(runs - 1); + expect(results.every((r) => r.received === true)).toBe(true); + }); +}); diff --git a/apps/api/test/unit/middleware/security.middleware.test.ts b/apps/api/test/unit/middleware/security.middleware.test.ts new file mode 100644 index 0000000..4e4a632 --- /dev/null +++ b/apps/api/test/unit/middleware/security.middleware.test.ts @@ -0,0 +1,127 @@ +import { jest } from "@jest/globals"; + +describe("Security middleware", () => { + beforeEach(() => { + jest.resetModules(); + }); + + it("blocks unsafe request with invalid origin in production CSRF", async () => { + jest.unstable_mockModule("../../../src/config/env.js", () => ({ + ENV: { + NODE_ENV: "production", + }, + })); + + const { csrfProtection } = await import("../../../src/middleware/csrf.js"); + const next = jest.fn(); + + const middleware = csrfProtection(["https://app.fintrack.dev"]); + + middleware( + { + method: "POST", + path: "/transactions", + headers: { origin: "https://evil.site" }, + } as never, + {} as never, + next, + ); + + expect(next).toHaveBeenCalledTimes(1); + const firstArg = next.mock.calls[0]?.[0] as { + message?: string; + statusCode?: number; + }; + expect(firstArg?.message).toBe("CSRF validation failed"); + expect(firstArg?.statusCode).toBe(403); + }); + + it("allows Stripe webhook path in production CSRF middleware", async () => { + jest.unstable_mockModule("../../../src/config/env.js", () => ({ + ENV: { + NODE_ENV: "production", + }, + })); + + const { csrfProtection } = await import("../../../src/middleware/csrf.js"); + const next = jest.fn(); + + const middleware = csrfProtection(["https://app.fintrack.dev"]); + + middleware( + { + method: "POST", + path: "/donations/webhook", + headers: { origin: "https://evil.site" }, + } as never, + {} as never, + next, + ); + + expect(next).toHaveBeenCalledWith(); + }); + + it("falls back to token role when DB role lookup fails", async () => { + const findUnique = jest.fn().mockRejectedValue(new Error("db down")); + + jest.unstable_mockModule("../../../src/config/env.js", () => ({ + ENV: { + NODE_ENV: "production", + }, + })); + + jest.unstable_mockModule("../../../src/prisma/client.js", () => ({ + prisma: { + user: { + findUnique, + }, + }, + })); + + const { requireRole } = await import("../../../src/middleware/authz.js"); + const next = jest.fn(); + + const middleware = requireRole(["ADMIN"]); + + await middleware( + { + user: { + id: "user-1", + role: "ADMIN", + sessionId: "session-1", + email: null, + telegram_id: null, + isVerified: true, + }, + } as never, + {} as never, + next, + ); + + expect(findUnique).toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + it("maps AppError into custom status and payload", async () => { + const { AppError, errorHandler } = + await import("../../../src/middleware/errorHandler.js"); + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + errorHandler( + new AppError("Forbidden", 403, { reason: "role" }), + {} as never, + res as never, + jest.fn(), + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Forbidden", + details: { reason: "role" }, + }); + }); +}); diff --git a/apps/api/test/unit/modules/ai.service.test.ts b/apps/api/test/unit/modules/ai.service.test.ts new file mode 100644 index 0000000..37f8fa8 --- /dev/null +++ b/apps/api/test/unit/modules/ai.service.test.ts @@ -0,0 +1,143 @@ +import { jest } from "@jest/globals"; + +const findUnique = jest.fn(); +const update = jest.fn(); +const findFirst = jest.fn(); +const findMany = jest.fn(); +const createMany = jest.fn(); + +const mockChatCompletionsCreate = jest.fn(); +const decryptApiKey = jest.fn((value: string) => value); + +describe("AI service", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + + jest.unstable_mockModule("../../../src/config/env.js", () => ({ + ENV: { + NODE_ENV: "test", + GROQAPITOKENS: ["default-token"], + }, + })); + + jest.unstable_mockModule("../../../src/prisma/client.js", () => ({ + prisma: { + user: { + findUnique, + update, + }, + userApiKey: { + findFirst, + }, + message: { + findMany, + createMany, + }, + }, + })); + + jest.unstable_mockModule("../../../src/utils/crypto.js", () => ({ + decryptApiKey, + })); + + jest.unstable_mockModule("openai", () => ({ + default: class OpenAI { + chat = { + completions: { + create: mockChatCompletionsCreate, + }, + }; + + constructor() { + // no-op + } + }, + })); + }); + + it("throws 403 when AI attempts are exhausted for regular user", async () => { + findUnique.mockResolvedValue({ + role: "USER", + donationStatus: "NONE", + donationExpiresAt: null, + aiAnalysisUsed: 10, + aiAnalysisLimit: 10, + }); + + const { ensureAiAccessOrThrow } = + await import("../../../src/modules/ai/service.js"); + + await expect(ensureAiAccessOrThrow("user-1")).rejects.toMatchObject({ + statusCode: 403, + message: + "AI analysis limit reached. Please make a donation to unlock unlimited access.", + }); + }); + + it("tries fallback Gemini model candidates and succeeds", async () => { + findFirst.mockResolvedValue({ + provider: "GEMINI", + apiKey: "encrypted-key", + isActive: true, + updatedAt: new Date(), + }); + findMany.mockResolvedValue([]); + + mockChatCompletionsCreate + .mockRejectedValueOnce( + Object.assign(new Error("model missing"), { status: 404 }), + ) + .mockResolvedValueOnce({ + model: "gemini-2.5-flash", + choices: [{ message: { content: "AI result" } }], + }); + + const { getAiResponse } = + await import("../../../src/modules/ai/service.js"); + + const result = await getAiResponse( + "user-1", + "Prompt", + { x: 1 }, + "gemini-custom", + ); + + expect(result).toEqual({ model: "gemini-2.5-flash", result: "AI result" }); + expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(2); + expect(createMany).toHaveBeenCalledWith({ + data: [ + { role: "user", content: "Prompt", userId: "user-1" }, + { role: "assistant", content: "AI result", userId: "user-1" }, + ], + }); + }); + + it("maps provider rate-limit error to USER_KEY_LIMIT", async () => { + findFirst.mockResolvedValue({ + provider: "GROQ", + apiKey: "encrypted-key", + isActive: true, + updatedAt: new Date(), + }); + findMany.mockResolvedValue([]); + + mockChatCompletionsCreate.mockRejectedValueOnce( + Object.assign(new Error("rate_limit"), { + status: 429, + code: "rate_limit_exceeded", + }), + ); + + const { getAiResponse } = + await import("../../../src/modules/ai/service.js"); + + await expect( + getAiResponse("user-1", "Prompt", { x: 1 }), + ).rejects.toMatchObject({ + name: "AiServiceError", + code: "USER_KEY_LIMIT", + message: "Your API key has reached its rate limit.", + }); + }); +}); diff --git a/apps/api/test/unit/modules/donation.service.test.ts b/apps/api/test/unit/modules/donation.service.test.ts new file mode 100644 index 0000000..43c3582 --- /dev/null +++ b/apps/api/test/unit/modules/donation.service.test.ts @@ -0,0 +1,171 @@ +import { jest } from "@jest/globals"; + +const mockCustomersCreate = jest.fn(); +const mockCheckoutCreate = jest.fn(); +const mockConstructEvent = jest.fn(); +const logSecurityEvent = jest.fn(); + +const mockPrisma = { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + donationPayment: { + upsert: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn(), + }, + stripeWebhookEvent: { + create: jest.fn(), + }, + $transaction: jest.fn(), +}; + +describe("Donation service", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + + jest.unstable_mockModule("../../../src/config/env.js", () => ({ + ENV: { + STRIPE_SECRET_KEY: "sk_test_123", + STRIPE_WEBHOOK_SECRET: "whsec_123", + STRIPE_DONATION_PRICE_ID: "", + STRIPE_DONATION_SUCCESS_URL: "https://app.fintrack.dev/success", + STRIPE_DONATION_CANCEL_URL: "https://app.fintrack.dev/cancel", + STRIPE_DONATION_CURRENCY: "usd", + STRIPE_DONATION_AMOUNT: 500, + STRIPE_DONATION_DURATION_DAYS: 30, + }, + })); + + jest.unstable_mockModule("../../../src/prisma/client.js", () => ({ + prisma: mockPrisma, + })); + + jest.unstable_mockModule("../../../src/utils/authSecurity.js", () => ({ + logSecurityEvent, + })); + + jest.unstable_mockModule("stripe", () => ({ + default: class Stripe { + customers = { + create: mockCustomersCreate, + }; + + checkout = { + sessions: { + create: mockCheckoutCreate, + }, + }; + + webhooks = { + constructEvent: mockConstructEvent, + }; + + constructor() { + // no-op + } + }, + })); + }); + + it("creates donation checkout session and passes idempotency key", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "user-1", + name: "User", + stripeCustomerId: null, + authMethods: [{ email: "user@test.dev" }], + }); + mockCustomersCreate.mockResolvedValue({ id: "cus_123" }); + mockCheckoutCreate.mockResolvedValue({ + id: "cs_123", + url: "https://checkout.stripe.com/pay/cs_123", + payment_intent: "pi_123", + amount_total: 500, + currency: "usd", + }); + + const { createDonationCheckoutSession } = + await import("../../../src/modules/donation/service.js"); + + const result = await createDonationCheckoutSession("user-1", "idem-1"); + + expect(result).toEqual({ + checkoutUrl: "https://checkout.stripe.com/pay/cs_123", + checkoutSessionId: "cs_123", + }); + + expect(mockCheckoutCreate).toHaveBeenCalledWith( + expect.objectContaining({ + customer: "cus_123", + mode: "payment", + }), + { idempotencyKey: "idem-1" }, + ); + expect(mockPrisma.donationPayment.upsert).toHaveBeenCalled(); + }); + + it("rejects webhook with invalid Stripe signature", async () => { + mockConstructEvent.mockImplementation(() => { + throw new Error("bad signature"); + }); + + const { processStripeWebhook } = + await import("../../../src/modules/donation/service.js"); + + await expect( + processStripeWebhook(Buffer.from("{}"), "invalid-signature"), + ).rejects.toMatchObject({ + statusCode: 400, + message: "Invalid Stripe webhook signature", + }); + }); + + it("processes completed checkout webhook and activates donation", async () => { + mockConstructEvent.mockReturnValue({ + id: "evt_123", + type: "checkout.session.completed", + data: { + object: { + id: "cs_123", + metadata: { userId: "user-1" }, + payment_status: "paid", + payment_intent: "pi_123", + amount_total: 500, + currency: "usd", + }, + }, + }); + + mockPrisma.stripeWebhookEvent.create.mockResolvedValue({ id: "evt_123" }); + mockPrisma.donationPayment.findUnique.mockResolvedValue({ + userId: "user-1", + }); + mockPrisma.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => { + const tx = { + donationPayment: { upsert: jest.fn().mockResolvedValue({}) }, + user: { update: jest.fn().mockResolvedValue({}) }, + }; + return callback(tx as never); + }, + ); + + const { processStripeWebhook } = + await import("../../../src/modules/donation/service.js"); + + const result = await processStripeWebhook(Buffer.from("{}"), "sig_123"); + + expect(result).toEqual({ received: true, duplicate: false }); + expect(mockPrisma.stripeWebhookEvent.create).toHaveBeenCalled(); + expect(mockPrisma.$transaction).toHaveBeenCalled(); + expect(logSecurityEvent).toHaveBeenCalledWith( + "stripe.donation.activated", + expect.objectContaining({ + userId: "user-1", + stripeCheckoutSessionId: "cs_123", + }), + ); + }); +}); diff --git a/apps/api/test/unit/modules/monobank.controller.test.ts b/apps/api/test/unit/modules/monobank.controller.test.ts new file mode 100644 index 0000000..d5afb9d --- /dev/null +++ b/apps/api/test/unit/modules/monobank.controller.test.ts @@ -0,0 +1,197 @@ +import { jest } from "@jest/globals"; + +const importMonobankTransactions = jest.fn(); +const deleteAllMonobankTransactions = jest.fn(); + +function createRes() { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res; +} + +describe("Monobank controller", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it("returns 400 when from >= to", async () => { + const { fetchMonobankTransactions } = + await import("../../../src/modules/transaction/monobank.controller.js"); + + const req = { + user: { id: "user-1" }, + body: { + token: "12345678901234567890", + from: 200, + to: 100, + }, + }; + const res = createRes(); + const next = jest.fn(); + + await fetchMonobankTransactions(req as never, res as never, next); + + const err = next.mock.calls[0]?.[0] as { + statusCode?: number; + message?: string; + }; + expect(err.statusCode).toBe(400); + expect(err.message).toContain("'from' must be less than 'to'"); + }); + + it("enforces cooldown for repeated account requests", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ accounts: [{ id: "acc-1" }] }), + } as Response); + + const { fetchMonobankAccounts } = + await import("../../../src/modules/transaction/monobank.controller.js"); + + const req = { + user: { id: "user-1" }, + body: { + token: "12345678901234567890", + }, + }; + + const res1 = createRes(); + const next1 = jest.fn(); + await fetchMonobankAccounts(req as never, res1 as never, next1); + expect(res1.status).toHaveBeenCalledWith(200); + + const res2 = createRes(); + const next2 = jest.fn(); + await fetchMonobankAccounts(req as never, res2 as never, next2); + + const err = next2.mock.calls[0]?.[0] as { + statusCode?: number; + message?: string; + }; + expect(err.statusCode).toBe(429); + expect(err.message).toContain("Monobank request cooldown"); + }); + + it("maps fetched statement items into preview payload", async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ accounts: [{ id: "acc-1", currencyCode: 980 }] }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + id: "tx-income", + time: 1_716_000_000, + description: "Salary", + amount: 100500, + currencyCode: 980, + }, + { + id: "tx-expense", + time: 1_716_000_100, + description: "Coffee", + amount: -2500, + currencyCode: 840, + }, + ], + } as Response); + + const { fetchMonobankTransactions } = + await import("../../../src/modules/transaction/monobank.controller.js"); + + const req = { + user: { id: "user-mapping" }, + body: { + token: "12345678901234567890", + }, + }; + + const res = createRes(); + const next = jest.fn(); + await fetchMonobankTransactions(req as never, res as never, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0]?.[0] as { + transactions: Array<{ + type: string; + amount: number; + currencyCode: string; + }>; + }; + expect(payload.transactions[0]).toMatchObject({ + type: "INCOME", + amount: 1005, + currencyCode: "UAH", + }); + expect(payload.transactions[1]).toMatchObject({ + type: "EXPENSE", + amount: 25, + currencyCode: "USD", + }); + }); + + it("imports monobank transactions through service", async () => { + jest.unstable_mockModule( + "../../../src/modules/transaction/service.js", + () => ({ + importMonobankTransactions, + deleteAllMonobankTransactions, + }), + ); + + const controller = + await import("../../../src/modules/transaction/monobank.controller.js"); + + importMonobankTransactions.mockResolvedValue({ + imported: 1, + skipped: 0, + total: 1, + }); + + const req = { + user: { id: "user-1" }, + body: { + transactions: [ + { + title: "Salary", + type: "INCOME", + amount: 100, + currencyCode: "USD", + created_at: new Date().toISOString(), + sourceTransactionId: "source-1", + sourceAccountId: "account-1", + }, + ], + }, + }; + + const res = createRes(); + const next = jest.fn(); + + await controller.importMonobankTransactions( + req as never, + res as never, + next, + ); + + expect(next).not.toHaveBeenCalled(); + expect(importMonobankTransactions).toHaveBeenCalledWith( + "user-1", + expect.any(Array), + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + imported: 1, + skipped: 0, + total: 1, + source: "MONOBANK", + }); + }); +}); diff --git a/apps/web/.env.docker.example b/apps/web/.env.docker.example index 9072114..7145f6b 100644 --- a/apps/web/.env.docker.example +++ b/apps/web/.env.docker.example @@ -3,6 +3,7 @@ NODE_ENV=development # Main API URL NEXT_PUBLIC_API_URL=http://localhost:8000/api +NEXT_PUBLIC_SITE_ORIGIN=http://localhost:5173 # NextAuth NEXTAUTH_URL=http://localhost:5173/FinTrack/api/auth diff --git a/apps/web/.env.example b/apps/web/.env.example index 884de7a..86182bd 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -3,6 +3,7 @@ NODE_ENV="development" # Main API URL NEXT_PUBLIC_API_URL="http://localhost:8000/api" +NEXT_PUBLIC_SITE_ORIGIN="http://localhost:5173" # NextAuth NEXTAUTH_URL="http://localhost:5173/FinTrack/api/auth" diff --git a/apps/web/package.json b/apps/web/package.json index 91240b4..5f2827b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,9 @@ "dev": "next dev --port 5173", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", @@ -38,20 +40,25 @@ "@eslint/js": "^9.30.1", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4.1.11", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/leaflet": "^1.9.20", "@types/node": "^25.5.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", + "jsdom": "^29.0.2", "postcss": "^8.5.6", "typescript": "~5.8.3", - "typescript-eslint": "^8.35.1" + "typescript-eslint": "^8.35.1", + "vitest": "^4.1.4" }, "optionalDependencies": { - "lightningcss-linux-x64-gnu": "*", - "lightningcss-linux-x64-musl": "*", "@tailwindcss/oxide-linux-x64-gnu": "*", - "@tailwindcss/oxide-linux-x64-musl": "*" + "@tailwindcss/oxide-linux-x64-musl": "*", + "lightningcss-linux-x64-gnu": "*", + "lightningcss-linux-x64-musl": "*" } } diff --git a/apps/web/public/og/fintrack-preview.png b/apps/web/public/og/fintrack-preview.png new file mode 100644 index 0000000..5b4c5c6 Binary files /dev/null and b/apps/web/public/og/fintrack-preview.png differ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f28d596..740553f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,18 +1,86 @@ import "leaflet/dist/leaflet.css"; -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import "../styles/tailwind.css"; import "../styles/style.css"; import { ClientRoot } from "./client-root"; +import { APP_BASE_PATH } from "@/config/constants"; + +const SITE_NAME = "FinTrack"; +const SITE_TITLE = "FinTrack — Personal Finance Tracker"; +const SITE_DESCRIPTION = + "Track expenses and income, import Monobank transactions, and get AI-powered finance insights in one secure dashboard."; +const SITE_ORIGIN = + process.env.NEXT_PUBLIC_SITE_ORIGIN ?? "http://localhost:5173"; +const OG_IMAGE_PATH = `${APP_BASE_PATH}/og/fintrack-preview.png`; export const metadata: Metadata = { - title: "FinTrack | Finance Tracker", + metadataBase: new URL(SITE_ORIGIN), + applicationName: SITE_NAME, + title: { + default: SITE_TITLE, + template: "%s | FinTrack", + }, + description: SITE_DESCRIPTION, + keywords: [ + "personal finance", + "expense tracker", + "budget planner", + "monobank", + "ai analytics", + "fintrack", + ], + alternates: { + canonical: APP_BASE_PATH, + }, + openGraph: { + type: "website", + url: APP_BASE_PATH, + siteName: SITE_NAME, + title: SITE_TITLE, + description: SITE_DESCRIPTION, + images: [ + { + url: OG_IMAGE_PATH, + width: 1200, + height: 630, + alt: "FinTrack dashboard preview", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: SITE_TITLE, + description: SITE_DESCRIPTION, + images: [OG_IMAGE_PATH], + }, + manifest: `${APP_BASE_PATH}/manifest.webmanifest`, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, icons: { - icon: "./logo.png", - shortcut: "./logo.png", - apple: "./logo.png", + icon: `${APP_BASE_PATH}/logo.png`, + shortcut: `${APP_BASE_PATH}/logo.png`, + apple: `${APP_BASE_PATH}/logo.png`, }, }; +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f8fafc" }, + { media: "(prefers-color-scheme: dark)", color: "#0f172a" }, + ], +}; + export default function RootLayout({ children, }: Readonly<{ diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts new file mode 100644 index 0000000..c6b8022 --- /dev/null +++ b/apps/web/src/app/manifest.ts @@ -0,0 +1,28 @@ +import type { MetadataRoute } from "next"; +import { APP_BASE_PATH } from "@/config/constants"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "FinTrack - Personal Finance Tracker", + short_name: "FinTrack", + description: + "Personal finance tracker with transaction analytics, Monobank import, and AI insights.", + start_url: `${APP_BASE_PATH}/dashboard`, + scope: `${APP_BASE_PATH}/`, + display: "standalone", + background_color: "#0b1220", + theme_color: "#0f172a", + icons: [ + { + src: `${APP_BASE_PATH}/logo.png`, + sizes: "192x192", + type: "image/png", + }, + { + src: `${APP_BASE_PATH}/logo.png`, + sizes: "512x512", + type: "image/png", + }, + ], + }; +} diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts new file mode 100644 index 0000000..978740c --- /dev/null +++ b/apps/web/src/app/robots.ts @@ -0,0 +1,27 @@ +import type { MetadataRoute } from "next"; +import { APP_BASE_PATH } from "@/config/constants"; + +const SITE_ORIGIN = + process.env.NEXT_PUBLIC_SITE_ORIGIN ?? "http://localhost:5173"; +const BASE_URL = `${SITE_ORIGIN}${APP_BASE_PATH}`; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: [APP_BASE_PATH], + disallow: [ + `${APP_BASE_PATH}/api/`, + `${APP_BASE_PATH}/admin`, + `${APP_BASE_PATH}/dashboard`, + `${APP_BASE_PATH}/analytics`, + `${APP_BASE_PATH}/transactions`, + `${APP_BASE_PATH}/monobank`, + `${APP_BASE_PATH}/donation`, + ], + }, + ], + sitemap: `${BASE_URL}/sitemap.xml`, + }; +} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts new file mode 100644 index 0000000..7fe40ff --- /dev/null +++ b/apps/web/src/app/sitemap.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; +import { APP_BASE_PATH } from "@/config/constants"; + +const SITE_ORIGIN = + process.env.NEXT_PUBLIC_SITE_ORIGIN ?? "http://localhost:5173"; +const BASE_URL = `${SITE_ORIGIN}${APP_BASE_PATH}`; + +export default function sitemap(): MetadataRoute.Sitemap { + const now = new Date(); + + return [ + { + url: BASE_URL, + lastModified: now, + changeFrequency: "weekly", + priority: 1, + }, + { + url: `${BASE_URL}/login`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.7, + }, + ]; +} diff --git a/apps/web/src/test/api/api.test.ts b/apps/web/src/test/api/api.test.ts new file mode 100644 index 0000000..76a6342 --- /dev/null +++ b/apps/web/src/test/api/api.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type ErrorHandler = (error: unknown) => Promise; + +const refreshPost = vi.fn(); +const retryRequest = vi.fn(); + +let rejectedHandler: ErrorHandler; + +const mockStoreState = { + isAuthenticated: true, + setAuthenticated: vi.fn(), + logout: vi.fn(), +}; + +const apiInstance = Object.assign(retryRequest, { + defaults: { + baseURL: "https://api.fintrack.dev", + }, + interceptors: { + response: { + use: vi.fn((onFulfilled, onRejected) => { + rejectedHandler = onRejected; + return 1; + }), + }, + }, +}); + +vi.mock("axios", () => ({ + default: { + create: vi.fn(() => apiInstance), + post: refreshPost, + }, +})); + +vi.mock("@/store/useAuthStore", () => ({ + useAuthStore: { + getState: () => mockStoreState, + }, +})); + +describe("api interceptor refresh flow", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + await import("@/api/api"); + }); + + it("refreshes token and retries request on 401 for non-auth endpoint", async () => { + refreshPost.mockResolvedValue({ status: 200 }); + retryRequest.mockResolvedValue({ data: { ok: true } }); + + const originalRequest = { url: "/transactions", _retry: false }; + + const result = await rejectedHandler({ + config: originalRequest, + response: { status: 401 }, + }); + + expect(refreshPost).toHaveBeenCalledWith( + "https://api.fintrack.dev/auth/token", + {}, + { withCredentials: true }, + ); + expect(mockStoreState.setAuthenticated).toHaveBeenCalledWith(true); + expect(retryRequest).toHaveBeenCalledWith(originalRequest); + expect(result).toEqual({ data: { ok: true } }); + }); + + it("does not refresh for auth endpoints", async () => { + const originalRequest = { url: "/auth/login", _retry: false }; + + await expect( + rejectedHandler({ + config: originalRequest, + response: { status: 401 }, + }), + ).rejects.toMatchObject({ response: { status: 401 } }); + + expect(refreshPost).not.toHaveBeenCalled(); + expect(retryRequest).not.toHaveBeenCalled(); + }); + + it("logs out when refresh fails", async () => { + refreshPost.mockRejectedValue(new Error("refresh failed")); + + const originalRequest = { url: "/summary", _retry: false }; + + await expect( + rejectedHandler({ + config: originalRequest, + response: { status: 401 }, + }), + ).rejects.toThrow("refresh failed"); + + expect(mockStoreState.logout).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/test/auth/OAuthBridge.test.tsx b/apps/web/src/test/auth/OAuthBridge.test.tsx new file mode 100644 index 0000000..916b290 --- /dev/null +++ b/apps/web/src/test/auth/OAuthBridge.test.tsx @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import React from "react"; + +const exchangeGoogleSession = vi.fn(); +const invalidateQueries = vi.fn(); +const signOut = vi.fn(); + +let sessionStatus: "authenticated" | "loading" | "unauthenticated" = + "unauthenticated"; +let sessionData: { googleIdToken?: string } | null = null; + +const authStoreState = { + setAuthenticated: vi.fn(), + setBootstrapping: vi.fn(), +}; + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: sessionData, status: sessionStatus }), + signOut, +})); + +vi.mock("@/api/auth", () => ({ + exchangeGoogleSession, +})); + +vi.mock("@/api/queryClient", () => ({ + queryClient: { + invalidateQueries, + }, +})); + +vi.mock("@/store/useAuthStore", () => ({ + useAuthStore: (selector: (state: typeof authStoreState) => unknown) => + selector(authStoreState), +})); + +vi.mock("@/lib/oauthBridge", () => ({ + getProcessedGoogleIdToken: vi.fn(() => null), + setProcessedGoogleIdToken: vi.fn(), + clearProcessedGoogleIdToken: vi.fn(), +})); + +describe("OAuthBridge", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStatus = "unauthenticated"; + sessionData = null; + }); + + it("exchanges google token and sets auth state", async () => { + sessionStatus = "authenticated"; + sessionData = { googleIdToken: "google-token-1" }; + exchangeGoogleSession.mockResolvedValue(undefined); + + const { OAuthBridge } = await import("@/app/_components/auth/OAuthBridge"); + render(); + + await waitFor(() => { + expect(exchangeGoogleSession).toHaveBeenCalledWith("google-token-1"); + }); + + expect(authStoreState.setBootstrapping).toHaveBeenCalledWith(true); + expect(authStoreState.setAuthenticated).toHaveBeenCalledWith(true); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["user", "me"], + }); + expect(authStoreState.setBootstrapping).toHaveBeenLastCalledWith(false); + }); + + it("signs out when exchange fails", async () => { + sessionStatus = "authenticated"; + sessionData = { googleIdToken: "google-token-2" }; + exchangeGoogleSession.mockRejectedValue(new Error("exchange failed")); + + const { OAuthBridge } = await import("@/app/_components/auth/OAuthBridge"); + render(); + + await waitFor(() => { + expect(signOut).toHaveBeenCalledWith({ redirect: false }); + }); + + expect(authStoreState.setAuthenticated).not.toHaveBeenCalledWith(true); + expect(authStoreState.setBootstrapping).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/apps/web/src/test/auth/ProtectedClientGate.test.tsx b/apps/web/src/test/auth/ProtectedClientGate.test.tsx new file mode 100644 index 0000000..29bc62f --- /dev/null +++ b/apps/web/src/test/auth/ProtectedClientGate.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +let authLoading = false; +let sessionStatus: "authenticated" | "loading" | "unauthenticated" = + "authenticated"; + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ isLoading: authLoading }), +})); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ status: sessionStatus }), +})); + +describe("ProtectedClientGate", () => { + it("shows spinner while auth is loading", async () => { + authLoading = true; + sessionStatus = "authenticated"; + + const { ProtectedClientGate } = + await import("@/app/_components/auth/ProtectedClientGate"); + render( + +
secret-content
+
, + ); + + expect(screen.getByLabelText("Loading")).toBeInTheDocument(); + }); + + it("shows spinner while oauth session is loading", async () => { + authLoading = false; + sessionStatus = "loading"; + + const { ProtectedClientGate } = + await import("@/app/_components/auth/ProtectedClientGate"); + render( + +
secret-content
+
, + ); + + expect(screen.getByLabelText("Loading")).toBeInTheDocument(); + }); + + it("renders children when ready", async () => { + authLoading = false; + sessionStatus = "authenticated"; + + const { ProtectedClientGate } = + await import("@/app/_components/auth/ProtectedClientGate"); + render( + +
secret-content
+
, + ); + + expect(screen.getByText("secret-content")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/test/monobank/useMonobankActions.test.tsx b/apps/web/src/test/monobank/useMonobankActions.test.tsx new file mode 100644 index 0000000..f47e82f --- /dev/null +++ b/apps/web/src/test/monobank/useMonobankActions.test.tsx @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { useMonobankActions } from "@/app/(protected)/monobank/_components/useMonobankActions"; + +const t = (key: string, options?: Record) => + `${key}${options ? JSON.stringify(options) : ""}`; + +describe("useMonobankActions", () => { + it("validates short token and stops request", async () => { + const setTokenError = vi.fn(); + const fetchMonobankAccountsData = vi.fn(); + + const { result } = renderHook(() => + useMonobankActions({ + token: "short-token", + transactions: [], + isCooldownActive: false, + remainingSeconds: 0, + fetchMonobankAccountsData, + fetchMonobankData: vi.fn(), + importMonobankData: vi.fn(), + deleteMonobankData: vi.fn(), + startCooldown: vi.fn(), + setTokenError, + setPreviewTransactions: vi.fn(), + open: vi.fn(), + close: vi.fn(), + t, + }), + ); + + await act(async () => { + await result.current.handleFetchAccounts({ + preventDefault: vi.fn(), + } as never); + }); + + expect(setTokenError).toHaveBeenCalledWith("monobank.tokenInvalid"); + expect(fetchMonobankAccountsData).not.toHaveBeenCalled(); + }); + + it("respects cooldown and does not call API", async () => { + const setTokenError = vi.fn(); + const fetchMonobankAccountsData = vi.fn(); + + const { result } = renderHook(() => + useMonobankActions({ + token: "12345678901234567890", + transactions: [], + isCooldownActive: true, + remainingSeconds: 37, + fetchMonobankAccountsData, + fetchMonobankData: vi.fn(), + importMonobankData: vi.fn(), + deleteMonobankData: vi.fn(), + startCooldown: vi.fn(), + setTokenError, + setPreviewTransactions: vi.fn(), + open: vi.fn(), + close: vi.fn(), + t, + }), + ); + + await act(async () => { + await result.current.handleFetchAccounts({ + preventDefault: vi.fn(), + } as never); + }); + + expect(setTokenError).toHaveBeenCalledWith( + `monobank.waitBeforeRequest${JSON.stringify({ seconds: 37 })}`, + ); + expect(fetchMonobankAccountsData).not.toHaveBeenCalled(); + }); + + it("runs account->import flow and reports success", async () => { + const open = vi.fn(); + const close = vi.fn(); + const fetchMonobankAccountsData = vi.fn().mockResolvedValue({ + accounts: [{ id: "acc-1", currencyCode: 980 }], + }); + const fetchMonobankData = vi.fn().mockResolvedValue({ + transactions: [ + { + sourceTransactionId: "tx-1", + title: "Salary", + type: "INCOME", + amount: 100, + currencyCode: "USD", + created_at: new Date("2026-04-10T10:00:00.000Z"), + source: "MONOBANK", + sourceAccountId: "acc-1", + }, + ], + }); + const importMonobankData = vi.fn().mockResolvedValue({ + imported: 1, + skipped: 0, + }); + const setPreviewTransactions = vi.fn(); + + const { result } = renderHook(() => + useMonobankActions({ + token: "12345678901234567890", + transactions: [], + isCooldownActive: false, + remainingSeconds: 0, + fetchMonobankAccountsData, + fetchMonobankData, + importMonobankData, + deleteMonobankData: vi.fn(), + startCooldown: vi.fn(), + setTokenError: vi.fn(), + setPreviewTransactions, + open, + close, + t, + }), + ); + + await act(async () => { + await result.current.handleFetchAccounts({ + preventDefault: vi.fn(), + } as never); + }); + + expect(fetchMonobankAccountsData).toHaveBeenCalledWith({ + token: "12345678901234567890", + }); + + const firstPopup = open.mock.calls[0]?.[1] as ReactElement<{ + onContinue: (params: { + accountId: string; + accountCurrencyCode?: number; + mode: "IMPORT" | "PREVIEW"; + }) => Promise; + }>; + + await act(async () => { + await firstPopup.props.onContinue({ + accountId: "acc-1", + accountCurrencyCode: 980, + mode: "IMPORT", + }); + }); + + expect(close).toHaveBeenCalled(); + expect(fetchMonobankData).toHaveBeenCalled(); + expect(importMonobankData).toHaveBeenCalled(); + expect(setPreviewTransactions).toHaveBeenCalled(); + expect(open).toHaveBeenLastCalledWith( + "monobank.importCompleted", + expect.anything(), + ); + }); +}); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/apps/web/src/test/utils/api.test.ts b/apps/web/src/test/utils/api.test.ts new file mode 100644 index 0000000..b37d7a7 --- /dev/null +++ b/apps/web/src/test/utils/api.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import axios from "axios"; +import { z } from "zod"; +import { handleRequest } from "@/utils/api"; + +const { captureClientError } = vi.hoisted(() => ({ + captureClientError: vi.fn(), +})); + +vi.mock("@/lib/errorCapture", () => ({ + captureClientError, +})); + +describe("handleRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns parsed payload when schema is valid", async () => { + const payload = { ok: true }; + const result = await handleRequest( + Promise.resolve({ data: payload } as never), + z.object({ ok: z.boolean() }), + ); + + expect(result).toEqual(payload); + }); + + it("maps 401 login error into user-friendly message", async () => { + const error = { + isAxiosError: true, + message: "Request failed", + config: { url: "/auth/login" }, + response: { + status: 401, + data: { + error: "Unauthorized", + message: "Unauthorized", + code: 401, + }, + }, + }; + + await expect( + handleRequest(Promise.reject(error as never)), + ).rejects.toMatchObject({ + code: 401, + message: "Invalid email or password", + }); + expect(captureClientError).not.toHaveBeenCalled(); + }); + + it("maps network error to code=0", async () => { + const error = { + isAxiosError: true, + message: "Network Error", + config: { url: "/transactions" }, + response: undefined, + code: "ERR_NETWORK", + }; + + await expect( + handleRequest(Promise.reject(error as never)), + ).rejects.toMatchObject({ + code: 0, + message: "Server is unreachable. Please check your connection", + }); + + expect(captureClientError).not.toHaveBeenCalled(); + }); + + it("reports 5xx errors to capture service", async () => { + const error = { + isAxiosError: true, + message: "Server exploded", + config: { url: "/transactions" }, + response: { + status: 500, + data: { + error: "Internal Server Error", + message: "Internal Server Error", + code: 500, + }, + }, + }; + + await expect( + handleRequest(Promise.reject(error as never)), + ).rejects.toMatchObject({ + code: 500, + }); + + expect(captureClientError).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api:/transactions", + }), + ); + }); + + it("treats non-axios errors as unexpected", async () => { + vi.spyOn(axios, "isAxiosError").mockReturnValue(false); + + await expect( + handleRequest(Promise.reject(new Error("boom"))), + ).rejects.toMatchObject({ + code: 500, + error: "boom", + }); + + expect(captureClientError).toHaveBeenCalledWith( + expect.objectContaining({ source: "api:unexpected" }), + ); + }); +}); diff --git a/apps/web/src/test/utils/summary.helpers.test.ts b/apps/web/src/test/utils/summary.helpers.test.ts new file mode 100644 index 0000000..6d8d5d4 --- /dev/null +++ b/apps/web/src/test/utils/summary.helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + buildChartData, + buildSummary, + normalizeTransactionsForSummary, +} from "@/utils/summary.helpers"; + +describe("summary.helpers", () => { + const tx = [ + { + id: "1", + title: "Salary", + type: "INCOME" as const, + amount: 1000, + created_at: new Date("2026-04-15T10:00:00.000Z"), + }, + { + id: "2", + title: "Food", + type: "EXPENSE" as const, + amount: 200, + created_at: new Date("2026-04-15T12:00:00.000Z"), + }, + { + id: "3", + title: "Bonus", + type: "INCOME" as const, + amount: 300, + created_at: new Date("2026-04-16T09:00:00.000Z"), + }, + ]; + + it("builds summary totals and top transactions", () => { + const result = buildSummary(tx); + + expect(result.currentBalance).toBe(1100); + expect(result.topTransaction.maxPositiveTransaction).toBe("1000"); + expect(result.topTransaction.maxNegativeTransaction).toBe("200"); + }); + + it("builds chart data for all range in chronological order", () => { + const result = buildChartData(tx, "all"); + + expect(result.labels.length).toBe(2); + expect(result.income).toEqual([1000, 300]); + expect(result.outcome).toEqual([200, 0]); + }); + + it("normalizes transaction dates and amounts", () => { + const normalized = normalizeTransactionsForSummary([ + { + id: "1", + title: "Salary", + type: "INCOME", + amount: "1000", + currencyCode: "USD", + source: "MANUAL", + created_at: "2026-04-15T10:00:00.000Z", + updated_at: "2026-04-15T10:00:00.000Z", + location: null, + }, + ]); + + expect(normalized[0]?.amount).toBe(1000); + expect(normalized[0]?.created_at).toBeInstanceOf(Date); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..b28ae85 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/test/**/*.test.ts", "src/test/**/*.test.tsx"], + globals: true, + clearMocks: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 98c1c74..782f288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -159,15 +159,20 @@ "@eslint/js": "^9.30.1", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4.1.11", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/leaflet": "^1.9.20", "@types/node": "^25.5.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", + "jsdom": "^29.0.2", "postcss": "^8.5.6", "typescript": "~5.8.3", - "typescript-eslint": "^8.35.1" + "typescript-eslint": "^8.35.1", + "vitest": "^4.1.4" }, "optionalDependencies": { "@tailwindcss/oxide-linux-x64-gnu": "*", @@ -425,6 +430,13 @@ "node": ">=14.17" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "dev": true, @@ -472,6 +484,78 @@ "openapi-types": ">=7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", + "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@auth/prisma-adapter": { "version": "2.11.1", "license": "ISC", @@ -2189,6 +2273,40 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "dev": true, @@ -2209,6 +2327,155 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "license": "MIT", @@ -2323,6 +2590,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2462,6 +2730,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fintrack/types": { "resolved": "packages/types", "link": true @@ -2990,6 +3276,25 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@next/env": { "version": "16.2.2", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", @@ -3163,6 +3468,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "license": "MIT", @@ -3260,67 +3575,331 @@ "react-dom": "^19.0.0" } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "hasInstallScript": true, - "license": "Apache-2.0" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", "dev": true, "license": "MIT", "engines": { @@ -3694,6 +4273,134 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "dev": true, @@ -3796,6 +4503,25 @@ "win32" ] }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -3850,6 +4576,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "dev": true, @@ -3879,6 +4616,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -4343,27 +5087,113 @@ "version": "8.58.0", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://opencollective.com/vitest" } }, "node_modules/abort-controller": { @@ -4489,6 +5319,16 @@ "version": "2.0.1", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "dev": true, @@ -4622,6 +5462,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -4859,6 +5709,16 @@ "node": ">= 18" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -5129,6 +5989,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -5610,6 +6480,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csso": { "version": "5.0.5", "dev": true, @@ -5644,6 +6521,58 @@ "version": "3.2.3", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -5707,6 +6636,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "dev": true, @@ -5792,6 +6728,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "devOptional": true, @@ -5857,6 +6803,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "license": "MIT", @@ -6193,6 +7147,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -6532,6 +7493,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "license": "BSD-2-Clause", @@ -6604,6 +7575,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "license": "MIT", @@ -6933,6 +7914,21 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -7311,6 +8307,19 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "dev": true, @@ -7464,6 +8473,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "license": "ISC", @@ -7753,6 +8772,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "dev": true, @@ -8542,6 +9568,141 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "license": "MIT", @@ -9194,6 +10355,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -9342,6 +10514,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "license": "ISC", @@ -9859,6 +11041,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "devOptional": true, @@ -10057,6 +11250,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -10586,6 +11805,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "dev": true, @@ -10682,6 +11915,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "license": "MIT", @@ -10862,6 +12105,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, "node_modules/rxjs": { "version": "7.8.2", "dev": true, @@ -10949,6 +12226,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT" @@ -11202,6 +12492,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "dev": true, @@ -11345,6 +12642,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "license": "MIT", @@ -11352,6 +12656,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -11519,6 +12830,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -11759,6 +13083,13 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.2", "license": "MIT" @@ -11788,6 +13119,13 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.1", "devOptional": true, @@ -11838,6 +13176,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "dev": true, @@ -11869,6 +13237,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "license": "MIT" @@ -12224,6 +13605,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "dev": true, @@ -12374,6 +13765,232 @@ "node": ">= 0.8" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/void-elements": { "version": "3.1.0", "license": "MIT", @@ -12381,6 +13998,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "dev": true, @@ -12393,6 +14023,16 @@ "version": "3.0.1", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "license": "MIT", @@ -12496,6 +14136,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -12541,6 +14198,23 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, diff --git a/package.json b/package.json index 0ea1c3d..24a51f1 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "tidy": "npm run fix && npm run check", "check-types": "turbo run check-types", "test:api": "npm --prefix apps/api run test", + "test:web": "npm --prefix apps/web run test", + "test": "npm run test:api && npm run test:web", "dump": "bash scripts/codebase-dump.sh", "dump:api": "bash scripts/codebase-dump.sh -d apps/api", "dump:web": "bash scripts/codebase-dump.sh -d apps/web",