PluginProbe
Extendify / 3.1.1
Extendify v3.1.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / unit / QuickEdit / lib / insights.test.js

insights.test.js in Extendify 3.1.1, at tests/unit/QuickEdit/lib/insights.test.js

137 lines 4.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Pins the no-PII payload contract per the plan's "no PII to backends"
2 // hard rule, plus the keepalive + swallow-errors posture and the
3 // short-circuits (missing event, missing INSIGHTS_HOST).
4
5 const mockFetch = jest.fn();
6
7 jest.mock('@constants', () => ({
8 INSIGHTS_HOST: 'https://insights.test',
9 AI_HOST: '',
10 PATTERNS_HOST: '',
11 IMAGES_HOST: '',
12 KB_HOST: '',
13 }));
14
15 jest.mock('@shared/lib/data', () => ({
16 reqDataBasics: {
17 partnerId: 'p1',
18 siteId: 's1',
19 version: '1.0.0',
20 homeUrl: 'https://my-site.test',
21 },
22 }));
23
24 beforeEach(() => {
25 jest.resetModules();
26 jest.clearAllMocks();
27 global.fetch = mockFetch;
28 mockFetch.mockReset();
29 mockFetch.mockReturnValue({ catch: () => {} });
30 });
31
32 afterEach(() => {
33 delete global.fetch;
34 });
35
36 describe('track — request shape', () => {
37 it('POSTs to INSIGHTS_HOST + /api/v1/quick-edit with JSON + Extendify headers + keepalive', async () => {
38 const { track } = await import('@quick-edit/lib/insights');
39 track('edit_mode_on', { source: 'admin-bar' });
40
41 expect(mockFetch).toHaveBeenCalledTimes(1);
42 const [url, init] = mockFetch.mock.calls[0];
43 expect(url).toBe('https://insights.test/api/v1/quick-edit');
44 expect(init.method).toBe('POST');
45 expect(init.keepalive).toBe(true);
46 expect(init.headers).toEqual({
47 'Content-type': 'application/json',
48 Accept: 'application/json',
49 'X-Extendify': 'true',
50 });
51 });
52 });
53
54 describe('track — payload shape (no PII)', () => {
55 it('includes only reqDataBasics + event + props + ts', async () => {
56 const { track } = await import('@quick-edit/lib/insights');
57 track('edit_mode_on', { source: 'admin-bar' });
58 const body = JSON.parse(mockFetch.mock.calls[0][1].body);
59
60 expect(Object.keys(body).sort()).toEqual(
61 [
62 'event',
63 'homeUrl',
64 'partnerId',
65 'props',
66 'siteId',
67 'ts',
68 'version',
69 ].sort(),
70 );
71 expect(body.event).toBe('edit_mode_on');
72 expect(body.props).toEqual({ source: 'admin-bar' });
73 expect(typeof body.ts).toBe('number');
74 });
75
76 it('defaults props to an empty object when omitted', async () => {
77 const { track } = await import('@quick-edit/lib/insights');
78 track('edit_mode_on');
79 const body = JSON.parse(mockFetch.mock.calls[0][1].body);
80 expect(body.props).toEqual({});
81 });
82
83 it('forwards the props object verbatim (no field filtering at the helper level)', async () => {
84 // Characterization: track() does not sanitize props itself — call sites
85 // are responsible for not putting PII into the props bag. If you change
86 // this to enforce a deny-list, update this test.
87 const { track } = await import('@quick-edit/lib/insights');
88 track('edit_mode_on', { foo: 'bar', n: 42 });
89 const body = JSON.parse(mockFetch.mock.calls[0][1].body);
90 expect(body.props).toEqual({ foo: 'bar', n: 42 });
91 });
92 });
93
94 describe('track — short-circuits', () => {
95 it('does not fire fetch when event is empty', async () => {
96 const { track } = await import('@quick-edit/lib/insights');
97 track('');
98 track(null);
99 track(undefined);
100 expect(mockFetch).not.toHaveBeenCalled();
101 });
102
103 it('does not fire fetch when INSIGHTS_HOST is falsy', async () => {
104 jest.resetModules();
105 jest.doMock('@constants', () => ({
106 INSIGHTS_HOST: '',
107 AI_HOST: '',
108 PATTERNS_HOST: '',
109 IMAGES_HOST: '',
110 KB_HOST: '',
111 }));
112 const { track } = await import('@quick-edit/lib/insights');
113 track('edit_mode_on');
114 expect(mockFetch).not.toHaveBeenCalled();
115 });
116 });
117
118 describe('track — error swallowing', () => {
119 it('swallows a synchronous throw inside fetch', async () => {
120 mockFetch.mockImplementation(() => {
121 throw new Error('synchronous boom');
122 });
123 const { track } = await import('@quick-edit/lib/insights');
124 expect(() => track('edit_mode_on')).not.toThrow();
125 });
126
127 it('swallows an async fetch rejection (no unhandled rejection)', async () => {
128 // Create the rejected promise inside the mock impl so .catch()
129 // attaches in the same microtask — otherwise the rejection would
130 // surface as unhandled before the dynamic import returns.
131 mockFetch.mockImplementation(() => Promise.reject(new Error('async boom')));
132 const { track } = await import('@quick-edit/lib/insights');
133 expect(() => track('edit_mode_on')).not.toThrow();
134 await Promise.resolve();
135 });
136 });
137