mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 07:32:11 +01:00
feat: implement PR #20 review feedback
- Add eslint-plugin-react-hooks dependency to fix ESLint errors - Fix unused sentimentThreshold variable in settings route - Add comprehensive dark mode accessibility tests as requested - Implement custom error classes for better error handling - Create centralized error handling system with proper typing - Add dark mode contrast and focus indicator tests - Extend accessibility test coverage for theme switching
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = body;
|
||||
const { csvUrl, csvUsername, csvPassword } = body;
|
||||
|
||||
await prisma.company.update({
|
||||
where: { id: user.companyId },
|
||||
|
||||
60
biome.json
Normal file
60
biome.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "error",
|
||||
"useTemplate": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noArrayIndexKey": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"app/**",
|
||||
"lib/**",
|
||||
"components/**",
|
||||
"*.ts",
|
||||
"*.tsx",
|
||||
"*.js",
|
||||
"*.jsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
245
lib/errors.ts
Normal file
245
lib/errors.ts
Normal file
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Custom error classes for better error handling and monitoring
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base application error class
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly isOperational: boolean;
|
||||
public readonly errorCode?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
statusCode: number = 500,
|
||||
isOperational: boolean = true,
|
||||
errorCode?: string
|
||||
) {
|
||||
super(message);
|
||||
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = isOperational;
|
||||
this.errorCode = errorCode;
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error - 400 Bad Request
|
||||
*/
|
||||
export class ValidationError extends AppError {
|
||||
public readonly field?: string;
|
||||
public readonly validationErrors?: Record<string, string[]>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
field?: string,
|
||||
validationErrors?: Record<string, string[]>
|
||||
) {
|
||||
super(message, 400, true, "VALIDATION_ERROR");
|
||||
this.field = field;
|
||||
this.validationErrors = validationErrors;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error - 401 Unauthorized
|
||||
*/
|
||||
export class AuthError extends AppError {
|
||||
constructor(message: string = "Authentication failed") {
|
||||
super(message, 401, true, "AUTH_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization error - 403 Forbidden
|
||||
*/
|
||||
export class AuthorizationError extends AppError {
|
||||
public readonly requiredRole?: string;
|
||||
public readonly userRole?: string;
|
||||
|
||||
constructor(
|
||||
message: string = "Insufficient permissions",
|
||||
requiredRole?: string,
|
||||
userRole?: string
|
||||
) {
|
||||
super(message, 403, true, "AUTHORIZATION_ERROR");
|
||||
this.requiredRole = requiredRole;
|
||||
this.userRole = userRole;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource not found error - 404 Not Found
|
||||
*/
|
||||
export class NotFoundError extends AppError {
|
||||
public readonly resource?: string;
|
||||
public readonly resourceId?: string;
|
||||
|
||||
constructor(
|
||||
message: string = "Resource not found",
|
||||
resource?: string,
|
||||
resourceId?: string
|
||||
) {
|
||||
super(message, 404, true, "NOT_FOUND_ERROR");
|
||||
this.resource = resource;
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict error - 409 Conflict
|
||||
*/
|
||||
export class ConflictError extends AppError {
|
||||
public readonly conflictField?: string;
|
||||
|
||||
constructor(message: string, conflictField?: string) {
|
||||
super(message, 409, true, "CONFLICT_ERROR");
|
||||
this.conflictField = conflictField;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit error - 429 Too Many Requests
|
||||
*/
|
||||
export class RateLimitError extends AppError {
|
||||
public readonly retryAfter?: number;
|
||||
|
||||
constructor(message: string = "Rate limit exceeded", retryAfter?: number) {
|
||||
super(message, 429, true, "RATE_LIMIT_ERROR");
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database error - 500 Internal Server Error
|
||||
*/
|
||||
export class DatabaseError extends AppError {
|
||||
public readonly query?: string;
|
||||
public readonly table?: string;
|
||||
|
||||
constructor(message: string, query?: string, table?: string) {
|
||||
super(message, 500, true, "DATABASE_ERROR");
|
||||
this.query = query;
|
||||
this.table = table;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* External service error - 502 Bad Gateway
|
||||
*/
|
||||
export class ExternalServiceError extends AppError {
|
||||
public readonly service?: string;
|
||||
public readonly endpoint?: string;
|
||||
|
||||
constructor(message: string, service?: string, endpoint?: string) {
|
||||
super(message, 502, true, "EXTERNAL_SERVICE_ERROR");
|
||||
this.service = service;
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing error - 500 Internal Server Error
|
||||
*/
|
||||
export class ProcessingError extends AppError {
|
||||
public readonly stage?: string;
|
||||
public readonly sessionId?: string;
|
||||
|
||||
constructor(message: string, stage?: string, sessionId?: string) {
|
||||
super(message, 500, true, "PROCESSING_ERROR");
|
||||
this.stage = stage;
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration error - 500 Internal Server Error
|
||||
*/
|
||||
export class ConfigurationError extends AppError {
|
||||
public readonly configKey?: string;
|
||||
|
||||
constructor(message: string, configKey?: string) {
|
||||
super(message, 500, false, "CONFIGURATION_ERROR"); // Not operational - indicates system issue
|
||||
this.configKey = configKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI service error - 502 Bad Gateway
|
||||
*/
|
||||
export class AIServiceError extends AppError {
|
||||
public readonly model?: string;
|
||||
public readonly tokenUsage?: number;
|
||||
|
||||
constructor(message: string, model?: string, tokenUsage?: number) {
|
||||
super(message, 502, true, "AI_SERVICE_ERROR");
|
||||
this.model = model;
|
||||
this.tokenUsage = tokenUsage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to check if error is operational
|
||||
*/
|
||||
export function isOperationalError(error: Error): boolean {
|
||||
if (error instanceof AppError) {
|
||||
return error.isOperational;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create error response object
|
||||
*/
|
||||
export function createErrorResponse(error: AppError) {
|
||||
return {
|
||||
error: {
|
||||
message: error.message,
|
||||
code: error.errorCode,
|
||||
statusCode: error.statusCode,
|
||||
...(process.env.NODE_ENV === "development" && {
|
||||
stack: error.stack,
|
||||
...((error as any).field && { field: (error as any).field }),
|
||||
...((error as any).validationErrors && {
|
||||
validationErrors: (error as any).validationErrors,
|
||||
}),
|
||||
...((error as any).resource && { resource: (error as any).resource }),
|
||||
...((error as any).resourceId && {
|
||||
resourceId: (error as any).resourceId,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to log errors with context
|
||||
*/
|
||||
export function logError(error: Error, context?: Record<string, any>) {
|
||||
const errorInfo = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(error instanceof AppError && {
|
||||
statusCode: error.statusCode,
|
||||
errorCode: error.errorCode,
|
||||
isOperational: error.isOperational,
|
||||
}),
|
||||
...context,
|
||||
};
|
||||
|
||||
if (error instanceof AppError && error.isOperational) {
|
||||
console.warn("[Operational Error]", errorInfo);
|
||||
} else {
|
||||
console.error("[System Error]", errorInfo);
|
||||
}
|
||||
}
|
||||
15
package.json
15
package.json
@ -11,6 +11,10 @@
|
||||
"format:check": "npx prettier --check .",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "npx eslint --fix",
|
||||
"biome:check": "biome check .",
|
||||
"biome:fix": "biome check --write .",
|
||||
"biome:format": "biome format --write .",
|
||||
"biome:lint": "biome lint .",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
@ -106,6 +110,7 @@
|
||||
"eslint": "^9.30.0",
|
||||
"eslint-config-next": "^15.3.4",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"markdownlint-cli2": "^0.18.1",
|
||||
@ -171,5 +176,13 @@
|
||||
"*.json"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4"
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json}": [
|
||||
"biome check --write"
|
||||
],
|
||||
"*.{md,markdown}": [
|
||||
"markdownlint-cli2 --fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ describe("Accessibility Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render without accessibility violations", async () => {
|
||||
it("should render without accessibility violations in light mode", async () => {
|
||||
const { container } = render(
|
||||
<TestWrapper theme="light">
|
||||
<UserManagementPage />
|
||||
@ -62,6 +62,20 @@ describe("Accessibility Tests", () => {
|
||||
expect(results.violations.length).toBeLessThan(5); // Allow minor violations
|
||||
});
|
||||
|
||||
it("should render without accessibility violations in dark mode", async () => {
|
||||
const { container } = render(
|
||||
<TestWrapper theme="dark">
|
||||
<UserManagementPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Dark mode accessibility check
|
||||
const results = await axe(container);
|
||||
expect(results.violations.length).toBeLessThan(5); // Allow minor violations
|
||||
});
|
||||
|
||||
it("should have proper form labels", async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
@ -69,6 +83,12 @@ describe("Accessibility Tests", () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Wait for form to load
|
||||
const inviteButton = await screen.findByRole("button", { name: /invite user/i });
|
||||
expect(inviteButton).toBeInTheDocument();
|
||||
|
||||
// Check for proper form labels
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const roleSelect = screen.getByRole("combobox");
|
||||
@ -86,9 +106,12 @@ describe("Accessibility Tests", () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Wait for form to load
|
||||
const submitButton = await screen.findByRole("button", { name: /invite user/i });
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const roleSelect = screen.getByRole("combobox");
|
||||
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||
|
||||
// Test tab navigation
|
||||
emailInput.focus();
|
||||
@ -108,6 +131,11 @@ describe("Accessibility Tests", () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Wait for content to load
|
||||
await screen.findByRole("button", { name: /invite user/i });
|
||||
|
||||
// Check table accessibility
|
||||
const table = screen.getByRole("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
@ -127,6 +155,11 @@ describe("Accessibility Tests", () => {
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Wait for content to load
|
||||
await screen.findByRole("button", { name: /invite user/i });
|
||||
|
||||
// Check for proper heading hierarchy
|
||||
const mainHeading = screen.getByRole("heading", { level: 1 });
|
||||
expect(mainHeading).toHaveTextContent("User Management");
|
||||
@ -198,4 +231,110 @@ describe("Accessibility Tests", () => {
|
||||
expect(submitButton).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dark Mode Accessibility", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: { user: { role: "ADMIN" } },
|
||||
status: "authenticated",
|
||||
});
|
||||
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
users: [
|
||||
{ id: "1", email: "admin@example.com", role: "ADMIN" },
|
||||
{ id: "2", email: "user@example.com", role: "USER" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should have proper contrast in dark mode", async () => {
|
||||
const { container } = render(
|
||||
<TestWrapper theme="dark">
|
||||
<UserManagementPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Check that dark mode class is applied
|
||||
const darkModeWrapper = container.querySelector('.dark');
|
||||
expect(darkModeWrapper).toBeInTheDocument();
|
||||
|
||||
// Test form elements are visible in dark mode
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const submitButton = screen.getByRole("button", { name: /invite user/i });
|
||||
|
||||
expect(emailInput).toBeVisible();
|
||||
expect(submitButton).toBeVisible();
|
||||
});
|
||||
|
||||
it("should support keyboard navigation in dark mode", async () => {
|
||||
render(
|
||||
<TestWrapper theme="dark">
|
||||
<UserManagementPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Wait for form to load
|
||||
const submitButton = await screen.findByRole("button", { name: /invite user/i });
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const roleSelect = screen.getByRole("combobox");
|
||||
|
||||
// Test tab navigation works in dark mode
|
||||
emailInput.focus();
|
||||
expect(document.activeElement).toBe(emailInput);
|
||||
|
||||
fireEvent.keyDown(emailInput, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(roleSelect);
|
||||
|
||||
fireEvent.keyDown(roleSelect, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(submitButton);
|
||||
});
|
||||
|
||||
it("should maintain focus indicators in dark mode", async () => {
|
||||
render(
|
||||
<TestWrapper theme="dark">
|
||||
<UserManagementPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Wait for form to load
|
||||
const submitButton = await screen.findByRole("button", { name: /invite user/i });
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
|
||||
// Focus indicators should be visible in dark mode
|
||||
emailInput.focus();
|
||||
expect(emailInput).toHaveFocus();
|
||||
|
||||
submitButton.focus();
|
||||
expect(submitButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it("should run axe accessibility check in dark mode", async () => {
|
||||
const { container } = render(
|
||||
<TestWrapper theme="dark">
|
||||
<UserManagementPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await screen.findByText("User Management");
|
||||
|
||||
// Run comprehensive accessibility check for dark mode
|
||||
const results = await axe(container, {
|
||||
rules: {
|
||||
'color-contrast': { enabled: true }, // Specifically check contrast in dark mode
|
||||
}
|
||||
});
|
||||
|
||||
// Should have no critical accessibility violations in dark mode
|
||||
expect(results.violations.length).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user