| 1 |
import { getAnalyticsUrl, hasAnalyticsDashboard } from '../analytics-url'; |
| 2 |
import type { AnalyticsView } from '../analytics-url'; |
| 3 |
|
| 4 |
const ADMIN_URL = 'https://example.com/wp-admin/'; |
| 5 |
const PAGE_SLUG = 'jetpack-premium-analytics-wp-admin'; |
| 6 |
|
| 7 |
/** |
| 8 |
* Seeds `window.JetpackScriptData`. Omitting `analytics` is a site where Stats |
| 9 |
* is still the analytics UI. |
| 10 |
* |
| 11 |
* Every case here pins an explicit timezone and explicit calendar dates, so no |
| 12 |
* assertion depends on the clock or on the machine's timezone. |
| 13 |
* |
| 14 |
* @param analytics - Overrides for the analytics payload, or undefined to omit it. |
| 15 |
*/ |
| 16 |
function seed( analytics?: Record< string, unknown > ) { |
| 17 |
window.JetpackScriptData = { |
| 18 |
site: { admin_url: ADMIN_URL }, |
| 19 |
...( analytics && { |
| 20 |
analytics: { |
| 21 |
enabled: true, |
| 22 |
page_slug: PAGE_SLUG, |
| 23 |
can_view: true, |
| 24 |
timezone: 'UTC', |
| 25 |
...analytics, |
| 26 |
}, |
| 27 |
} ), |
| 28 |
} as unknown as typeof window.JetpackScriptData; |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* The path and search the router ends up with. |
| 33 |
* |
| 34 |
* The dashboard keeps its whole path-and-search inside a single `p` param, so |
| 35 |
* the router decodes twice: once pulling `p` off the page URL, once parsing |
| 36 |
* `p`'s own search. This mirrors both steps. |
| 37 |
* |
| 38 |
* @param url - The URL to parse. |
| 39 |
* @return The decoded path and search, e.g. `/?section=traffic`. |
| 40 |
*/ |
| 41 |
function routerPath( url: string | null ): string | null { |
| 42 |
if ( ! url ) { |
| 43 |
return null; |
| 44 |
} |
| 45 |
|
| 46 |
const [ path, search ] = ( new URL( url ).searchParams.get( 'p' ) ?? '' ).split( '?' ); |
| 47 |
const params = [ ...new URLSearchParams( search ) ].map( ( [ k, v ] ) => `${ k }=${ v }` ); |
| 48 |
|
| 49 |
return params.length ? `${ path }?${ params.join( '&' ) }` : path; |
| 50 |
} |
| 51 |
|
| 52 |
afterEach( () => { |
| 53 |
delete window.JetpackScriptData; |
| 54 |
} ); |
| 55 |
|
| 56 |
describe( 'hasAnalyticsDashboard', () => { |
| 57 |
// Stays true without the capability: a user who fails the dashboard capability |
| 58 |
// fails the Stats page's too, so there is no useful fallback for them. |
| 59 |
it.each( [ |
| 60 |
[ 'the package announces itself', {}, true ], |
| 61 |
[ 'the user cannot open it', { can_view: false }, true ], |
| 62 |
[ 'the key is disabled', { enabled: false }, false ], |
| 63 |
[ 'the key is absent', undefined, false ], |
| 64 |
] )( 'is %s -> %s', ( _label, analytics, expected ) => { |
| 65 |
seed( analytics as Record< string, unknown > | undefined ); |
| 66 |
|
| 67 |
expect( hasAnalyticsDashboard() ).toBe( expected ); |
| 68 |
} ); |
| 69 |
} ); |
| 70 |
|
| 71 |
describe( 'getAnalyticsUrl', () => { |
| 72 |
beforeEach( () => seed( {} ) ); |
| 73 |
|
| 74 |
it( 'builds a dashboard URL on the dashboard page', () => { |
| 75 |
expect( getAnalyticsUrl( { view: 'dashboard' } ) ).toBe( |
| 76 |
`${ ADMIN_URL }admin.php?page=${ PAGE_SLUG }&p=%2F` |
| 77 |
); |
| 78 |
} ); |
| 79 |
|
| 80 |
it.each( [ |
| 81 |
[ { view: 'dashboard' }, '/' ], |
| 82 |
[ { view: 'post', id: 42 }, '/post/42' ], |
| 83 |
[ { view: 'dashboard', section: 'traffic' }, '/?section=traffic' ], |
| 84 |
[ { view: 'dashboard', section: 'insights' }, '/?section=insights' ], |
| 85 |
[ { view: 'dashboard', section: 'subscribers' }, '/?section=subscribers' ], |
| 86 |
[ { view: 'dashboard', section: 'store' }, '/?section=store' ], |
| 87 |
// The caller's neutral `traffic` is the route's `post-traffic`. |
| 88 |
[ { view: 'post', id: 9, section: 'traffic' }, '/post/9?section=post-traffic' ], |
| 89 |
[ { view: 'post', id: 9, section: 'email-opens' }, '/post/9?section=email-opens' ], |
| 90 |
[ { view: 'post', id: 9, section: 'email-clicks' }, '/post/9?section=email-clicks' ], |
| 91 |
// An unknown section resolves to the default tab anyway, so it is dropped |
| 92 |
// rather than left dead in a shareable URL. |
| 93 |
[ { view: 'dashboard', section: 'nope' }, '/' ], |
| 94 |
] as [ AnalyticsView, string ][] )( 'routes %j to %s', ( view, expected ) => { |
| 95 |
expect( routerPath( getAnalyticsUrl( view ) ) ).toBe( expected ); |
| 96 |
} ); |
| 97 |
|
| 98 |
it.each( [ 0, -1 ] )( 'returns null for post id %i', id => { |
| 99 |
expect( getAnalyticsUrl( { view: 'post', id } ) ).toBeNull(); |
| 100 |
} ); |
| 101 |
|
| 102 |
describe( 'date ranges', () => { |
| 103 |
it.each( [ |
| 104 |
[ 'UTC', '+00:00' ], |
| 105 |
[ 'America/New_York', '-04:00' ], |
| 106 |
[ '+05:30', '+05:30' ], |
| 107 |
] )( 'encodes both boundaries in %s', ( timezone, offset ) => { |
| 108 |
seed( { timezone } ); |
| 109 |
|
| 110 |
expect( |
| 111 |
routerPath( |
| 112 |
getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-01', to: '2026-07-31' } } ) |
| 113 |
) |
| 114 |
).toBe( `/?from=2026-07-01T00:00:00.000${ offset }&to=2026-07-31T23:59:59.999${ offset }` ); |
| 115 |
} ); |
| 116 |
|
| 117 |
// A day whose offset changes partway through gets a different offset at |
| 118 |
// each boundary. Both US transitions are covered: one day is 23 hours |
| 119 |
// long, the other 25. |
| 120 |
it.each( [ |
| 121 |
[ 'spring forward', '2026-03-08', '-05:00', '-04:00' ], |
| 122 |
[ 'fall back', '2026-11-01', '-04:00', '-05:00' ], |
| 123 |
] )( 'uses the offset in effect at each boundary across %s', ( _label, day, opens, closes ) => { |
| 124 |
seed( { timezone: 'America/New_York' } ); |
| 125 |
|
| 126 |
expect( |
| 127 |
routerPath( getAnalyticsUrl( { view: 'dashboard', range: { from: day, to: day } } ) ) |
| 128 |
).toBe( `/?from=${ day }T00:00:00.000${ opens }&to=${ day }T23:59:59.999${ closes }` ); |
| 129 |
} ); |
| 130 |
|
| 131 |
it( 'carries a range on the post view too', () => { |
| 132 |
expect( |
| 133 |
routerPath( |
| 134 |
getAnalyticsUrl( { |
| 135 |
view: 'post', |
| 136 |
id: 5, |
| 137 |
range: { from: '2026-01-01', to: '2026-01-01' }, |
| 138 |
} ) |
| 139 |
) |
| 140 |
).toBe( '/post/5?from=2026-01-01T00:00:00.000+00:00&to=2026-01-01T23:59:59.999+00:00' ); |
| 141 |
} ); |
| 142 |
|
| 143 |
// The route seeds an interval itself, and omitting `preset` keeps the |
| 144 |
// range custom rather than forcing a comparison nobody asked for. |
| 145 |
it( 'sets neither interval, preset nor comparison params', () => { |
| 146 |
const path = routerPath( |
| 147 |
getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-01', to: '2026-07-31' } } ) |
| 148 |
); |
| 149 |
|
| 150 |
expect( path ).not.toMatch( /interval=|preset=|comp=/ ); |
| 151 |
} ); |
| 152 |
|
| 153 |
// Half a range would silently widen the window, so an unusable one is |
| 154 |
// dropped whole — but the section survives. |
| 155 |
it.each( [ |
| 156 |
[ 'a malformed from', { from: '07/01/2026', to: '2026-07-31' }, 'UTC' ], |
| 157 |
[ 'a malformed to', { from: '2026-07-01', to: 'yesterday' }, 'UTC' ], |
| 158 |
[ 'an empty range', { from: '', to: '' }, 'UTC' ], |
| 159 |
[ 'an unusable timezone', { from: '2026-07-01', to: '2026-07-31' }, 'Not/AZone' ], |
| 160 |
] )( 'drops the range for %s', ( _label, range, timezone ) => { |
| 161 |
seed( { timezone } ); |
| 162 |
|
| 163 |
expect( |
| 164 |
routerPath( getAnalyticsUrl( { view: 'dashboard', section: 'traffic', range } ) ) |
| 165 |
).toBe( '/?section=traffic' ); |
| 166 |
} ); |
| 167 |
|
| 168 |
// The `+` of the offset must survive as `%252B`: one layer for `p`'s own |
| 169 |
// search, one for `p` itself. A single layer would decode to a space and |
| 170 |
// the date picker would reject the range. |
| 171 |
it( 'encodes the inner search twice, matching the two decodes the router does', () => { |
| 172 |
const raw = getAnalyticsUrl( { |
| 173 |
view: 'dashboard', |
| 174 |
range: { from: '2026-07-01', to: '2026-07-01' }, |
| 175 |
} ) as string; |
| 176 |
|
| 177 |
expect( raw ).toContain( '%252B00%253A00' ); |
| 178 |
expect( new URL( raw ).searchParams.get( 'p' ) ).toContain( '%2B00%3A00' ); |
| 179 |
} ); |
| 180 |
} ); |
| 181 |
|
| 182 |
it.each( [ |
| 183 |
[ 'the user cannot open the dashboard', { can_view: false } ], |
| 184 |
[ 'the dashboard is disabled', { enabled: false } ], |
| 185 |
[ 'the dashboard is not the analytics UI', undefined ], |
| 186 |
] )( 'returns null when %s', ( _label, analytics ) => { |
| 187 |
seed( analytics as Record< string, unknown > | undefined ); |
| 188 |
|
| 189 |
expect( getAnalyticsUrl( { view: 'dashboard' } ) ).toBeNull(); |
| 190 |
expect( getAnalyticsUrl( { view: 'post', id: 1, section: 'email-opens' } ) ).toBeNull(); |
| 191 |
} ); |
| 192 |
} ); |
| 193 |
|