analytics-url.ts
193 lines
| 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 |