PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / shared / test / analytics-url.ts
jetpack / _inc / shared / test Last commit date
analytics-url.ts 1 week ago
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