Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v4
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

- name: Setup Node.js
uses: actions/setup-node@v4
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
with:
node-version: '22.12.0'
cache: 'npm'
Expand Down
2 changes: 1 addition & 1 deletion nexus/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"swagger-ui-express": "^5.0.1",
"winston": "^3.19.0",
"zod": "^4.3.6",
"ajv": "^8.18.0"
"ajv": "^8.12.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",
Expand Down
12 changes: 12 additions & 0 deletions nexus/frontend/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Config } from 'jest';

const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};

export default config;
1 change: 1 addition & 0 deletions nexus/frontend/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
12 changes: 10 additions & 2 deletions nexus/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"build": "next build",
"start": "next start -H 0.0.0.0 -p ${PORT:-3000}",
"lint": "node -e \"process.env.NODE_PATH=require('path').resolve('node_modules'); require('module').Module._initPaths(); require('child_process').execSync('npx eslint src', {stdio:'inherit', env: {...process.env, NODE_PATH: require('path').resolve('node_modules')}})\"",
"lint:strict": "npm run lint"
"lint:strict": "npm run lint",
"test": "jest"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
Expand Down Expand Up @@ -42,13 +43,20 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/babel__traverse": "^7.28.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"axios-mock-adapter": "^2.1.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jest": "^30.4.0",
"jest-environment-jsdom": "^30.4.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.9",
"typescript": "^5"
}
}
}
173 changes: 173 additions & 0 deletions nexus/frontend/src/lib/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { api } from "../api";
import {
ensureNetworkConsent,
} from "../network-consent";

// Mock dependencies
jest.mock("../network-consent", () => ({
ensureNetworkConsent: jest.fn().mockResolvedValue(undefined),
ensureRecentUserInteraction: jest.fn().mockResolvedValue(undefined),
isNetworkConsentError: jest.fn().mockReturnValue(false),
isNetworkInteractionError: jest.fn().mockReturnValue(false),
}));

jest.mock("../desktop-offline", () => ({
shouldHandleDesktopOfflineRequest: jest.fn().mockReturnValue(false),
handleDesktopOfflineRequest: jest.fn(),
}));

describe("api interceptors and configuration", () => {
let mockApi: MockAdapter;
let mockAxios: MockAdapter;

const originalLocation = window.location;
Comment thread
Adityavanjre marked this conversation as resolved.

beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
mockApi = new MockAdapter(api);
mockAxios = new MockAdapter(axios); // for the refresh token axios.post call

// Clear mocks
jest.clearAllMocks();

// Reset document.cookie
Object.defineProperty(document, "cookie", {
writable: true,
value: "",
});

// Reset window/localStorage things
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});

Object.defineProperty(window, "dispatchEvent", {
value: jest.fn(),
writable: true,
});
});

afterEach(() => {
jest.restoreAllMocks();
mockApi.restore();
mockAxios.restore();
(window as unknown as Record<string, unknown>).location = originalLocation;
});

it("should inject CSRF token on mutating requests", async () => {
document.cookie = "nexus-csrf=test-csrf-token";

mockApi.onPost("/test").reply((config) => {
return [200, { headers: config.headers }];
});

const response = await api.post("/test");
expect(response.data.headers["X-CSRF-Token"]).toBe("test-csrf-token");
});

it("should not inject CSRF token on GET requests", async () => {
document.cookie = "nexus-csrf=test-csrf-token";

mockApi.onGet("/test-get").reply((config) => {
return [200, { headers: config.headers }];
});

const response = await api.get("/test-get");
expect(response.data.headers["X-CSRF-Token"]).toBeUndefined();
});

it("should trigger soft refresh on x-app-version mismatch", async () => {
(window.localStorage.getItem as jest.Mock).mockReturnValue("v1");

mockApi.onGet("/version-test").reply(200, {}, { "x-app-version": "v2" });

await api.get("/version-test");

expect(window.localStorage.setItem).toHaveBeenCalledWith("nexus_version", "v2");
});

it("should not trigger soft refresh if versions match", async () => {
(window.localStorage.getItem as jest.Mock).mockReturnValue("v1");

mockApi.onGet("/version-test").reply(200, {}, { "x-app-version": "v1" });

await api.get("/version-test");

expect(window.localStorage.setItem).not.toHaveBeenCalledWith("nexus_version", "v1");
// expect(window.location.reload).not.toHaveBeenCalled();
});

it("should set initial version if not present", async () => {
(window.localStorage.getItem as jest.Mock).mockReturnValue(null);

mockApi.onGet("/version-test").reply(200, {}, { "x-app-version": "v1" });

await api.get("/version-test");

expect(window.localStorage.setItem).toHaveBeenCalledWith("nexus_version", "v1");
// expect(window.location.reload).not.toHaveBeenCalled();
});

it("should reject with network offline payload when ERR_NETWORK", async () => {
mockApi.onGet("/offline-test").networkError();

await expect(api.get("/offline-test")).rejects.toEqual({
message: "Offline Mode: Please check your internet connection.",
isOffline: true,
});

expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent));
expect((window.dispatchEvent as jest.Mock).mock.calls[0][0].type).toBe("offline-mode");
});

it("should handle 429 rate limit correctly", async () => {
mockApi.onGet("/rate-limit").reply(429);

await expect(api.get("/rate-limit")).rejects.toEqual({
message: "Klypso Cloud is waking up. Please try again after 90s.",
status: 429,
isRateLimited: true,
});
});

it("should handle 503 Server Overload correctly", async () => {
mockApi.onGet("/wakeup").reply(503);

await expect(api.get("/wakeup")).rejects.toEqual({
message: "Klypso Cloud is waking up. Please wait 90 seconds and try again.",
isWakeup: true,
});
});

it("should dispatch session-expired on 401 when not on auth page", async () => {
mockApi.onGet("/unauth").reply(401, { code: "OTHER" });

await expect(api.get("/unauth")).rejects.toThrow();

expect(window.localStorage.removeItem).toHaveBeenCalledWith("k_user");
expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent));
expect((window.dispatchEvent as jest.Mock).mock.calls[0][0].type).toBe("session-expired");
});

it("should attempt token refresh on 401 TOKEN_EXPIRED", async () => {
mockApi.onGet("/protected").replyOnce(401, { code: "TOKEN_EXPIRED" });
mockApi.onGet("/protected").reply(200, { success: true });

mockAxios.onPost(/\/auth\/refresh/).reply(200, { user: { id: 1 } });

const response = await api.get("/protected");

expect(ensureNetworkConsent).toHaveBeenCalled();
expect(window.localStorage.setItem).toHaveBeenCalledWith("k_user", JSON.stringify({ id: 1 }));
expect(response.data.success).toBe(true);
});
Comment thread
Adityavanjre marked this conversation as resolved.
});
Loading