Files
OpenHands/__tests__/api/profiles-service.test.ts
T
0569bd77ec feat: add LLM profiles API layer and React Query hooks (#387)
* feat: add LLM profiles API layer and React Query hooks

This PR adds the foundational data layer for the LLM profiles feature:

## API Layer
- ProfilesService: Thin wrapper around SDK ProfilesClient with methods
  for list, get, save, delete, rename, and activate profile operations
- Re-exports SDK types for consumer convenience

## React Query Hooks
- useLlmProfiles: Query hook for listing all profiles
- useSaveLlmProfile: Mutation hook for creating/updating profiles
- useDeleteLlmProfile: Mutation hook for deleting profiles
- useRenameLlmProfile: Mutation hook for renaming profiles
- useActivateLlmProfile: Mutation hook for activating a profile

All mutation hooks properly invalidate both profile list and settings
caches on success, and disable global toasts (consumers handle errors).

## Utilities
- deriveProfileNameFromModel: Derives a clean profile name from model
  strings (e.g., 'openai/gpt-4' -> 'gpt-4')
- PROFILE_NAME_PATTERN: Validation regex for profile names

## Tests
- 47 tests covering all new functionality
- API service method tests
- Hook behavior tests (success, error handling, cache invalidation)
- Utility function tests

Part 1 of LLM Profiles feature (PR A from split plan).
No UI changes - this is purely a data layer addition.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: address PR review feedback

- Remove client.close() calls for consistency with other services
  (SettingsService, SecretsService don't call close())
- Use SETTINGS_QUERY_KEYS.personal() instead of .all for precision
- Add ActiveBackendProvider wrapper in useLlmProfiles tests
- Add test for query key including backend.id and orgId
- Add test for backend-switch cache isolation
- Fix truncation test to actually exercise trailing-dash removal
- Add test for model names that sanitize to empty string

Addresses review feedback from all-hands-bot.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: Remove PR-only artifacts

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-05-12 16:19:28 -04:00

232 lines
6.7 KiB
TypeScript

/**
* ProfilesService tests using SDK client mocks.
*
* Note: We mock ProfilesClient directly because the SDK's HttpClient uses
* native fetch that MSW doesn't intercept reliably in Node.js test environments.
* This approach tests that ProfilesService correctly delegates to ProfilesClient
* methods and propagates errors from the SDK.
*
* For full integration testing, use browser-level tests with MSW.
*/
import { ProfilesClient } from "@openhands/typescript-client/clients";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import ProfilesService from "#/api/profiles-service/profiles-service.api";
// Mock the ProfilesClient from the SDK using vi.hoisted
const {
mockListProfiles,
mockGetProfile,
mockSaveProfile,
mockDeleteProfile,
mockRenameProfile,
mockActivateProfile,
} = vi.hoisted(() => ({
mockListProfiles: vi.fn(),
mockGetProfile: vi.fn(),
mockSaveProfile: vi.fn(),
mockDeleteProfile: vi.fn(),
mockRenameProfile: vi.fn(),
mockActivateProfile: vi.fn(),
}));
vi.mock("@openhands/typescript-client/clients", () => ({
ProfilesClient: vi.fn(function ProfilesClientMock() {
return {
listProfiles: mockListProfiles,
getProfile: mockGetProfile,
saveProfile: mockSaveProfile,
deleteProfile: mockDeleteProfile,
renameProfile: mockRenameProfile,
activateProfile: mockActivateProfile,
};
}),
}));
vi.mock("#/api/agent-server-client-options", () => ({
getAgentServerClientOptions: vi.fn(() => ({
host: "http://localhost:3000",
apiKey: "test-key",
})),
}));
describe("ProfilesService", () => {
beforeEach(() => {
mockListProfiles.mockReset();
mockGetProfile.mockReset();
mockSaveProfile.mockReset();
mockDeleteProfile.mockReset();
mockRenameProfile.mockReset();
mockActivateProfile.mockReset();
vi.mocked(ProfilesClient).mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("listProfiles", () => {
it("calls listProfiles and returns profiles list", async () => {
const mockResponse = {
profiles: [
{
name: "gpt-4-profile",
model: "openai/gpt-4",
base_url: null,
api_key_set: true,
},
{
name: "claude-profile",
model: "anthropic/claude-3",
base_url: "https://api.anthropic.com",
api_key_set: false,
},
],
active_profile: null,
};
mockListProfiles.mockResolvedValue(mockResponse);
const result = await ProfilesService.listProfiles();
expect(mockListProfiles).toHaveBeenCalled();
expect(result).toEqual(mockResponse);
expect(result.profiles).toHaveLength(2);
});
it("returns empty profiles array when no profiles exist", async () => {
const mockResponse = { profiles: [], active_profile: null };
mockListProfiles.mockResolvedValue(mockResponse);
const result = await ProfilesService.listProfiles();
expect(result.profiles).toEqual([]);
});
it("propagates errors from the SDK", async () => {
mockListProfiles.mockRejectedValue(new Error("Network error"));
await expect(ProfilesService.listProfiles()).rejects.toThrow(
"Network error",
);
});
});
describe("getProfile", () => {
it("calls getProfile with profile name", async () => {
const mockResponse = {
name: "my-profile",
config: { model: "openai/gpt-4" },
api_key_set: true,
};
mockGetProfile.mockResolvedValue(mockResponse);
const result = await ProfilesService.getProfile("my-profile");
expect(mockGetProfile).toHaveBeenCalledWith("my-profile", {});
expect(result).toEqual(mockResponse);
});
it("passes exposeSecrets option when set", async () => {
const mockResponse = {
name: "my-profile",
config: { model: "openai/gpt-4", api_key: "encrypted_..." },
api_key_set: true,
};
mockGetProfile.mockResolvedValue(mockResponse);
await ProfilesService.getProfile("my-profile", "encrypted");
expect(mockGetProfile).toHaveBeenCalledWith("my-profile", {
exposeSecrets: "encrypted",
});
});
});
describe("saveProfile", () => {
it("calls saveProfile with name and request body", async () => {
const mockResponse = { name: "new-profile", message: "Profile saved" };
mockSaveProfile.mockResolvedValue(mockResponse);
const request = {
llm: {
model: "openai/gpt-4",
api_key: "sk-xxx",
},
include_secrets: true,
};
const result = await ProfilesService.saveProfile("new-profile", request);
expect(mockSaveProfile).toHaveBeenCalledWith("new-profile", request);
expect(result).toEqual(mockResponse);
});
it("saves profile with base_url", async () => {
const mockResponse = { name: "custom-profile", message: "Profile saved" };
mockSaveProfile.mockResolvedValue(mockResponse);
const request = {
llm: {
model: "openai/gpt-4",
base_url: "https://custom.api.com",
},
};
await ProfilesService.saveProfile("custom-profile", request);
expect(mockSaveProfile).toHaveBeenCalledWith("custom-profile", request);
});
});
describe("deleteProfile", () => {
it("calls deleteProfile with profile name", async () => {
const mockResponse = { name: "old-profile", message: "Profile deleted" };
mockDeleteProfile.mockResolvedValue(mockResponse);
const result = await ProfilesService.deleteProfile("old-profile");
expect(mockDeleteProfile).toHaveBeenCalledWith("old-profile");
expect(result).toEqual(mockResponse);
});
});
describe("renameProfile", () => {
it("calls renameProfile with old and new names", async () => {
const mockResponse = {
name: "renamed-profile",
message: "Profile renamed",
};
mockRenameProfile.mockResolvedValue(mockResponse);
const result = await ProfilesService.renameProfile(
"old-name",
"renamed-profile",
);
expect(mockRenameProfile).toHaveBeenCalledWith(
"old-name",
"renamed-profile",
);
expect(result).toEqual(mockResponse);
});
});
describe("activateProfile", () => {
it("calls activateProfile with profile name", async () => {
const mockResponse = {
name: "active-profile",
message: "Profile activated",
llm_applied: true,
};
mockActivateProfile.mockResolvedValue(mockResponse);
const result = await ProfilesService.activateProfile("active-profile");
expect(mockActivateProfile).toHaveBeenCalledWith("active-profile");
expect(result).toEqual(mockResponse);
});
});
});