PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / _inc / shared / test / analytics-url.ts

analytics-url.ts in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at _inc/shared/test/analytics-url.ts

193 lines 7.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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