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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/shared/src/hooks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ logEvent({
- Data that isn't the primary subject of the event
- Always JSON.stringify objects in extra

**For optional API fields in analytics payloads:**
- If backend types mark fields as optional, still emit the event with available fields
- Do not drop a whole item just because optional fields are missing
- Analytics/logging helpers must never throw inside mutation `onSuccess` paths
- Prefer explicit fallback defaults over broad type assertions (e.g. `as Post`)

### useLogEventOnce - One-Time Event Logging

Use `useLogEventOnce` for logging events that should fire exactly once (e.g., impressions, form open tracking). This hook follows React best practices by using refs to track logged state, avoiding `eslint-disable` comments for empty dependency arrays.
Expand Down
177 changes: 177 additions & 0 deletions packages/shared/src/hooks/squads/useSourceModerationList.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react';
import { mocked } from 'ts-jest/utils';
import {
PostModerationReason,
SourcePostModerationStatus,
squadApproveMutation,
squadRejectMutation,
} from '../../graphql/squads';
import type { SourcePostModeration } from '../../graphql/squads';
import { PostType } from '../../graphql/posts';
import { useSourceModerationList } from './useSourceModerationList';
import { useLazyModal } from '../useLazyModal';
import { usePrompt } from '../usePrompt';
import { useToastNotification } from '../useToastNotification';
import { useLogContext } from '../../contexts/LogContext';
import { useAuthContext } from '../../contexts/AuthContext';
import { LogEvent } from '../../lib/log';

jest.mock('../../graphql/squads', () => ({
...(jest.requireActual('../../graphql/squads') as Iterable<unknown>),
squadApproveMutation: jest.fn(),
squadRejectMutation: jest.fn(),
}));

jest.mock('../useLazyModal', () => ({
useLazyModal: jest.fn(),
}));

jest.mock('../usePrompt', () => ({
usePrompt: jest.fn(),
}));

jest.mock('../useToastNotification', () => ({
useToastNotification: jest.fn(),
}));

jest.mock('../../contexts/LogContext', () => ({
...(jest.requireActual('../../contexts/LogContext') as Iterable<unknown>),
useLogContext: jest.fn(),
}));

jest.mock('../../contexts/AuthContext', () => ({
...(jest.requireActual('../../contexts/AuthContext') as Iterable<unknown>),
useAuthContext: jest.fn(),
}));

describe('useSourceModerationList', () => {
const displayToast = jest.fn();
const openModal = jest.fn();
const closeModal = jest.fn();
const showPrompt = jest.fn();
const logEvent = jest.fn();
let client: QueryClient;

const wrapper = ({ children }: React.PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const createModerationItem = (
overrides: Partial<SourcePostModeration>,
): SourcePostModeration => ({
id: 'valid-post',
status: SourcePostModerationStatus.Pending,
...overrides,
});

beforeEach(() => {
client = new QueryClient();
jest.clearAllMocks();

mocked(useToastNotification).mockReturnValue({
displayToast,
} as ReturnType<typeof useToastNotification>);
mocked(useLazyModal).mockReturnValue({
openModal,
closeModal,
} as ReturnType<typeof useLazyModal>);
mocked(usePrompt).mockReturnValue({
showPrompt,
} as ReturnType<typeof usePrompt>);
mocked(useLogContext).mockReturnValue({
logEvent,
} as ReturnType<typeof useLogContext>);
mocked(useAuthContext).mockReturnValue({
user: { id: 'user-1' },
} as ReturnType<typeof useAuthContext>);
});

it('resolves approve flow and logs all posts including partial data', async () => {
mocked(squadApproveMutation).mockResolvedValue([
createModerationItem({
status: SourcePostModerationStatus.Approved,
type: 'article',
image: 'https://daily.dev/post.jpg',
}),
createModerationItem({
id: 'missing-image',
status: SourcePostModerationStatus.Approved,
type: 'article',
}),
]);

const invalidateQueriesSpy = jest.spyOn(client, 'invalidateQueries');
const { result } = renderHook(() => useSourceModerationList(), { wrapper });

await act(async () => {
await result.current.onApprove(['valid-post'], 'source-1');
});

expect(displayToast).toHaveBeenCalledWith('Post(s) approved successfully');
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledTimes(2);
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.ApprovePost,
target_id: 'valid-post',
}),
);
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.ApprovePost,
target_id: 'missing-image',
feed_item_image: '',
post_type: PostType.Article,
}),
);
});

it('resolves reject flow, closes modal, and logs all posts including partial data', async () => {
mocked(squadRejectMutation).mockResolvedValue([
createModerationItem({
status: SourcePostModerationStatus.Rejected,
type: 'article',
image: 'https://daily.dev/post.jpg',
}),
createModerationItem({
id: 'missing-type',
status: SourcePostModerationStatus.Rejected,
image: 'https://daily.dev/post-2.jpg',
}),
]);

const { result } = renderHook(() => useSourceModerationList(), { wrapper });

act(() => {
result.current.onReject('valid-post', 'source-1');
});

const modalConfig = openModal.mock.calls[0][0];

await act(async () => {
await modalConfig.props.onReport(
undefined,
PostModerationReason.Other,
'test note',
);
});

expect(displayToast).toHaveBeenCalledWith('Post(s) declined successfully');
expect(closeModal).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledTimes(2);
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.RejectPost,
target_id: 'valid-post',
}),
);
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.RejectPost,
target_id: 'missing-type',
post_type: PostType.Article,
}),
);
});
});
13 changes: 3 additions & 10 deletions packages/shared/src/hooks/squads/useSourceModerationList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { generateQueryKey, RequestKey } from '../../lib/query';
import { LogEvent } from '../../lib/log';
import { useLogContext } from '../../contexts/LogContext';
import { postLogEvent } from '../../lib/feed';
import { PostType } from '../../graphql/posts';
import type { Post } from '../../graphql/posts';
import { useAuthContext } from '../../contexts/AuthContext';

Expand Down Expand Up @@ -75,19 +76,11 @@ export interface UseSourceModerationList {

const getLogPostsFromModerationArray = (data: SourcePostModeration[]) => {
return data.map<Post>((item) => {
if (!item.type) {
throw new Error('Source post moderation type is required');
}

if (!item.image) {
throw new Error('Source post moderation image is required');
}

return {
id: item.id,
source: item.source,
type: item.type,
image: item.image,
type: item.type ?? PostType.Article,
image: item.image ?? '',
commentsPermalink: '',
author: item.createdBy,
createdAt: item.createdAt,
Expand Down