You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ghost/apps/posts/test/unit/hooks/usePostNewsletterStats.test...

245 lines
7.8 KiB

/* eslint-disable @typescript-eslint/no-explicit-any */
import {beforeEach, describe, expect, it} from 'vitest';
import {createTestWrapper, mockData, mockServer} from '../../utils/msw-helpers';
import {renderHook, waitFor} from '@testing-library/react';
import {usePostNewsletterStats} from '@src/hooks/usePostNewsletterStats';
describe('usePostNewsletterStats', () => {
const testPostId = 'test-post-id';
beforeEach(() => {
mockServer.setup(); // Basic setup with defaults
});
it('calculates stats correctly from post email data', async () => {
const postWithEmailStats = mockData.post({
id: testPostId,
email: {
email_count: 1000,
opened_count: 300
},
count: {
clicks: 50
}
});
mockServer.setup({
posts: [postWithEmailStats]
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
expect(result.current.stats).toEqual({
sent: 1000,
opened: 300,
clicked: 50,
openedRate: 0.3, // 300/1000
clickedRate: 0.05 // 50/1000
});
});
});
it('returns zero stats when post has no email data', async () => {
const postWithoutEmail = mockData.post({
id: testPostId
// No email or count data
});
mockServer.setup({
posts: [postWithoutEmail]
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
expect(result.current.stats).toEqual({
sent: 0,
opened: 0,
clicked: 0,
openedRate: 0,
clickedRate: 0
});
});
});
it('calculates average newsletter performance correctly', async () => {
const newsletterStats = [
{post_id: 'post1', open_rate: 0.25, click_rate: 0.03},
{post_id: 'post2', open_rate: 0.35, click_rate: 0.07},
{post_id: 'post3', open_rate: 0.30, click_rate: 0.05}
];
mockServer.setup({
posts: [mockData.post({id: testPostId})],
newsletterBasicStats: newsletterStats,
newsletterClickStats: newsletterStats
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
// Average: (0.25 + 0.35 + 0.30) / 3 = 0.30
// Average: (0.03 + 0.07 + 0.05) / 3 = 0.05
expect(result.current.averageStats).toEqual({
openedRate: 0.30,
clickedRate: 0.05
});
});
});
it('prevents division by zero in rate calculations', async () => {
const postWithClicksButNoEmails = mockData.post({
id: testPostId,
email: {
email_count: 0,
opened_count: 5 // Impossible but testing edge case
},
count: {
clicks: 10
}
});
mockServer.setup({
posts: [postWithClicksButNoEmails]
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
expect(result.current.stats.openedRate).toBe(0);
expect(result.current.stats.clickedRate).toBe(0);
expect(Number.isNaN(result.current.stats.openedRate)).toBe(false);
expect(Number.isNaN(result.current.stats.clickedRate)).toBe(false);
});
});
it('handles missing newsletter comparison data gracefully', async () => {
mockServer.setup({
posts: [mockData.post({id: testPostId})],
newsletterBasicStats: [],
newsletterClickStats: []
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
expect(result.current.averageStats).toEqual({
openedRate: 0,
clickedRate: 0
});
});
});
it('provides top performing links sorted by click count', async () => {
const linksData = [
{
post_id: testPostId,
link: {link_id: 'link1', to: 'https://popular.com', from: 'post', edited: false},
count: {clicks: 25}
},
{
post_id: testPostId,
link: {link_id: 'link2', to: 'https://www.another.com', from: 'post', edited: false},
count: {clicks: 15}
}
];
mockServer.setup({
posts: [mockData.post({id: testPostId})],
links: linksData
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
// Should be sorted by click count (highest first) and URLs cleaned
expect(result.current.topLinks).toHaveLength(2);
expect(result.current.topLinks[0].count).toBe(25);
expect(result.current.topLinks[1].count).toBe(15);
// Verify URL cleaning and display formatting happens
expect(result.current.topLinks[0].link.title).toBe('popular.com');
expect(result.current.topLinks[1].link.title).toBe('another.com');
});
});
it('calculates precise rates with fractional results', async () => {
const postWithPrecisionChallenge = mockData.post({
id: testPostId,
email: {
email_count: 7,
opened_count: 2
},
count: {
clicks: 1
}
});
mockServer.setup({
posts: [postWithPrecisionChallenge]
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
// 2/7 = 0.2857142857142857... (JavaScript precision)
expect(result.current.stats.openedRate).toBeCloseTo(2 / 7, 10);
// 1/7 = 0.14285714285714285... (JavaScript precision)
expect(result.current.stats.clickedRate).toBeCloseTo(1 / 7, 10);
// Ensure calculations return valid numbers (not NaN or Infinity)
expect(Number.isFinite(result.current.stats.openedRate)).toBe(true);
expect(Number.isFinite(result.current.stats.clickedRate)).toBe(true);
});
});
it('handles enterprise scale numbers correctly', async () => {
const enterprisePost = mockData.post({
id: testPostId,
email: {
email_count: 1000000,
opened_count: 250000
},
count: {
clicks: 12500
}
});
mockServer.setup({
posts: [enterprisePost]
});
const {result} = renderHook(() => usePostNewsletterStats(testPostId), {
wrapper: createTestWrapper()
});
await waitFor(() => {
expect(result.current.stats).toEqual({
sent: 1000000,
opened: 250000,
clicked: 12500,
openedRate: 0.25, // 250000/1000000
clickedRate: 0.0125 // 12500/1000000
});
// Ensure calculations maintain precision at scale
expect(Number.isFinite(result.current.stats.openedRate)).toBe(true);
expect(Number.isFinite(result.current.stats.clickedRate)).toBe(true);
});
});
});