LibreChat/api/server/controllers/FavoritesController.spec.js
Marco Beretta 1a83f36cda
📌 feat: Add Pin Support for Model Specs (#11219)
* feat: Enhance favorites functionality to support model specs

* refactor(FavoritesList): reorder imports based on lenght

* feat: improve favorite modelSpec controller; refactor: useIsActiveItem hook

* refactor: consolidate Favorite type, harden controller, add tests

- Add canonical TUserFavorite type in data-provider, replace three
  duplicate definitions (data-service, data-schemas, store/favorites)
- Consolidate FavoritesController spec validation into single block,
  add return on 500 paths, add maxlength to mongoose sub-schema
- Fix import order in FavoritesList.tsx, merge namespace type imports
  in FavoriteItem.tsx and FavoritesList.tsx
- Add focus-visible:ring-inset on pin buttons to prevent ring clipping
- Add explicit return type and JSDoc on useIsActiveItem hook
- Use props.type for narrowing consistency in FavoriteItem getTypeLabel
- Add 22 backend tests for FavoritesController (spec validation,
  typeCount exclusivity, persistence, GET path)
- Add 40 frontend tests: useFavorites spec methods, useIsActiveItem
  observer lifecycle, ModelSpecItem pin button, FavoriteItem all three
  type branches, FavoritesList spec rendering

* fix: address PR review findings for pin model specs

- Harden backend validation to reject partial cross-type fields
  (e.g. spec+endpoint, agentId+model without endpoint)
- Add stale-spec auto-cleanup in FavoritesList mirroring agent cleanup
- Add type="button" to pin buttons in ModelSpecItem/EndpointModelItem
- Fix import order violations in EndpointModelItem and ModelSpecItem
- Remove hollow test, dead key prop, inline trivial helpers
- Fix misleading test description, add onSelectSpec to test mock
- Add return to controller success responses for consistency
- Add 6 backend tests for partial cross-type field validation

* fix: guard stale-spec cleanup against unloaded startupConfig

Prevents race condition where spec favorites are incorrectly deleted
on cold start before startupConfig has loaded. Mirrors the existing
agentsMap === undefined guard pattern used for stale agent cleanup.

Also adds tests for stale-spec cleanup persistence and fixes namespace
import pattern in FavoritesList.spec.tsx.

* fix: replace nested ternaries with if/else in FavoriteItem

Resolves ESLint no-nested-ternary warnings for name and typeLabel
derivations.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-09 18:37:25 -04:00

308 lines
12 KiB
JavaScript

jest.mock('~/models', () => ({
updateUser: jest.fn(),
getUserById: jest.fn(),
}));
const { updateUser, getUserById } = require('~/models');
const { updateFavoritesController, getFavoritesController } = require('./FavoritesController');
const makeRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
const makeReq = (body = {}) => ({
body,
user: { id: 'user-123' },
});
describe('FavoritesController', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('updateFavoritesController - payload envelope', () => {
it('rejects missing favorites key with 400', async () => {
const req = makeReq({});
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'Favorites data is required' });
expect(updateUser).not.toHaveBeenCalled();
});
it('rejects non-array favorites with 400', async () => {
const req = makeReq({ favorites: 'not-an-array' });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'Favorites must be an array' });
});
it('rejects favorites over MAX_FAVORITES with 400 + code', async () => {
const favorites = Array.from({ length: 51 }, (_, i) => ({ agentId: `agent-${i}` }));
const req = makeReq({ favorites });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
code: 'MAX_FAVORITES_EXCEEDED',
message: 'Maximum 50 favorites allowed',
limit: 50,
});
});
});
describe('updateFavoritesController - agent/model length validation', () => {
it('rejects oversized agentId', async () => {
const req = makeReq({ favorites: [{ agentId: 'a'.repeat(257) }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'agentId exceeds maximum length of 256' });
});
it('rejects oversized model', async () => {
const req = makeReq({ favorites: [{ model: 'm'.repeat(257), endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'model exceeds maximum length of 256' });
});
});
describe('updateFavoritesController - spec validation', () => {
it('accepts a valid spec favorite', async () => {
updateUser.mockResolvedValue({ favorites: [{ spec: 'my-spec' }] });
const req = makeReq({ favorites: [{ spec: 'my-spec' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith([{ spec: 'my-spec' }]);
expect(updateUser).toHaveBeenCalledWith('user-123', {
favorites: [{ spec: 'my-spec' }],
});
});
it('rejects non-string spec with 400', async () => {
const req = makeReq({ favorites: [{ spec: 42 }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
});
it('rejects empty string spec with 400', async () => {
const req = makeReq({ favorites: [{ spec: '' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
});
it('rejects oversized spec with 400', async () => {
const req = makeReq({ favorites: [{ spec: 's'.repeat(257) }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ message: 'spec exceeds maximum length of 256' });
});
it('allows undefined/null spec (treated as absent)', async () => {
updateUser.mockResolvedValue({ favorites: [{ agentId: 'a1' }] });
const req = makeReq({ favorites: [{ agentId: 'a1', spec: null }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
});
});
describe('updateFavoritesController - exclusivity (typeCount)', () => {
it('rejects empty favorite entry with 400', async () => {
const req = makeReq({ favorites: [{}] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'Each favorite must have either agentId, model+endpoint, or spec',
});
});
it('rejects agentId + model combination', async () => {
const req = makeReq({
favorites: [{ agentId: 'a1', model: 'gpt-5', endpoint: 'openai' }],
});
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
});
});
it('rejects agentId + spec combination', async () => {
const req = makeReq({ favorites: [{ agentId: 'a1', spec: 's1' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
});
});
it('rejects model + spec combination', async () => {
const req = makeReq({
favorites: [{ model: 'gpt-5', endpoint: 'openai', spec: 's1' }],
});
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('rejects spec with stray endpoint field', async () => {
const req = makeReq({ favorites: [{ spec: 's1', endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'spec cannot be combined with agentId, model, or endpoint',
});
});
it('rejects spec with stray model field', async () => {
const req = makeReq({ favorites: [{ spec: 's1', model: 'gpt-5' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'spec cannot be combined with agentId, model, or endpoint',
});
});
it('rejects agentId with stray model field (no endpoint)', async () => {
const req = makeReq({ favorites: [{ agentId: 'a1', model: 'gpt-5' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'agentId cannot be combined with model or endpoint',
});
});
it('rejects agentId with stray endpoint field (no model)', async () => {
const req = makeReq({ favorites: [{ agentId: 'a1', endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'agentId cannot be combined with model or endpoint',
});
});
it('rejects model without endpoint (partial model pair)', async () => {
const req = makeReq({ favorites: [{ model: 'gpt-5' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'model and endpoint must be provided together',
});
});
it('rejects endpoint without model (partial model pair)', async () => {
const req = makeReq({ favorites: [{ endpoint: 'openai' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: 'model and endpoint must be provided together',
});
});
it('accepts a mixed array of valid single-type favorites', async () => {
const favorites = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' }];
updateUser.mockResolvedValue({ favorites });
const req = makeReq({ favorites });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(favorites);
});
});
describe('updateFavoritesController - persistence', () => {
it('returns 404 when user is not found', async () => {
updateUser.mockResolvedValue(null);
const req = makeReq({ favorites: [{ spec: 's1' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
});
it('returns 500 when updateUser throws', async () => {
updateUser.mockRejectedValue(new Error('db down'));
const req = makeReq({ favorites: [{ spec: 's1' }] });
const res = makeRes();
await updateFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' });
});
});
describe('getFavoritesController', () => {
it('returns the user favorites array', async () => {
const favorites = [{ agentId: 'a1' }, { spec: 's1' }];
getUserById.mockResolvedValue({ favorites });
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(favorites);
});
it('returns [] when user.favorites is null (falsy)', async () => {
getUserById.mockResolvedValue({ favorites: null });
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(updateUser).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith([]);
});
it('repairs corrupt favorites field (non-array truthy)', async () => {
getUserById.mockResolvedValue({ favorites: 'corrupt' });
updateUser.mockResolvedValue({ favorites: [] });
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(updateUser).toHaveBeenCalledWith('user-123', { favorites: [] });
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith([]);
});
it('returns 404 when user not found', async () => {
getUserById.mockResolvedValue(null);
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
it('returns 500 when getUserById throws', async () => {
getUserById.mockRejectedValue(new Error('db down'));
const req = makeReq();
const res = makeRes();
await getFavoritesController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
});
});
});