mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🧵 fix: Retry Shared Links After Message Persistence Gaps (#15306)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: Retry shared links after message persistence gaps * chore: Sort share test imports * fix: Address share review feedback * fix: Normalize hydrated share target
This commit is contained in:
parent
c06b09c945
commit
04a7577821
14 changed files with 594 additions and 79 deletions
|
|
@ -5,6 +5,7 @@ const mongoose = require('mongoose');
|
|||
const mockGetSharedLinkExpiration = jest.fn();
|
||||
const mockGrantCreationPermissions = jest.fn();
|
||||
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
|
||||
const mockRecordShareLinkRejection = jest.fn();
|
||||
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
|
||||
const mockSharedLinkConfigMiddleware = jest.fn((_req, _res, next) => next());
|
||||
let mockShareTenantId;
|
||||
|
|
@ -103,6 +104,8 @@ jest.mock('@librechat/api', () => ({
|
|||
(...args) =>
|
||||
mockGetSharedLangfuseSessionUrl(...args),
|
||||
),
|
||||
recordShareLinkRejection: (...args) => mockRecordShareLinkRejection(...args),
|
||||
traceIdForMessage: (messageId) => `trace-${messageId}`,
|
||||
isContentFilterError: jest.fn(
|
||||
(error) =>
|
||||
error?.code === 'content_filter_block' || error?.code === 'content_filter_uninspectable',
|
||||
|
|
@ -113,7 +116,10 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
logger: { error: jest.fn(), warn: jest.fn() },
|
||||
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
|
||||
runAsSystem: jest.fn((fn) => fn()),
|
||||
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
|
||||
tenantStorage: {
|
||||
getStore: jest.fn(() => ({ requestId: 'request-123' })),
|
||||
run: jest.fn((_ctx, fn) => fn()),
|
||||
},
|
||||
SYSTEM_TENANT_ID: '__SYSTEM__',
|
||||
SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
|
||||
}));
|
||||
|
|
@ -832,7 +838,31 @@ describe('share routes', () => {
|
|||
const response = await request(buildApp()).post('/api/share/convo-123').send({});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body).toEqual({ message: 'Share already exists' });
|
||||
expect(response.body).toEqual({ message: 'Share already exists', code: 'SHARE_EXISTS' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['TARGET_MESSAGE_NOT_FOUND', 'Target message not found', 'trace-msg-123'],
|
||||
['NO_MESSAGES', 'No messages to share', 'trace-msg-123'],
|
||||
])('returns and records the %s create rejection', async (code, message, traceId) => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockRejectedValue(Object.assign(new Error(message), { code }));
|
||||
|
||||
const response = await request(buildApp())
|
||||
.post('/api/share/convo-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ message, code });
|
||||
expect(mockRecordShareLinkRejection).toHaveBeenCalledWith('create', code);
|
||||
expect(logger.warn).toHaveBeenCalledWith('[share] Shared link publication rejected', {
|
||||
event: 'share_link_rejected',
|
||||
operation: 'create',
|
||||
code,
|
||||
request_id: 'request-123',
|
||||
trace_id: traceId,
|
||||
});
|
||||
expect(logger.error).not.toHaveBeenCalledWith('Error creating shared link:', expect.anything());
|
||||
});
|
||||
|
||||
it('returns a raw-free 400 when the exact create snapshot fails policy preflight', async () => {
|
||||
|
|
@ -1332,7 +1362,35 @@ describe('share routes', () => {
|
|||
const response = await request(buildApp()).patch('/api/share/share-123').send({});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'Share not found' });
|
||||
expect(response.body).toEqual({ message: 'Share not found', code: 'SHARE_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('returns and records a missing-tail update rejection', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
updateSharedLink.mockRejectedValue(
|
||||
Object.assign(new Error('Target message not found'), {
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await request(buildApp())
|
||||
.patch('/api/share/share-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Target message not found',
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
});
|
||||
expect(mockRecordShareLinkRejection).toHaveBeenCalledWith('update', 'TARGET_MESSAGE_NOT_FOUND');
|
||||
expect(logger.warn).toHaveBeenCalledWith('[share] Shared link publication rejected', {
|
||||
event: 'share_link_rejected',
|
||||
operation: 'update',
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
request_id: 'request-123',
|
||||
trace_id: 'trace-msg-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows deleting existing shares without CREATE permission gate', async () => {
|
||||
|
|
@ -1466,7 +1524,10 @@ describe('share fork route', () => {
|
|||
.send({ targetMessageIndex: 3, shareRevision: '2026-01-01T00:00:00.000Z' });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body).toEqual({ message: 'Shared link was updated' });
|
||||
expect(response.body).toEqual({
|
||||
message: 'Shared link was updated',
|
||||
code: 'SHARE_REVISION_MISMATCH',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ const {
|
|||
MAX_SHARED_LINK_SEARCH_LENGTH,
|
||||
createSharedLinkConfigMiddleware,
|
||||
createSharedLangfuseSessionResolver,
|
||||
recordShareLinkRejection,
|
||||
traceIdForMessage,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
logger,
|
||||
|
|
@ -71,10 +73,30 @@ const SHARE_SERVICE_ERROR_STATUS = {
|
|||
SHARE_REVISION_MISMATCH: 409,
|
||||
};
|
||||
|
||||
const sendShareServiceError = (res, error, fallbackMessage) => {
|
||||
const OBSERVABLE_SHARE_REJECTIONS = new Set(['TARGET_MESSAGE_NOT_FOUND', 'NO_MESSAGES']);
|
||||
|
||||
const sendShareServiceError = (req, res, error, fallbackMessage, operation) => {
|
||||
const status = SHARE_SERVICE_ERROR_STATUS[error?.code] ?? 500;
|
||||
const message = status === 500 ? fallbackMessage : error.message;
|
||||
return res.status(status).json({ message });
|
||||
const code = status === 500 ? undefined : error.code;
|
||||
|
||||
if (OBSERVABLE_SHARE_REJECTIONS.has(code)) {
|
||||
const targetMessageId = req.body?.targetMessageId;
|
||||
const requestId = tenantStorage.getStore()?.requestId ?? req.requestId;
|
||||
const traceId =
|
||||
typeof targetMessageId === 'string' ? traceIdForMessage(targetMessageId) : undefined;
|
||||
|
||||
recordShareLinkRejection(operation, code);
|
||||
logger.warn('[share] Shared link publication rejected', {
|
||||
event: 'share_link_rejected',
|
||||
operation,
|
||||
code,
|
||||
...(requestId && { request_id: requestId }),
|
||||
...(traceId && { trace_id: traceId }),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(status).json({ message, ...(code && { code }) });
|
||||
};
|
||||
|
||||
const checkSharedLinksAccess = generateCheckAccess({
|
||||
|
|
@ -417,7 +439,7 @@ if (allowSharedLinks) {
|
|||
if (error?.code !== 'SHARE_REVISION_MISMATCH') {
|
||||
logger.error('Error forking shared conversation:', error);
|
||||
}
|
||||
return sendShareServiceError(res, error, 'Error forking shared conversation');
|
||||
return sendShareServiceError(req, res, error, 'Error forking shared conversation', 'fork');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -649,8 +671,10 @@ router.post(
|
|||
if (isContentFilterError(error)) {
|
||||
return res.status(error.statusCode).json(error.body);
|
||||
}
|
||||
logger.error('Error creating shared link:', error);
|
||||
return sendShareServiceError(res, error, 'Error creating shared link');
|
||||
if (!OBSERVABLE_SHARE_REJECTIONS.has(error?.code)) {
|
||||
logger.error('Error creating shared link:', error);
|
||||
}
|
||||
return sendShareServiceError(req, res, error, 'Error creating shared link', 'create');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -720,8 +744,10 @@ router.patch(
|
|||
if (isContentFilterError(error)) {
|
||||
return res.status(error.statusCode).json(error.body);
|
||||
}
|
||||
logger.error('Error updating shared link:', error);
|
||||
return sendShareServiceError(res, error, 'Error updating shared link');
|
||||
if (!OBSERVABLE_SHARE_REJECTIONS.has(error?.code)) {
|
||||
logger.error('Error updating shared link:', error);
|
||||
}
|
||||
return sendShareServiceError(req, res, error, 'Error updating shared link', 'update');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
ESide,
|
||||
|
|
@ -14,7 +16,7 @@ import {
|
|||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import { useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
|
||||
import { useGetLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
|
||||
import SharedLinkCopyButton from './SharedLinkCopyButton';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import SharedLinkButton from './SharedLinkButton';
|
||||
|
|
@ -22,6 +24,12 @@ import { buildShareLinkUrl } from '~/utils';
|
|||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
type ShareTargetErrorCode = 'TARGET_MESSAGE_NOT_FOUND' | 'NO_MESSAGES';
|
||||
|
||||
const createShareTargetError = (
|
||||
code: ShareTargetErrorCode,
|
||||
): Error & { code: ShareTargetErrorCode } => Object.assign(new Error(code), { code });
|
||||
|
||||
export default function ShareButton({
|
||||
conversationId,
|
||||
open,
|
||||
|
|
@ -38,16 +46,42 @@ export default function ShareButton({
|
|||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const canSnapshotFiles = startupConfig?.sharedLinksSnapshotFilesEnabled === true;
|
||||
const queryClient = useQueryClient();
|
||||
const [showQR, setShowQR] = useState(true);
|
||||
const [sharedLink, setSharedLink] = useState('');
|
||||
const [snapshotFiles, setSnapshotFiles] = useState(true);
|
||||
const shareFilesSwitchRef = React.useRef<HTMLButtonElement>(null);
|
||||
const activeConversationId = useRecoilValue(store.conversationIdByIndex(0));
|
||||
const activeLatestMessageId = useLatestMessageId(0);
|
||||
const getActiveLatestMessage = useGetLatestMessage(0);
|
||||
/** `useLatestMessageId` resolves the active pane's branch tail, so it only describes
|
||||
* this dialog's conversation when the two match. Sharing another conversation from
|
||||
* the list sends no target, which shares it in full instead of a foreign message. */
|
||||
const latestMessageId = activeConversationId === conversationId ? activeLatestMessageId : null;
|
||||
const isActiveConversation = activeConversationId === conversationId;
|
||||
const latestMessageId = isActiveConversation ? activeLatestMessageId : null;
|
||||
const resolveTargetMessageId = useCallback(async (): Promise<string> => {
|
||||
let selectedMessageId = getActiveLatestMessage()?.messageId ?? latestMessageId;
|
||||
|
||||
if (!selectedMessageId) {
|
||||
await queryClient.fetchQuery(
|
||||
[QueryKeys.messages, conversationId],
|
||||
() => dataService.getMessagesByConvoId(conversationId),
|
||||
{ staleTime: 0 },
|
||||
);
|
||||
selectedMessageId = getActiveLatestMessage()?.messageId ?? null;
|
||||
}
|
||||
|
||||
if (!selectedMessageId) {
|
||||
throw createShareTargetError('NO_MESSAGES');
|
||||
}
|
||||
|
||||
const persistedMessages = await dataService.getMessageById(conversationId, selectedMessageId);
|
||||
if (!persistedMessages.some((message) => message.messageId === selectedMessageId)) {
|
||||
throw createShareTargetError('TARGET_MESSAGE_NOT_FOUND');
|
||||
}
|
||||
|
||||
return selectedMessageId;
|
||||
}, [conversationId, getActiveLatestMessage, latestMessageId, queryClient]);
|
||||
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
|
||||
const shareId = share?.shareId ?? '';
|
||||
|
||||
|
|
@ -74,6 +108,7 @@ export default function ShareButton({
|
|||
share={share}
|
||||
conversationId={conversationId}
|
||||
targetMessageId={latestMessageId ?? undefined}
|
||||
resolveTargetMessageId={isActiveConversation ? resolveTargetMessageId : undefined}
|
||||
showQR={showQR}
|
||||
setShowQR={setShowQR}
|
||||
sharedLink={sharedLink}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,51 @@ import { useHasAccess, useResourcePermissions, useLocalize } from '~/hooks';
|
|||
import { NotificationSeverity } from '~/common';
|
||||
import { buildShareLinkUrl } from '~/utils';
|
||||
|
||||
type SharePublicationErrorCode = 'TARGET_MESSAGE_NOT_FOUND' | 'NO_MESSAGES';
|
||||
|
||||
const getSharePublicationErrorCode = (error: unknown): SharePublicationErrorCode | undefined => {
|
||||
if (error == null || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const directCode = 'code' in error ? error.code : undefined;
|
||||
if (directCode === 'TARGET_MESSAGE_NOT_FOUND' || directCode === 'NO_MESSAGES') {
|
||||
return directCode;
|
||||
}
|
||||
|
||||
if (!('response' in error) || error.response == null || typeof error.response !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const data = 'data' in error.response ? error.response.data : undefined;
|
||||
if (data == null || typeof data !== 'object' || !('code' in data)) {
|
||||
return undefined;
|
||||
}
|
||||
return data.code === 'TARGET_MESSAGE_NOT_FOUND' || data.code === 'NO_MESSAGES'
|
||||
? data.code
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const publishWithTailRetry = async <T,>(
|
||||
resolveTargetMessageId: () => Promise<string | undefined>,
|
||||
publish: (targetMessageId?: string) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await publish(await resolveTargetMessageId());
|
||||
} catch (error) {
|
||||
const code = getSharePublicationErrorCode(error);
|
||||
if (code !== 'TARGET_MESSAGE_NOT_FOUND' && code !== 'NO_MESSAGES') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return publish(await resolveTargetMessageId());
|
||||
};
|
||||
|
||||
export default function SharedLinkButton({
|
||||
share,
|
||||
conversationId,
|
||||
targetMessageId,
|
||||
resolveTargetMessageId,
|
||||
showQR,
|
||||
setShowQR,
|
||||
sharedLink,
|
||||
|
|
@ -45,6 +86,7 @@ export default function SharedLinkButton({
|
|||
share: TSharedLinkGetResponse | undefined;
|
||||
conversationId: string;
|
||||
targetMessageId?: string;
|
||||
resolveTargetMessageId?: () => Promise<string>;
|
||||
showQR: boolean;
|
||||
setShowQR: (showQR: boolean) => void;
|
||||
sharedLink: string;
|
||||
|
|
@ -58,6 +100,7 @@ export default function SharedLinkButton({
|
|||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||
const [refreshAnimationId, setRefreshAnimationId] = useState(0);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [canNativeShare, setCanNativeShare] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const shareId = share?.shareId ?? '';
|
||||
|
|
@ -67,25 +110,9 @@ export default function SharedLinkButton({
|
|||
setCanNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation();
|
||||
|
||||
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation({
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation();
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: () => {
|
||||
|
|
@ -110,13 +137,34 @@ export default function SharedLinkButton({
|
|||
|
||||
const generateShareLink = (shareId: string) => buildShareLinkUrl(shareId);
|
||||
|
||||
const showPublicationError = (error: unknown) => {
|
||||
const code = getSharePublicationErrorCode(error);
|
||||
let message = localize('com_ui_share_error');
|
||||
if (code === 'TARGET_MESSAGE_NOT_FOUND') {
|
||||
message = localize('com_ui_share_target_not_saved');
|
||||
} else if (code === 'NO_MESSAGES') {
|
||||
message = localize('com_ui_share_no_messages');
|
||||
}
|
||||
showToast({
|
||||
message,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
};
|
||||
|
||||
const resolvePublicationTarget = () =>
|
||||
resolveTargetMessageId?.() ?? Promise.resolve(targetMessageId);
|
||||
|
||||
const updateSharedLink = async () => {
|
||||
if (!shareId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles });
|
||||
const updateShare = await publishWithTailRetry(resolvePublicationTarget, (resolvedTargetId) =>
|
||||
mutateAsync({ shareId, targetMessageId: resolvedTargetId, snapshotFiles }),
|
||||
);
|
||||
setRefreshAnimationId((animationId) => animationId + 1);
|
||||
setSharedLink(generateShareLink(updateShare.shareId));
|
||||
setShowUpdateDialog(false);
|
||||
|
|
@ -126,15 +174,24 @@ export default function SharedLinkButton({
|
|||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to update shared link:', error);
|
||||
showPublicationError(error);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createShareLink = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const share = await mutate({ conversationId, targetMessageId, snapshotFiles });
|
||||
const share = await publishWithTailRetry(resolvePublicationTarget, (resolvedTargetId) =>
|
||||
mutate({ conversationId, targetMessageId: resolvedTargetId, snapshotFiles }),
|
||||
);
|
||||
setSharedLink(generateShareLink(share.shareId));
|
||||
} catch (error) {
|
||||
console.error('Failed to create shared link:', error);
|
||||
showPublicationError(error);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -207,13 +264,13 @@ export default function SharedLinkButton({
|
|||
{!shareId && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isCreateLoading}
|
||||
disabled={isCreateLoading || isPublishing}
|
||||
variant="submit"
|
||||
onClick={createShareLink}
|
||||
className="ml-auto min-w-28"
|
||||
>
|
||||
{!isCreateLoading && localize('com_ui_create_link')}
|
||||
{isCreateLoading && <Spinner className="size-4" />}
|
||||
{!isCreateLoading && !isPublishing && localize('com_ui_create_link')}
|
||||
{(isCreateLoading || isPublishing) && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{shareId && (
|
||||
|
|
@ -276,7 +333,7 @@ export default function SharedLinkButton({
|
|||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 sm:size-10"
|
||||
disabled={isUpdateLoading}
|
||||
disabled={isUpdateLoading || isPublishing}
|
||||
aria-label={localize('com_ui_update_shared_link')}
|
||||
>
|
||||
<RotateCw
|
||||
|
|
@ -351,9 +408,9 @@ export default function SharedLinkButton({
|
|||
type="button"
|
||||
variant="submit"
|
||||
onClick={updateSharedLink}
|
||||
disabled={isUpdateLoading}
|
||||
disabled={isUpdateLoading || isPublishing}
|
||||
>
|
||||
{isUpdateLoading && <Spinner className="size-4" />}
|
||||
{(isUpdateLoading || isPublishing) && <Spinner className="size-4" />}
|
||||
{localize('com_ui_update_shared_link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { MutableSnapshot } from 'recoil';
|
||||
import ShareButton from '../ShareButton';
|
||||
|
|
@ -17,6 +18,22 @@ let mockShare: {
|
|||
};
|
||||
const mockCopyLink = jest.fn(() => true);
|
||||
const mockAnnouncePolite = jest.fn();
|
||||
const mockGetMessagesByConvoId = jest.fn();
|
||||
const mockGetMessageById = jest.fn();
|
||||
let mockResolveTargetMessageId: (() => Promise<string>) | undefined;
|
||||
let mockLatestMessageId: string | null = 'message-1';
|
||||
|
||||
jest.mock('librechat-data-provider', () => {
|
||||
const actual = jest.requireActual('librechat-data-provider');
|
||||
return {
|
||||
...actual,
|
||||
dataService: {
|
||||
...actual.dataService,
|
||||
getMessagesByConvoId: (...args: unknown[]) => mockGetMessagesByConvoId(...args),
|
||||
getMessageById: (...args: unknown[]) => mockGetMessageById(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useGetSharedLinkQuery: () => ({ data: mockShare, isLoading: false }),
|
||||
|
|
@ -27,7 +44,9 @@ jest.mock('~/data-provider', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks/Messages/useLatestMessage', () => ({
|
||||
useLatestMessageId: () => 'message-1',
|
||||
useLatestMessageId: () => mockLatestMessageId,
|
||||
useGetLatestMessage: () => () =>
|
||||
mockLatestMessageId == null ? null : { messageId: mockLatestMessageId },
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
|
|
@ -46,38 +65,51 @@ jest.mock('../SharedLinkButton', () => ({
|
|||
setShowQR,
|
||||
snapshotFiles,
|
||||
targetMessageId,
|
||||
resolveTargetMessageId,
|
||||
}: {
|
||||
showQR: boolean;
|
||||
setShowQR: (show: boolean) => void;
|
||||
snapshotFiles?: boolean;
|
||||
targetMessageId?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="share-actions"
|
||||
data-snapshot-files={String(snapshotFiles)}
|
||||
data-target-message-id={String(targetMessageId)}
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
>
|
||||
{showQR ? 'com_ui_hide_qr' : 'com_ui_show_qr'}
|
||||
</button>
|
||||
),
|
||||
resolveTargetMessageId?: () => Promise<string>;
|
||||
}) => {
|
||||
mockResolveTargetMessageId = resolveTargetMessageId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="share-actions"
|
||||
data-snapshot-files={String(snapshotFiles)}
|
||||
data-target-message-id={String(targetMessageId)}
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
>
|
||||
{showQR ? 'com_ui_hide_qr' : 'com_ui_show_qr'}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const ACTIVE_CONVERSATION_ID = 'conversation-1';
|
||||
|
||||
const renderShareButton = (conversationId = ACTIVE_CONVERSATION_ID) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const initializeState = ({ set }: MutableSnapshot) => {
|
||||
set(store.conversationByIndex(0), {
|
||||
conversationId: ACTIVE_CONVERSATION_ID,
|
||||
} as never);
|
||||
};
|
||||
|
||||
return render(
|
||||
<RecoilRoot initializeState={initializeState}>
|
||||
<ShareButton conversationId={conversationId} open={true} onOpenChange={jest.fn()} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
return {
|
||||
queryClient,
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot initializeState={initializeState}>
|
||||
<ShareButton conversationId={conversationId} open={true} onOpenChange={jest.fn()} />
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
describe('ShareButton', () => {
|
||||
|
|
@ -90,6 +122,10 @@ describe('ShareButton', () => {
|
|||
mockCopyLink.mockClear();
|
||||
mockCopyLink.mockReturnValue(true);
|
||||
mockAnnouncePolite.mockClear();
|
||||
mockGetMessagesByConvoId.mockReset();
|
||||
mockGetMessageById.mockReset();
|
||||
mockLatestMessageId = 'message-1';
|
||||
mockResolveTargetMessageId = undefined;
|
||||
});
|
||||
|
||||
it('centers the active QR code and keeps details behind inline info controls', () => {
|
||||
|
|
@ -150,21 +186,23 @@ describe('ShareButton', () => {
|
|||
|
||||
it('resets the file choice and link when the dialog moves to another conversation', () => {
|
||||
mockShare = { success: true, shareId: 'share-1', snapshotFiles: false };
|
||||
const { rerender } = renderShareButton();
|
||||
const { rerender, queryClient } = renderShareButton();
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).not.toBeChecked();
|
||||
|
||||
mockShare = { success: true, shareId: null };
|
||||
rerender(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }: MutableSnapshot) => {
|
||||
set(store.conversationByIndex(0), {
|
||||
conversationId: ACTIVE_CONVERSATION_ID,
|
||||
} as never);
|
||||
}}
|
||||
>
|
||||
<ShareButton conversationId="conversation-2" open={true} onOpenChange={jest.fn()} />
|
||||
</RecoilRoot>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot
|
||||
initializeState={({ set }: MutableSnapshot) => {
|
||||
set(store.conversationByIndex(0), {
|
||||
conversationId: ACTIVE_CONVERSATION_ID,
|
||||
} as never);
|
||||
}}
|
||||
>
|
||||
<ShareButton conversationId="conversation-2" open={true} onOpenChange={jest.fn()} />
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).toBeChecked();
|
||||
|
|
@ -181,6 +219,52 @@ describe('ShareButton', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('verifies the selected branch tail with a bounded persisted-message read', async () => {
|
||||
mockGetMessageById.mockResolvedValue([{ messageId: 'message-1' }]);
|
||||
renderShareButton();
|
||||
|
||||
await expect(mockResolveTargetMessageId?.()).resolves.toBe('message-1');
|
||||
await expect(mockResolveTargetMessageId?.()).resolves.toBe('message-1');
|
||||
expect(mockGetMessageById).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetMessageById).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, 'message-1');
|
||||
expect(mockGetMessagesByConvoId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unsaved selected tail instead of dropping the branch target', async () => {
|
||||
mockGetMessageById.mockResolvedValue([]);
|
||||
renderShareButton();
|
||||
|
||||
await expect(mockResolveTargetMessageId?.()).rejects.toMatchObject({
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates the message cache before deciding an initially unloaded chat is empty', async () => {
|
||||
mockLatestMessageId = null;
|
||||
mockGetMessagesByConvoId.mockImplementation(async () => {
|
||||
mockLatestMessageId = 'persisted-message';
|
||||
return [{ messageId: 'persisted-message' }];
|
||||
});
|
||||
mockGetMessageById.mockResolvedValue([{ messageId: 'persisted-message' }]);
|
||||
renderShareButton();
|
||||
|
||||
await expect(mockResolveTargetMessageId?.()).resolves.toBe('persisted-message');
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID);
|
||||
expect(mockGetMessageById).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, 'persisted-message');
|
||||
});
|
||||
|
||||
it('reports an empty chat only after checking persisted messages', async () => {
|
||||
mockLatestMessageId = null;
|
||||
mockGetMessagesByConvoId.mockResolvedValue([]);
|
||||
renderShareButton();
|
||||
|
||||
await expect(mockResolveTargetMessageId?.()).rejects.toMatchObject({
|
||||
code: 'NO_MESSAGES',
|
||||
});
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID);
|
||||
expect(mockGetMessageById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends no target message when sharing a conversation other than the open one', () => {
|
||||
renderShareButton('conversation-2');
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,108 @@ describe('SharedLinkButton', () => {
|
|||
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-old'));
|
||||
});
|
||||
|
||||
it('refetches the persisted tail and retries once when link creation misses it', async () => {
|
||||
const resolveTargetMessageId = jest.fn().mockResolvedValue('message-1');
|
||||
mockCreate
|
||||
.mockRejectedValueOnce({
|
||||
response: { data: { code: 'TARGET_MESSAGE_NOT_FOUND' } },
|
||||
})
|
||||
.mockResolvedValueOnce({ shareId: 'share-new' });
|
||||
const setSharedLink = jest.fn();
|
||||
renderActions({
|
||||
share: { success: false, shareId: null },
|
||||
resolveTargetMessageId,
|
||||
setSharedLink,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
|
||||
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(2));
|
||||
expect(resolveTargetMessageId).toHaveBeenCalledTimes(2);
|
||||
expect(mockCreate).toHaveBeenNthCalledWith(1, {
|
||||
conversationId: 'conversation-1',
|
||||
targetMessageId: 'message-1',
|
||||
snapshotFiles: true,
|
||||
});
|
||||
expect(mockCreate).toHaveBeenNthCalledWith(2, {
|
||||
conversationId: 'conversation-1',
|
||||
targetMessageId: 'message-1',
|
||||
snapshotFiles: true,
|
||||
});
|
||||
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-new'));
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not publish another branch when the selected tail is still unsaved', async () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const missingTail = Object.assign(new Error('missing tail'), {
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
});
|
||||
const resolveTargetMessageId = jest.fn().mockRejectedValue(missingTail);
|
||||
renderActions({
|
||||
share: { success: false, shareId: null },
|
||||
resolveTargetMessageId,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
|
||||
|
||||
await waitFor(() => expect(resolveTargetMessageId).toHaveBeenCalledTimes(2));
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_ui_share_target_not_saved',
|
||||
severity: 'error',
|
||||
showIcon: true,
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('retries a temporary empty read and publishes once persistence catches up', async () => {
|
||||
const noMessages = Object.assign(new Error('no messages'), { code: 'NO_MESSAGES' });
|
||||
const resolveTargetMessageId = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(noMessages)
|
||||
.mockResolvedValueOnce('message-1');
|
||||
mockCreate.mockResolvedValue({ shareId: 'share-new' });
|
||||
const setSharedLink = jest.fn();
|
||||
renderActions({
|
||||
share: { success: false, shareId: null },
|
||||
resolveTargetMessageId,
|
||||
setSharedLink,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
|
||||
|
||||
await waitFor(() => expect(resolveTargetMessageId).toHaveBeenCalledTimes(2));
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
conversationId: 'conversation-1',
|
||||
targetMessageId: 'message-1',
|
||||
snapshotFiles: true,
|
||||
});
|
||||
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-new'));
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a precise error when messages are still absent after the retry', async () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const noMessages = Object.assign(new Error('no messages'), { code: 'NO_MESSAGES' });
|
||||
const resolveTargetMessageId = jest.fn().mockRejectedValue(noMessages);
|
||||
renderActions({
|
||||
share: { success: false, shareId: null },
|
||||
resolveTargetMessageId,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
|
||||
|
||||
await waitFor(() => expect(resolveTargetMessageId).toHaveBeenCalledTimes(2));
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_ui_share_no_messages',
|
||||
severity: 'error',
|
||||
showIcon: true,
|
||||
});
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('does not fake success when updating the link fails', async () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockUpdate.mockRejectedValue(new Error('update failed'));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { ShareHeader } from './ShareView';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ShareHeader, SharedLinkUnavailable } from './ShareView';
|
||||
|
||||
const defaultProps = {
|
||||
title: 'Shared conversation',
|
||||
|
|
@ -35,3 +35,35 @@ describe('ShareHeader', () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharedLinkUnavailable', () => {
|
||||
it('lets the viewer retry a broken shared-link load', () => {
|
||||
const onRetry = jest.fn();
|
||||
|
||||
render(
|
||||
<SharedLinkUnavailable
|
||||
message="Shared link not found"
|
||||
retryLabel="Retry"
|
||||
isRetrying={false}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables retry while the shared link is refetching', () => {
|
||||
render(
|
||||
<SharedLinkUnavailable
|
||||
message="Shared link not found"
|
||||
retryLabel="Retry"
|
||||
isRetrying={true}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { buildTree } from 'librechat-data-provider';
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
|
||||
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
|
||||
import { CalendarDays, ExternalLink, Settings, MessageSquarePlus } from 'lucide-react';
|
||||
import { CalendarDays, ExternalLink, RefreshCw, Settings, MessageSquarePlus } from 'lucide-react';
|
||||
import {
|
||||
Spinner,
|
||||
Button,
|
||||
|
|
@ -43,7 +43,7 @@ function SharedView() {
|
|||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const { shareId } = useParams();
|
||||
const { data: config } = useGetSharedStartupConfig(shareId, { enabled: isAuthReady });
|
||||
const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? '', {
|
||||
const { data, isLoading, isFetching, refetch } = useGetSharedMessages(shareId ?? '', {
|
||||
enabled: isAuthReady,
|
||||
});
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
|
|
@ -203,9 +203,12 @@ function SharedView() {
|
|||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
{localize('com_ui_shared_link_not_found')}
|
||||
</div>
|
||||
<SharedLinkUnavailable
|
||||
message={localize('com_ui_shared_link_not_found')}
|
||||
retryLabel={localize('com_ui_retry')}
|
||||
isRetrying={isFetching}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +253,32 @@ function SharedView() {
|
|||
);
|
||||
}
|
||||
|
||||
export function SharedLinkUnavailable({
|
||||
message,
|
||||
retryLabel,
|
||||
isRetrying,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
retryLabel: string;
|
||||
isRetrying: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<p>{message}</p>
|
||||
<Button type="button" variant="outline" onClick={onRetry} disabled={isRetrying}>
|
||||
{isRetrying ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareTitle({ title }: { title?: string }) {
|
||||
if (title == null || title === '') {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -2041,7 +2041,9 @@
|
|||
"com_ui_share_files_description": "Images and files in this conversation won't be visible to viewers unless this is enabled.",
|
||||
"com_ui_share_files_update_note": "Choose your setting, then select Update link. The same URL will include the latest messages and file choice.",
|
||||
"com_ui_share_link_to_chat": "Share link to chat",
|
||||
"com_ui_share_no_messages": "This chat has no saved messages to share yet.",
|
||||
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
|
||||
"com_ui_share_target_not_saved": "The latest message has not finished saving. Try sharing again in a moment.",
|
||||
"com_ui_share_update_message": "Your name and custom instructions stay private. Edits to shared messages appear right away; select Update link to include new messages without changing the URL.",
|
||||
"com_ui_share_var": "Share {{0}}",
|
||||
"com_ui_shared_link": "shared link",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
recordOpenIDUserLookup,
|
||||
recordRedisOperation,
|
||||
recordRumProxyRequest,
|
||||
recordShareLinkRejection,
|
||||
setGenerationJobsInFlight,
|
||||
} from './metrics';
|
||||
|
||||
|
|
@ -414,6 +415,28 @@ describe('createMetrics', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('tracks bounded shared-link rejection outcomes', async () => {
|
||||
const app = express();
|
||||
process.env.METRICS_SECRET = 'test-secret';
|
||||
const { metricsRouter } = createMetrics();
|
||||
app.use('/metrics', metricsRouter);
|
||||
|
||||
recordShareLinkRejection('create', 'TARGET_MESSAGE_NOT_FOUND');
|
||||
recordShareLinkRejection('update', 'NO_MESSAGES');
|
||||
|
||||
const response = await request(app)
|
||||
.get('/metrics')
|
||||
.set('Authorization', 'Bearer test-secret')
|
||||
.expect(200);
|
||||
|
||||
expect(response.text).toMatch(
|
||||
/share_link_rejections_total\{operation="create",code="TARGET_MESSAGE_NOT_FOUND"\} 1/,
|
||||
);
|
||||
expect(response.text).toMatch(
|
||||
/share_link_rejections_total\{operation="update",code="NO_MESSAGES"\} 1/,
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks mongoose query counts and latency by model and operation', async () => {
|
||||
class FakeQuery {
|
||||
model = { modelName: 'User' };
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ export type RumProxyResult =
|
|||
| 'collector_5xx'
|
||||
| 'collector_error'
|
||||
| 'collector_timeout';
|
||||
export type ShareLinkOperation = 'create' | 'update';
|
||||
export type ShareLinkRejectionCode = 'TARGET_MESSAGE_NOT_FOUND' | 'NO_MESSAGES';
|
||||
export type RedisClient = 'ioredis' | 'keyv';
|
||||
export type RedisOperationStatus = 'success' | 'error';
|
||||
|
||||
|
|
@ -235,6 +237,14 @@ let rumProxyMetrics: RumProxyMetrics = {
|
|||
recordRequest: () => undefined,
|
||||
};
|
||||
|
||||
type ShareLinkMetrics = {
|
||||
recordRejection: (operation: ShareLinkOperation, code: ShareLinkRejectionCode) => void;
|
||||
};
|
||||
|
||||
let shareLinkMetrics: ShareLinkMetrics = {
|
||||
recordRejection: () => undefined,
|
||||
};
|
||||
|
||||
type RedisOperationMetrics = {
|
||||
recordOperation: (
|
||||
client: RedisClient,
|
||||
|
|
@ -270,6 +280,9 @@ const resetMetricRecorders = (): void => {
|
|||
rumProxyMetrics = {
|
||||
recordRequest: () => undefined,
|
||||
};
|
||||
shareLinkMetrics = {
|
||||
recordRejection: () => undefined,
|
||||
};
|
||||
redisOperationMetrics = {
|
||||
recordOperation: () => undefined,
|
||||
};
|
||||
|
|
@ -328,6 +341,13 @@ export function recordRumProxyRequest(endpoint: RumProxyEndpoint, result: RumPro
|
|||
rumProxyMetrics.recordRequest(endpoint, result);
|
||||
}
|
||||
|
||||
export function recordShareLinkRejection(
|
||||
operation: ShareLinkOperation,
|
||||
code: ShareLinkRejectionCode,
|
||||
): void {
|
||||
shareLinkMetrics.recordRejection(operation, code);
|
||||
}
|
||||
|
||||
export function recordRedisOperation(
|
||||
client: RedisClient,
|
||||
useCase: string,
|
||||
|
|
@ -643,6 +663,13 @@ export function createMetrics(options: MetricsOptions = {}): PrometheusMetrics {
|
|||
registers: [registry],
|
||||
});
|
||||
|
||||
const shareLinkRejections = new Counter({
|
||||
name: 'share_link_rejections_total',
|
||||
help: 'Shared link publication rejections by operation and bounded domain code',
|
||||
labelNames: ['operation', 'code'] as const,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
const redisOperations = new Counter({
|
||||
name: 'redis_operations_total',
|
||||
help: 'Logical Redis operations by client, use case, operation, and status',
|
||||
|
|
@ -750,6 +777,10 @@ export function createMetrics(options: MetricsOptions = {}): PrometheusMetrics {
|
|||
recordRequest: (endpoint, result) => rumProxyRequests.inc({ endpoint, result }),
|
||||
};
|
||||
|
||||
shareLinkMetrics = {
|
||||
recordRejection: (operation, code) => shareLinkRejections.inc({ operation, code }),
|
||||
};
|
||||
|
||||
redisOperationMetrics = {
|
||||
recordOperation: (client, useCase, operation, status, durationSeconds) => {
|
||||
const labels = { client, use_case: useCase, operation, status };
|
||||
|
|
|
|||
|
|
@ -1002,6 +1002,10 @@ export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage
|
|||
return request.get(endpoints.messages({ conversationId }));
|
||||
}
|
||||
|
||||
export function getMessageById(conversationId: string, messageId: string): Promise<s.TMessage[]> {
|
||||
return request.get(endpoints.messages({ conversationId, messageId }));
|
||||
}
|
||||
|
||||
export function getParentSubagents(parentConversationId: string): Promise<t.ParentSubagentIndex> {
|
||||
return request.get(endpoints.parentSubagents(parentConversationId));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type ShareMethods,
|
||||
type SharedLinkContentSnapshot,
|
||||
} from './share';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
describe('Share Methods', () => {
|
||||
let mongoServer: MongoMemoryServer;
|
||||
|
|
@ -1521,6 +1522,30 @@ describe('Share Methods', () => {
|
|||
expect(await SharedLink.findOne({ shareId })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('does not error-log expected refresh target rejections', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
|
||||
await SharedLink.create({ shareId, conversationId, user: userId, messages: [] });
|
||||
await Message.create({
|
||||
messageId: `msg_${nanoid()}`,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Current message',
|
||||
isCreatedByUser: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
shareMethods.updateSharedLink(userId, shareId, 'missing-message'),
|
||||
).rejects.toMatchObject({ code: 'TARGET_MESSAGE_NOT_FOUND' });
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('should only update with messages from the same user', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const otherUserId = new mongoose.Types.ObjectId().toString();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class ShareServiceError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const EXPECTED_SHARE_REJECTION_CODES = new Set(['TARGET_MESSAGE_NOT_FOUND', 'NO_MESSAGES']);
|
||||
|
||||
type ShareOrder = Pick<t.ISharedLink, '_id' | 'createdAt'>;
|
||||
|
||||
const isEarlierShare = (candidate: ShareOrder, subject: ShareOrder): boolean => {
|
||||
|
|
@ -1368,11 +1370,13 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
if (preflightFailed) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('[updateSharedLink] Error updating shared link', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
user,
|
||||
shareId,
|
||||
});
|
||||
if (!(error instanceof ShareServiceError && EXPECTED_SHARE_REJECTION_CODES.has(error.code))) {
|
||||
logger.error('[updateSharedLink] Error updating shared link', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
user,
|
||||
shareId,
|
||||
});
|
||||
}
|
||||
throw new ShareServiceError(
|
||||
error instanceof ShareServiceError ? error.message : 'Error updating shared link',
|
||||
error instanceof ShareServiceError ? error.code : 'SHARE_UPDATE_ERROR',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue