mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 17:30:16 +01:00
Merge branch 'main' into docs/azure-instance-name-clarification
This commit is contained in:
commit
af4520df29
14 changed files with 1467 additions and 11 deletions
|
|
@ -254,6 +254,10 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
|
||||||
|
|
||||||
# OpenAI Image Tools Customization
|
# OpenAI Image Tools Customization
|
||||||
#----------------
|
#----------------
|
||||||
|
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
|
||||||
|
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
|
||||||
|
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
|
||||||
|
# IMAGE_GEN_OAI_DESCRIPTION=
|
||||||
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
|
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
|
||||||
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
|
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
|
||||||
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
|
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,6 @@ class STTService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
...(apiKey && { 'api-key': apiKey }),
|
...(apiKey && { 'api-key': apiKey }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,7 @@ describe('Apple Login Strategy', () => {
|
||||||
fileStrategy: 'local',
|
fileStrategy: 'local',
|
||||||
balance: { enabled: false },
|
balance: { enabled: false },
|
||||||
}),
|
}),
|
||||||
|
'jane.doe@example.com',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,25 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||||
const { updateUser, createUser, getUserById } = require('~/models');
|
const { updateUser, createUser, getUserById } = require('~/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
|
* Updates the avatar URL and email of an existing user. If the user's avatar URL does not include the query parameter
|
||||||
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
|
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
|
||||||
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
|
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
|
||||||
|
* Also updates the email if it has changed (e.g., when a Google Workspace email is updated).
|
||||||
*
|
*
|
||||||
* @param {IUser} oldUser - The existing user object that needs to be updated.
|
* @param {IUser} oldUser - The existing user object that needs to be updated.
|
||||||
* @param {string} avatarUrl - The new avatar URL to be set for the user.
|
* @param {string} avatarUrl - The new avatar URL to be set for the user.
|
||||||
* @param {AppConfig} appConfig - The application configuration object.
|
* @param {AppConfig} appConfig - The application configuration object.
|
||||||
|
* @param {string} [email] - Optional. The new email address to update if it has changed.
|
||||||
*
|
*
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
* The function updates the user's avatar and saves the user object. It does not return any value.
|
* The function updates the user's avatar and/or email and saves the user object. It does not return any value.
|
||||||
*
|
*
|
||||||
* @throws {Error} Throws an error if there's an issue saving the updated user object.
|
* @throws {Error} Throws an error if there's an issue saving the updated user object.
|
||||||
*/
|
*/
|
||||||
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
const handleExistingUser = async (oldUser, avatarUrl, appConfig, email) => {
|
||||||
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
||||||
const isLocal = fileStrategy === FileSources.local;
|
const isLocal = fileStrategy === FileSources.local;
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
let updatedAvatar = false;
|
let updatedAvatar = false;
|
||||||
const hasManualFlag =
|
const hasManualFlag =
|
||||||
|
|
@ -39,7 +42,16 @@ const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedAvatar) {
|
if (updatedAvatar) {
|
||||||
await updateUser(oldUser._id, { avatar: updatedAvatar });
|
updates.avatar = updatedAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update email if it has changed */
|
||||||
|
if (email && email.trim() !== oldUser.email) {
|
||||||
|
updates.email = email.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await updateUser(oldUser._id, updates);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,4 +167,76 @@ describe('handleExistingUser', () => {
|
||||||
// This should throw an error when trying to access oldUser._id
|
// This should throw an error when trying to access oldUser._id
|
||||||
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
|
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update email when it has changed', async () => {
|
||||||
|
const oldUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: 'old@example.com',
|
||||||
|
avatar: 'https://example.com/avatar.png?manual=true',
|
||||||
|
};
|
||||||
|
const avatarUrl = 'https://example.com/avatar.png';
|
||||||
|
const newEmail = 'new@example.com';
|
||||||
|
|
||||||
|
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
|
||||||
|
|
||||||
|
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'new@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update both avatar and email when both have changed', async () => {
|
||||||
|
const oldUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: 'old@example.com',
|
||||||
|
avatar: null,
|
||||||
|
};
|
||||||
|
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||||
|
const newEmail = 'new@example.com';
|
||||||
|
|
||||||
|
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
|
||||||
|
|
||||||
|
expect(updateUser).toHaveBeenCalledWith('user123', {
|
||||||
|
avatar: avatarUrl,
|
||||||
|
email: 'new@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update email when it has not changed', async () => {
|
||||||
|
const oldUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: 'same@example.com',
|
||||||
|
avatar: 'https://example.com/avatar.png?manual=true',
|
||||||
|
};
|
||||||
|
const avatarUrl = 'https://example.com/avatar.png';
|
||||||
|
const sameEmail = 'same@example.com';
|
||||||
|
|
||||||
|
await handleExistingUser(oldUser, avatarUrl, {}, sameEmail);
|
||||||
|
|
||||||
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim email before comparison and update', async () => {
|
||||||
|
const oldUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
avatar: 'https://example.com/avatar.png?manual=true',
|
||||||
|
};
|
||||||
|
const avatarUrl = 'https://example.com/avatar.png';
|
||||||
|
const newEmailWithSpaces = ' newemail@example.com ';
|
||||||
|
|
||||||
|
await handleExistingUser(oldUser, avatarUrl, {}, newEmailWithSpaces);
|
||||||
|
|
||||||
|
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'newemail@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update when email parameter is not provided', async () => {
|
||||||
|
const oldUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
avatar: 'https://example.com/avatar.png?manual=true',
|
||||||
|
};
|
||||||
|
const avatarUrl = 'https://example.com/avatar.png';
|
||||||
|
|
||||||
|
await handleExistingUser(oldUser, avatarUrl, {});
|
||||||
|
|
||||||
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,24 @@ const socialLogin =
|
||||||
return cb(error);
|
return cb(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await findUser({ email: email.trim() });
|
const providerKey = `${provider}Id`;
|
||||||
|
let existingUser = null;
|
||||||
|
|
||||||
|
/** First try to find user by provider ID (e.g., googleId, facebookId) */
|
||||||
|
if (id && typeof id === 'string') {
|
||||||
|
existingUser = await findUser({ [providerKey]: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If not found by provider ID, try finding by email */
|
||||||
|
if (!existingUser) {
|
||||||
|
existingUser = await findUser({ email: email?.trim() });
|
||||||
|
if (existingUser) {
|
||||||
|
logger.warn(`[${provider}Login] User found by email: ${email} but not by ${providerKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existingUser?.provider === provider) {
|
if (existingUser?.provider === provider) {
|
||||||
await handleExistingUser(existingUser, avatarUrl, appConfig);
|
await handleExistingUser(existingUser, avatarUrl, appConfig, email);
|
||||||
return cb(null, existingUser);
|
return cb(null, existingUser);
|
||||||
} else if (existingUser) {
|
} else if (existingUser) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
276
api/strategies/socialLogin.test.js
Normal file
276
api/strategies/socialLogin.test.js
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { ErrorTypes } = require('librechat-data-provider');
|
||||||
|
const { createSocialUser, handleExistingUser } = require('./process');
|
||||||
|
const socialLogin = require('./socialLogin');
|
||||||
|
const { findUser } = require('~/models');
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => {
|
||||||
|
const actualModule = jest.requireActual('@librechat/data-schemas');
|
||||||
|
return {
|
||||||
|
...actualModule,
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('./process', () => ({
|
||||||
|
createSocialUser: jest.fn(),
|
||||||
|
handleExistingUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
...jest.requireActual('@librechat/api'),
|
||||||
|
isEnabled: jest.fn().mockReturnValue(true),
|
||||||
|
isEmailDomainAllowed: jest.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models', () => ({
|
||||||
|
findUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config', () => ({
|
||||||
|
getAppConfig: jest.fn().mockResolvedValue({
|
||||||
|
fileStrategy: 'local',
|
||||||
|
balance: { enabled: false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('socialLogin', () => {
|
||||||
|
const mockGetProfileDetails = ({ profile }) => ({
|
||||||
|
email: profile.emails[0].value,
|
||||||
|
id: profile.id,
|
||||||
|
avatarUrl: profile.photos?.[0]?.value || null,
|
||||||
|
username: profile.name?.givenName || 'user',
|
||||||
|
name: `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
|
||||||
|
emailVerified: profile.emails[0].verified || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Finding users by provider ID', () => {
|
||||||
|
it('should find user by provider ID (googleId) when email has changed', async () => {
|
||||||
|
const provider = 'google';
|
||||||
|
const googleId = 'google-user-123';
|
||||||
|
const oldEmail = 'old@example.com';
|
||||||
|
const newEmail = 'new@example.com';
|
||||||
|
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: oldEmail,
|
||||||
|
provider: 'google',
|
||||||
|
googleId: googleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mock findUser to return user on first call (by googleId), null on second call */
|
||||||
|
findUser
|
||||||
|
.mockResolvedValueOnce(existingUser) // First call: finds by googleId
|
||||||
|
.mockResolvedValueOnce(null); // Second call would be by email, but won't be reached
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: googleId,
|
||||||
|
emails: [{ value: newEmail, verified: true }],
|
||||||
|
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||||
|
name: { givenName: 'John', familyName: 'Doe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
await loginFn(null, null, null, mockProfile, callback);
|
||||||
|
|
||||||
|
/** Verify it searched by googleId first */
|
||||||
|
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||||
|
|
||||||
|
/** Verify it did NOT search by email (because it found user by googleId) */
|
||||||
|
expect(findUser).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
/** Verify handleExistingUser was called with the new email */
|
||||||
|
expect(handleExistingUser).toHaveBeenCalledWith(
|
||||||
|
existingUser,
|
||||||
|
'https://example.com/avatar.png',
|
||||||
|
expect.any(Object),
|
||||||
|
newEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Verify callback was called with success */
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, existingUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find user by provider ID (facebookId) when using Facebook', async () => {
|
||||||
|
const provider = 'facebook';
|
||||||
|
const facebookId = 'fb-user-456';
|
||||||
|
const email = 'user@example.com';
|
||||||
|
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user456',
|
||||||
|
email: email,
|
||||||
|
provider: 'facebook',
|
||||||
|
facebookId: facebookId,
|
||||||
|
};
|
||||||
|
|
||||||
|
findUser.mockResolvedValue(existingUser); // Always returns user
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: facebookId,
|
||||||
|
emails: [{ value: email, verified: true }],
|
||||||
|
photos: [{ value: 'https://example.com/fb-avatar.png' }],
|
||||||
|
name: { givenName: 'Jane', familyName: 'Smith' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
await loginFn(null, null, null, mockProfile, callback);
|
||||||
|
|
||||||
|
/** Verify it searched by facebookId first */
|
||||||
|
expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
|
||||||
|
expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
|
||||||
|
|
||||||
|
expect(handleExistingUser).toHaveBeenCalledWith(
|
||||||
|
existingUser,
|
||||||
|
'https://example.com/fb-avatar.png',
|
||||||
|
expect.any(Object),
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, existingUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to finding user by email if not found by provider ID', async () => {
|
||||||
|
const provider = 'google';
|
||||||
|
const googleId = 'google-user-789';
|
||||||
|
const email = 'user@example.com';
|
||||||
|
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user789',
|
||||||
|
email: email,
|
||||||
|
provider: 'google',
|
||||||
|
googleId: 'old-google-id', // Different googleId (edge case)
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First call (by googleId) returns null, second call (by email) returns user */
|
||||||
|
findUser
|
||||||
|
.mockResolvedValueOnce(null) // By googleId
|
||||||
|
.mockResolvedValueOnce(existingUser); // By email
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: googleId,
|
||||||
|
emails: [{ value: email, verified: true }],
|
||||||
|
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||||
|
name: { givenName: 'Bob', familyName: 'Johnson' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
await loginFn(null, null, null, mockProfile, callback);
|
||||||
|
|
||||||
|
/** Verify both searches happened */
|
||||||
|
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||||
|
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
||||||
|
expect(findUser).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
/** Verify warning log */
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
`[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handleExistingUser).toHaveBeenCalled();
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, existingUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new user if not found by provider ID or email', async () => {
|
||||||
|
const provider = 'google';
|
||||||
|
const googleId = 'google-new-user';
|
||||||
|
const email = 'newuser@example.com';
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
_id: 'newuser123',
|
||||||
|
email: email,
|
||||||
|
provider: 'google',
|
||||||
|
googleId: googleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Both searches return null */
|
||||||
|
findUser.mockResolvedValue(null);
|
||||||
|
createSocialUser.mockResolvedValue(newUser);
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: googleId,
|
||||||
|
emails: [{ value: email, verified: true }],
|
||||||
|
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||||
|
name: { givenName: 'New', familyName: 'User' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
await loginFn(null, null, null, mockProfile, callback);
|
||||||
|
|
||||||
|
/** Verify both searches happened */
|
||||||
|
expect(findUser).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
/** Verify createSocialUser was called */
|
||||||
|
expect(createSocialUser).toHaveBeenCalledWith({
|
||||||
|
email: email,
|
||||||
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
|
provider: provider,
|
||||||
|
providerKey: 'googleId',
|
||||||
|
providerId: googleId,
|
||||||
|
username: 'New',
|
||||||
|
name: 'New User',
|
||||||
|
emailVerified: true,
|
||||||
|
appConfig: expect.any(Object),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, newUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should return error if user exists with different provider', async () => {
|
||||||
|
const provider = 'google';
|
||||||
|
const googleId = 'google-user-123';
|
||||||
|
const email = 'user@example.com';
|
||||||
|
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user123',
|
||||||
|
email: email,
|
||||||
|
provider: 'local', // Different provider
|
||||||
|
};
|
||||||
|
|
||||||
|
findUser
|
||||||
|
.mockResolvedValueOnce(null) // By googleId
|
||||||
|
.mockResolvedValueOnce(existingUser); // By email
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: googleId,
|
||||||
|
emails: [{ value: email, verified: true }],
|
||||||
|
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||||
|
name: { givenName: 'John', familyName: 'Doe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
await loginFn(null, null, null, mockProfile, callback);
|
||||||
|
|
||||||
|
/** Verify error callback */
|
||||||
|
expect(callback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: ErrorTypes.AUTH_FAILED,
|
||||||
|
provider: 'local',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
`[${provider}Login] User ${email} already exists with provider local`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -117,8 +117,10 @@ const AttachFileMenu = ({
|
||||||
const items: MenuItemProps[] = [];
|
const items: MenuItemProps[] = [];
|
||||||
|
|
||||||
const currentProvider = provider || endpoint;
|
const currentProvider = provider || endpoint;
|
||||||
|
if (
|
||||||
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
|
isDocumentSupportedProvider(endpointType) ||
|
||||||
|
isDocumentSupportedProvider(currentProvider)
|
||||||
|
) {
|
||||||
items.push({
|
items.push({
|
||||||
label: localize('com_ui_upload_provider'),
|
label: localize('com_ui_upload_provider'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||||
const currentProvider = provider || endpoint;
|
const currentProvider = provider || endpoint;
|
||||||
|
|
||||||
// Check if provider supports document upload
|
// Check if provider supports document upload
|
||||||
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
|
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
|
||||||
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
||||||
const validFileTypes = isGoogleProvider
|
const validFileTypes = isGoogleProvider
|
||||||
? files.every(
|
? files.every(
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export default function FileRow({
|
||||||
>
|
>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<Image
|
<Image
|
||||||
url={file.preview ?? file.filepath}
|
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
progress={file.progress}
|
progress={file.progress}
|
||||||
source={file.source}
|
source={file.source}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,602 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import AttachFileMenu from '../AttachFileMenu';
|
||||||
|
|
||||||
|
// Mock all the hooks
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useAgentToolPermissions: jest.fn(),
|
||||||
|
useAgentCapabilities: jest.fn(),
|
||||||
|
useGetAgentsConfig: jest.fn(),
|
||||||
|
useFileHandling: jest.fn(),
|
||||||
|
useLocalize: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/data-provider', () => ({
|
||||||
|
useGetStartupConfig: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components/SharePoint', () => ({
|
||||||
|
SharePointPickerDialog: jest.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/client', () => {
|
||||||
|
const React = jest.requireActual('react');
|
||||||
|
return {
|
||||||
|
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
|
||||||
|
<div data-testid="file-upload">
|
||||||
|
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
TooltipAnchor: ({ render }: any) => render,
|
||||||
|
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
|
||||||
|
const handleTriggerClick = () => {
|
||||||
|
if (setIsOpen) {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div onClick={handleTriggerClick}>{trigger}</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div data-testid="dropdown-menu">
|
||||||
|
{items.map((item: any, idx: number) => (
|
||||||
|
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
|
||||||
|
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ariakit/react', () => ({
|
||||||
|
MenuButton: ({ children, onClick, disabled, ...props }: any) => (
|
||||||
|
<button onClick={onClick} disabled={disabled} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
|
||||||
|
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
|
||||||
|
const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig;
|
||||||
|
const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling;
|
||||||
|
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
|
||||||
|
const mockUseSharePointFileHandling = jest.requireMock(
|
||||||
|
'~/hooks/Files/useSharePointFileHandling',
|
||||||
|
).default;
|
||||||
|
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
|
||||||
|
|
||||||
|
describe('AttachFileMenu', () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockHandleFileChange = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Default mock implementations
|
||||||
|
mockUseLocalize.mockReturnValue((key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
com_ui_upload_provider: 'Upload to Provider',
|
||||||
|
com_ui_upload_image_input: 'Upload Image',
|
||||||
|
com_ui_upload_ocr_text: 'Upload OCR Text',
|
||||||
|
com_ui_upload_file_search: 'Upload for File Search',
|
||||||
|
com_ui_upload_code_files: 'Upload Code Files',
|
||||||
|
com_sidepanel_attach_files: 'Attach Files',
|
||||||
|
com_files_upload_sharepoint: 'Upload from SharePoint',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
|
contextEnabled: false,
|
||||||
|
fileSearchEnabled: false,
|
||||||
|
codeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseGetAgentsConfig.mockReturnValue({
|
||||||
|
agentsConfig: {
|
||||||
|
capabilities: {
|
||||||
|
contextEnabled: false,
|
||||||
|
fileSearchEnabled: false,
|
||||||
|
codeEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseFileHandling.mockReturnValue({
|
||||||
|
handleFileChange: mockHandleFileChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseSharePointFileHandling.mockReturnValue({
|
||||||
|
handleSharePointFiles: jest.fn(),
|
||||||
|
isProcessing: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseGetStartupConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
sharePointFilePickerEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderAttachFileMenu = (props: any = {}) => {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RecoilRoot>
|
||||||
|
<AttachFileMenu conversationId="test-conversation" {...props} />
|
||||||
|
</RecoilRoot>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
it('should render the attachment button', () => {
|
||||||
|
renderAttachFileMenu();
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be disabled when disabled prop is true', () => {
|
||||||
|
renderAttachFileMenu({ disabled: true });
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be disabled when disabled prop is false', () => {
|
||||||
|
renderAttachFileMenu({ disabled: false });
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Provider Detection Fix - endpointType Priority', () => {
|
||||||
|
it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: 'litellm',
|
||||||
|
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// With the fix, should show "Upload to Provider" because endpointType is checked first
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: 'my-custom-gateway',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: 'my-custom-gateway',
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Upload Image when neither endpointType nor provider support documents', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: 'unsupported-provider',
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: 'unsupported-provider',
|
||||||
|
endpointType: 'unsupported-endpoint' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to currentProvider when endpointType is undefined', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: EModelEndpoint.openAI,
|
||||||
|
endpointType: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to currentProvider when endpointType is null', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: EModelEndpoint.anthropic,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: EModelEndpoint.anthropic,
|
||||||
|
endpointType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Supported Providers', () => {
|
||||||
|
const supportedProviders = [
|
||||||
|
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
|
||||||
|
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
|
||||||
|
{ name: 'Google', endpoint: EModelEndpoint.google },
|
||||||
|
{ name: 'Azure OpenAI', endpoint: EModelEndpoint.azureOpenAI },
|
||||||
|
{ name: 'Custom', endpoint: EModelEndpoint.custom },
|
||||||
|
];
|
||||||
|
|
||||||
|
supportedProviders.forEach(({ name, endpoint }) => {
|
||||||
|
it(`should show Upload to Provider for ${name}`, () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: endpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint,
|
||||||
|
endpointType: endpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Agent Capabilities', () => {
|
||||||
|
it('should show OCR Text option when context is enabled', () => {
|
||||||
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
|
contextEnabled: true,
|
||||||
|
fileSearchEnabled: false,
|
||||||
|
codeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show File Search option when enabled and allowed by agent', () => {
|
||||||
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
|
contextEnabled: false,
|
||||||
|
fileSearchEnabled: true,
|
||||||
|
codeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: true,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT show File Search when enabled but not allowed by agent', () => {
|
||||||
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
|
contextEnabled: false,
|
||||||
|
fileSearchEnabled: true,
|
||||||
|
codeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Code Files option when enabled and allowed by agent', () => {
|
||||||
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
|
contextEnabled: false,
|
||||||
|
fileSearchEnabled: false,
|
||||||
|
codeEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: true,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show all options when all capabilities are enabled', () => {
|
||||||
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
|
contextEnabled: true,
|
||||||
|
fileSearchEnabled: true,
|
||||||
|
codeEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: true,
|
||||||
|
codeAllowedByAgent: true,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SharePoint Integration', () => {
|
||||||
|
it('should show SharePoint option when enabled', () => {
|
||||||
|
mockUseGetStartupConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
sharePointFilePickerEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT show SharePoint option when disabled', () => {
|
||||||
|
mockUseGetStartupConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
sharePointFilePickerEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle undefined endpoint and provider gracefully', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: undefined,
|
||||||
|
endpointType: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Should show Upload Image as fallback
|
||||||
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null endpoint and provider gracefully', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: null,
|
||||||
|
endpointType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing agentId gracefully', () => {
|
||||||
|
renderAttachFileMenu({
|
||||||
|
agentId: undefined,
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string agentId', () => {
|
||||||
|
renderAttachFileMenu({
|
||||||
|
agentId: '',
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Google Provider Special Case', () => {
|
||||||
|
it('should use google_multimodal file type for Google provider', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: EModelEndpoint.google,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: EModelEndpoint.google,
|
||||||
|
endpointType: EModelEndpoint.google,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
const uploadProviderButton = screen.getByText('Upload to Provider');
|
||||||
|
expect(uploadProviderButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click the upload to provider option
|
||||||
|
fireEvent.click(uploadProviderButton);
|
||||||
|
|
||||||
|
// The file input should have been clicked (indirectly tested through the implementation)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use multimodal file type for non-Google providers', () => {
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: EModelEndpoint.openAI,
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
const uploadProviderButton = screen.getByText('Upload to Provider');
|
||||||
|
expect(uploadProviderButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(uploadProviderButton);
|
||||||
|
|
||||||
|
// Implementation detail - multimodal type is used
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Regression Tests', () => {
|
||||||
|
it('should not break the previous behavior for direct provider attachments', () => {
|
||||||
|
// When using a direct supported provider (not through a gateway)
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: EModelEndpoint.anthropic,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: EModelEndpoint.anthropic,
|
||||||
|
endpointType: EModelEndpoint.anthropic,
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain correct priority when both are supported', () => {
|
||||||
|
// Both endpointType and provider are supported, endpointType should be checked first
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: EModelEndpoint.google,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAttachFileMenu({
|
||||||
|
endpoint: EModelEndpoint.google,
|
||||||
|
endpointType: EModelEndpoint.openAI, // Different but both supported
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Should still work because endpointType (openAI) is supported
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
describe('DragDropModal - Provider Detection', () => {
|
||||||
|
describe('endpointType priority over currentProvider', () => {
|
||||||
|
it('should show upload option for LiteLLM with OpenAI endpointType', () => {
|
||||||
|
const currentProvider = 'litellm'; // NOT in documentSupportedProviders
|
||||||
|
const endpointType = EModelEndpoint.openAI; // IS in documentSupportedProviders
|
||||||
|
|
||||||
|
// With fix: endpointType checked
|
||||||
|
const withFix =
|
||||||
|
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||||
|
expect(withFix).toBe(true);
|
||||||
|
|
||||||
|
// Without fix: only currentProvider checked = false
|
||||||
|
const withoutFix = isDocumentSupportedProvider(currentProvider || endpointType);
|
||||||
|
expect(withoutFix).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show upload option for any custom gateway with OpenAI endpointType', () => {
|
||||||
|
const currentProvider = 'my-custom-gateway';
|
||||||
|
const endpointType = EModelEndpoint.openAI;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to currentProvider when endpointType is undefined', () => {
|
||||||
|
const currentProvider = EModelEndpoint.openAI;
|
||||||
|
const endpointType = undefined;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to currentProvider when endpointType is null', () => {
|
||||||
|
const currentProvider = EModelEndpoint.anthropic;
|
||||||
|
const endpointType = null;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isDocumentSupportedProvider(endpointType as any) ||
|
||||||
|
isDocumentSupportedProvider(currentProvider);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when neither provider supports documents', () => {
|
||||||
|
const currentProvider = 'unsupported-provider';
|
||||||
|
const endpointType = 'unsupported-endpoint' as any;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('supported providers', () => {
|
||||||
|
const supportedProviders = [
|
||||||
|
{ name: 'OpenAI', value: EModelEndpoint.openAI },
|
||||||
|
{ name: 'Anthropic', value: EModelEndpoint.anthropic },
|
||||||
|
{ name: 'Google', value: EModelEndpoint.google },
|
||||||
|
{ name: 'Azure OpenAI', value: EModelEndpoint.azureOpenAI },
|
||||||
|
{ name: 'Custom', value: EModelEndpoint.custom },
|
||||||
|
];
|
||||||
|
|
||||||
|
supportedProviders.forEach(({ name, value }) => {
|
||||||
|
it(`should recognize ${name} as supported`, () => {
|
||||||
|
expect(isDocumentSupportedProvider(value)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('real-world scenarios', () => {
|
||||||
|
it('should handle LiteLLM gateway pointing to OpenAI', () => {
|
||||||
|
const scenario = {
|
||||||
|
currentProvider: 'litellm',
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||||
|
isDocumentSupportedProvider(scenario.currentProvider),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle direct OpenAI connection', () => {
|
||||||
|
const scenario = {
|
||||||
|
currentProvider: EModelEndpoint.openAI,
|
||||||
|
endpointType: EModelEndpoint.openAI,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||||
|
isDocumentSupportedProvider(scenario.currentProvider),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unsupported custom endpoint without override', () => {
|
||||||
|
const scenario = {
|
||||||
|
currentProvider: 'my-unsupported-endpoint',
|
||||||
|
endpointType: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||||
|
isDocumentSupportedProvider(scenario.currentProvider),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('should handle agents endpoints with document supported providers', () => {
|
||||||
|
const scenario = {
|
||||||
|
currentProvider: EModelEndpoint.google,
|
||||||
|
endpointType: EModelEndpoint.agents,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||||
|
isDocumentSupportedProvider(scenario.currentProvider),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { FileSources } from 'librechat-data-provider';
|
||||||
|
import type { ExtendedFile } from '~/common';
|
||||||
|
import FileRow from '../FileRow';
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/data-provider', () => ({
|
||||||
|
useDeleteFilesMutation: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks/Files', () => ({
|
||||||
|
useFileDeletion: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
logger: {
|
||||||
|
log: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Image', () => {
|
||||||
|
return function MockImage({ url, progress, source }: any) {
|
||||||
|
return (
|
||||||
|
<div data-testid="mock-image">
|
||||||
|
<span data-testid="image-url">{url}</span>
|
||||||
|
<span data-testid="image-progress">{progress}</span>
|
||||||
|
<span data-testid="image-source">{source}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../FileContainer', () => {
|
||||||
|
return function MockFileContainer({ file }: any) {
|
||||||
|
return (
|
||||||
|
<div data-testid="mock-file-container">
|
||||||
|
<span data-testid="file-name">{file.filename}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
|
||||||
|
const mockUseDeleteFilesMutation = jest.requireMock('~/data-provider').useDeleteFilesMutation;
|
||||||
|
const mockUseFileDeletion = jest.requireMock('~/hooks/Files').useFileDeletion;
|
||||||
|
|
||||||
|
describe('FileRow', () => {
|
||||||
|
const mockSetFiles = jest.fn();
|
||||||
|
const mockSetFilesLoading = jest.fn();
|
||||||
|
const mockDeleteFile = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockUseLocalize.mockReturnValue((key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
com_ui_deleting_file: 'Deleting file...',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseDeleteFilesMutation.mockReturnValue({
|
||||||
|
mutateAsync: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseFileDeletion.mockReturnValue({
|
||||||
|
deleteFile: mockDeleteFile,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock ExtendedFile with sensible defaults
|
||||||
|
*/
|
||||||
|
const createMockFile = (overrides: Partial<ExtendedFile> = {}): ExtendedFile => ({
|
||||||
|
file_id: 'test-file-id',
|
||||||
|
type: 'image/png',
|
||||||
|
preview: 'blob:http://localhost:3080/preview-blob-url',
|
||||||
|
filepath: '/images/user123/test-file-id__image.png',
|
||||||
|
filename: 'test-image.png',
|
||||||
|
progress: 1,
|
||||||
|
size: 1024,
|
||||||
|
source: FileSources.local,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderFileRow = (files: Map<string, ExtendedFile>) => {
|
||||||
|
return render(
|
||||||
|
<FileRow files={files} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Image URL Selection Logic', () => {
|
||||||
|
it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'uploaded-file',
|
||||||
|
preview: 'blob:http://localhost:3080/temp-preview',
|
||||||
|
filepath: '/images/user123/uploaded-file__image.png',
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
|
||||||
|
expect(imageUrl).not.toContain('blob:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use preview when progress is less than 1 (uploading)', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'uploading-file',
|
||||||
|
preview: 'blob:http://localhost:3080/temp-preview',
|
||||||
|
filepath: undefined,
|
||||||
|
progress: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to filepath when preview is undefined and progress is less than 1', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'file-without-preview',
|
||||||
|
preview: undefined,
|
||||||
|
filepath: '/images/user123/file-without-preview__image.png',
|
||||||
|
progress: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'complete-file',
|
||||||
|
preview: 'blob:http://localhost:3080/old-blob',
|
||||||
|
filepath: '/images/user123/complete-file__image.png',
|
||||||
|
progress: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
expect(imageUrl).toBe('/images/user123/complete-file__image.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Progress States', () => {
|
||||||
|
it('should pass correct progress value during upload', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
progress: 0.65,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const progress = screen.getByTestId('image-progress').textContent;
|
||||||
|
expect(progress).toBe('0.65');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass progress value of 1 when upload is complete', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const progress = screen.getByTestId('image-progress').textContent;
|
||||||
|
expect(progress).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Source', () => {
|
||||||
|
it('should pass local source to Image component', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
source: FileSources.local,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const source = screen.getByTestId('image-source').textContent;
|
||||||
|
expect(source).toBe(FileSources.local);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass openai source to Image component', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
source: FileSources.openai,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const source = screen.getByTestId('image-source').textContent;
|
||||||
|
expect(source).toBe(FileSources.openai);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Type Detection', () => {
|
||||||
|
it('should render Image component for image files', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('mock-image')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('mock-file-container')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render FileContainer for non-image files', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
type: 'application/pdf',
|
||||||
|
filename: 'document.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('mock-file-container')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('mock-image')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Files', () => {
|
||||||
|
it('should render multiple image files with correct URLs based on their progress', () => {
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
|
||||||
|
const uploadingFile = createMockFile({
|
||||||
|
file_id: 'file-1',
|
||||||
|
preview: 'blob:http://localhost:3080/preview-1',
|
||||||
|
filepath: undefined,
|
||||||
|
progress: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedFile = createMockFile({
|
||||||
|
file_id: 'file-2',
|
||||||
|
preview: 'blob:http://localhost:3080/preview-2',
|
||||||
|
filepath: '/images/user123/file-2__image.png',
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
filesMap.set(uploadingFile.file_id, uploadingFile);
|
||||||
|
filesMap.set(completedFile.file_id, completedFile);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const images = screen.getAllByTestId('mock-image');
|
||||||
|
expect(images).toHaveLength(2);
|
||||||
|
|
||||||
|
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
|
||||||
|
expect(urls).toContain('blob:http://localhost:3080/preview-1');
|
||||||
|
expect(urls).toContain('/images/user123/file-2__image.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate files with the same file_id', () => {
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
|
||||||
|
const file1 = createMockFile({ file_id: 'duplicate-id' });
|
||||||
|
const file2 = createMockFile({ file_id: 'duplicate-id' });
|
||||||
|
|
||||||
|
filesMap.set('key-1', file1);
|
||||||
|
filesMap.set('key-2', file2);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const images = screen.getAllByTestId('mock-image');
|
||||||
|
expect(images).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty State', () => {
|
||||||
|
it('should render nothing when files map is empty', () => {
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
|
||||||
|
const { container } = renderFileRow(filesMap);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render nothing when files is undefined', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FileRow files={undefined} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Regression: Blob URL Bug Fix', () => {
|
||||||
|
it('should NOT use revoked blob URL after upload completes', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'regression-test',
|
||||||
|
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
|
||||||
|
filepath:
|
||||||
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
|
||||||
|
expect(imageUrl).not.toContain('blob:');
|
||||||
|
expect(imageUrl).toBe(
|
||||||
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,12 @@ export const genAzureEndpoint = ({
|
||||||
azureOpenAIApiInstanceName: string;
|
azureOpenAIApiInstanceName: string;
|
||||||
azureOpenAIApiDeploymentName: string;
|
azureOpenAIApiDeploymentName: string;
|
||||||
}): string => {
|
}): string => {
|
||||||
|
// Support both old (.openai.azure.com) and new (.cognitiveservices.azure.com) endpoint formats
|
||||||
|
// If instanceName already includes a full domain, use it as-is
|
||||||
|
if (azureOpenAIApiInstanceName.includes('.azure.com')) {
|
||||||
|
return `https://${azureOpenAIApiInstanceName}/openai/deployments/${azureOpenAIApiDeploymentName}`;
|
||||||
|
}
|
||||||
|
// Legacy format for backward compatibility
|
||||||
return `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${azureOpenAIApiDeploymentName}`;
|
return `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${azureOpenAIApiDeploymentName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue