@liiift-studio/sanity-visitor-insights 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/core.test.ts +53 -0
- package/src/server/cache.test.ts +136 -0
- package/src/server/reports/reports.test.ts +40 -0
- package/src/server/vercel.test.ts +134 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/sanity-visitor-insights",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios — GA4, Vercel and order data reconciled honestly",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
package/src/core/core.test.ts
CHANGED
|
@@ -501,3 +501,56 @@ describe('GA4 capture rate', () => {
|
|
|
501
501
|
expect(grossUp(100, model)).toBeNull()
|
|
502
502
|
})
|
|
503
503
|
})
|
|
504
|
+
|
|
505
|
+
describe('a day boundary survives a DST transition', () => {
|
|
506
|
+
/*
|
|
507
|
+
* The two-pass offset resolution had no test. Reverting it to a single pass — the naive
|
|
508
|
+
* implementation anyone would write first — left the whole suite green, on the function that
|
|
509
|
+
* decides which orders fall inside the window every other figure is compared against.
|
|
510
|
+
*/
|
|
511
|
+
/*
|
|
512
|
+
* These dates are chosen because the two passes DISAGREE on them.
|
|
513
|
+
*
|
|
514
|
+
* A first attempt used the US transition dates, and the mutation — reverting to a single pass —
|
|
515
|
+
* went uncaught: for Los Angeles the naive probe at 00:00Z lands on the previous local afternoon,
|
|
516
|
+
* which is on the same side of a 02:00 transition as local midnight is, so both passes measure
|
|
517
|
+
* the same offset. The zones that discriminate are those far enough ahead of UTC that the naive
|
|
518
|
+
* probe crosses the transition, which is what these are.
|
|
519
|
+
*/
|
|
520
|
+
it('resolves a fall-back date where the naive offset is the wrong one', () => {
|
|
521
|
+
// Sydney returns to +10 at 03:00 local on 2026-04-05, so local midnight that day is still
|
|
522
|
+
// +11. A single pass probes 00:00Z — already 11:00 local, after the change — and subtracts
|
|
523
|
+
// +10, putting the day boundary an hour late.
|
|
524
|
+
expect(zonedDayStartUtc('2026-04-05', 'Australia/Sydney')).toBe('2026-04-04T13:00:00.000Z')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('resolves a spring-forward date where the naive offset errs the other way', () => {
|
|
528
|
+
expect(zonedDayStartUtc('2026-10-04', 'Australia/Sydney')).toBe('2026-10-03T14:00:00.000Z')
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('is unchanged on a transition date whose probe stays on one side', () => {
|
|
532
|
+
// The US dates, kept because they are the foundries' own zones — they pass either way, which
|
|
533
|
+
// is exactly why they could not guard the two-pass resolution.
|
|
534
|
+
expect(zonedDayStartUtc('2026-03-08', 'America/Los_Angeles')).toBe('2026-03-08T08:00:00.000Z')
|
|
535
|
+
expect(zonedDayStartUtc('2026-11-01', 'America/Los_Angeles')).toBe('2026-11-01T07:00:00.000Z')
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('handles a zone whose offset is not a whole hour', () => {
|
|
539
|
+
expect(zonedDayStartUtc('2026-06-15', 'Asia/Kolkata')).toBe('2026-06-14T18:30:00.000Z')
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('gives an ordinary day the plain offset', () => {
|
|
543
|
+
expect(zonedDayStartUtc('2026-06-15', 'America/New_York')).toBe('2026-06-15T04:00:00.000Z')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('refuses a malformed date rather than resolving one', () => {
|
|
547
|
+
expect(() => zonedDayStartUtc('not-a-date', 'UTC')).toThrow(/Invalid ISO date/)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('makes one day end exactly where the next begins', () => {
|
|
551
|
+
// The property nothing can double-count across, and the reason the unconverged midnight-DST
|
|
552
|
+
// case is a shift rather than an overlap.
|
|
553
|
+
expect(zonedDayEndUtc('2026-03-07', 'America/Los_Angeles'))
|
|
554
|
+
.toBe(zonedDayStartUtc('2026-03-08', 'America/Los_Angeles'))
|
|
555
|
+
})
|
|
556
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the report cache.
|
|
3
|
+
*
|
|
4
|
+
* It had none. A review's mutation table showed that removing expiry, removing eviction, and
|
|
5
|
+
* ignoring the per-result TTL callback entirely all left the suite green — on a module whose whole
|
|
6
|
+
* job is bounding GA4 fan-out against a property with a ten-concurrent ceiling, and which had just
|
|
7
|
+
* been changed to cache failures briefly.
|
|
8
|
+
*
|
|
9
|
+
* Timers are faked rather than slept on, so expiry is asserted at the boundary instead of
|
|
10
|
+
* approximately and the suite stays fast.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
+
import { DEFAULT_TTL_MS, cacheKey, clearCache, getCached, setCached, withCache } from './cache'
|
|
15
|
+
|
|
16
|
+
describe('cacheKey', () => {
|
|
17
|
+
beforeEach(() => { clearCache() })
|
|
18
|
+
|
|
19
|
+
it('separates keys that differ in any part', () => {
|
|
20
|
+
expect(cacheKey(['vi', 'darden', 'overview'])).not.toBe(cacheKey(['vi', 'darden', 'journey']))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('treats an absent part as empty rather than as the string "undefined"', () => {
|
|
24
|
+
expect(cacheKey(['vi', undefined])).toBe('vi|')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does not collide across a part boundary', () => {
|
|
28
|
+
// Without a separator, ['a','bc'] and ['ab','c'] would be the same key and two different
|
|
29
|
+
// sites could read each other's reports.
|
|
30
|
+
expect(cacheKey(['a', 'bc'])).not.toBe(cacheKey(['ab', 'c']))
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('expiry', () => {
|
|
35
|
+
beforeEach(() => { clearCache(); vi.useFakeTimers() })
|
|
36
|
+
afterEach(() => { vi.useRealTimers() })
|
|
37
|
+
|
|
38
|
+
it('serves a value inside its TTL', () => {
|
|
39
|
+
setCached('k', 'value', 1000)
|
|
40
|
+
vi.advanceTimersByTime(999)
|
|
41
|
+
expect(getCached('k')).toBe('value')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('drops a value at its TTL, not merely after it', () => {
|
|
45
|
+
// `expiresAt <= now` — the boundary belongs to the expired side, so a stale report cannot be
|
|
46
|
+
// served for one more millisecond.
|
|
47
|
+
setCached('k', 'value', 1000)
|
|
48
|
+
vi.advanceTimersByTime(1000)
|
|
49
|
+
expect(getCached('k')).toBeUndefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('evicts on read rather than waiting for a sweep', () => {
|
|
53
|
+
// There is no timer in this module; a serverless instance may never run one.
|
|
54
|
+
setCached('k', 'value', 1000)
|
|
55
|
+
vi.advanceTimersByTime(1000)
|
|
56
|
+
getCached('k')
|
|
57
|
+
// Re-storing with a fresh TTL must work, which it cannot if the stale entry lingers.
|
|
58
|
+
setCached('k', 'second', 1000)
|
|
59
|
+
expect(getCached('k')).toBe('second')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('defaults to the documented window', () => {
|
|
63
|
+
setCached('k', 'value')
|
|
64
|
+
vi.advanceTimersByTime(DEFAULT_TTL_MS - 1)
|
|
65
|
+
expect(getCached('k')).toBe('value')
|
|
66
|
+
vi.advanceTimersByTime(1)
|
|
67
|
+
expect(getCached('k')).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('bounding', () => {
|
|
72
|
+
beforeEach(() => { clearCache() })
|
|
73
|
+
|
|
74
|
+
it('evicts the oldest entry rather than growing without limit', () => {
|
|
75
|
+
// A long-lived instance must not accumulate every range any editor has ever opened.
|
|
76
|
+
for (let i = 0; i < 200; i++) setCached(`k${i}`, i, 60_000)
|
|
77
|
+
expect(getCached('k0')).toBe(0)
|
|
78
|
+
setCached('overflow', 'new', 60_000)
|
|
79
|
+
expect(getCached('k0')).toBeUndefined()
|
|
80
|
+
expect(getCached('overflow')).toBe('new')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('overwrites in place without evicting anything', () => {
|
|
84
|
+
for (let i = 0; i < 200; i++) setCached(`k${i}`, i, 60_000)
|
|
85
|
+
setCached('k5', 'updated', 60_000)
|
|
86
|
+
expect(getCached('k0')).toBe(0)
|
|
87
|
+
expect(getCached('k5')).toBe('updated')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('withCache', () => {
|
|
92
|
+
beforeEach(() => { clearCache(); vi.useFakeTimers() })
|
|
93
|
+
afterEach(() => { vi.useRealTimers() })
|
|
94
|
+
|
|
95
|
+
it('computes once and serves the stored value after', async () => {
|
|
96
|
+
let calls = 0
|
|
97
|
+
const compute = async () => { calls += 1; return calls }
|
|
98
|
+
expect(await withCache('k', 60_000, compute)).toBe(1)
|
|
99
|
+
expect(await withCache('k', 60_000, compute)).toBe(1)
|
|
100
|
+
expect(calls).toBe(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('lets the caller set a TTL from the result', async () => {
|
|
104
|
+
// The whole point of the callback: a healthy answer keeps the full window, a degraded one is
|
|
105
|
+
// held for seconds so a transient blip does not read as a five-minute outage.
|
|
106
|
+
await withCache('healthy', 60_000, async () => ({ ok: true }), (v) => (v.ok ? 60_000 : 1_000))
|
|
107
|
+
await withCache('degraded', 60_000, async () => ({ ok: false }), (v) => (v.ok ? 60_000 : 1_000))
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(1_000)
|
|
110
|
+
expect(getCached('degraded')).toBeUndefined()
|
|
111
|
+
expect(getCached('healthy')).toBeDefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not store at all when the callback returns zero', async () => {
|
|
115
|
+
let calls = 0
|
|
116
|
+
const compute = async () => { calls += 1; return calls }
|
|
117
|
+
await withCache('k', 60_000, compute, () => 0)
|
|
118
|
+
await withCache('k', 60_000, compute, () => 0)
|
|
119
|
+
expect(calls).toBe(2)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('falls back to the fixed TTL when no callback is given', async () => {
|
|
123
|
+
await withCache('k', 1_000, async () => 'value')
|
|
124
|
+
vi.advanceTimersByTime(999)
|
|
125
|
+
expect(getCached('k')).toBe('value')
|
|
126
|
+
vi.advanceTimersByTime(1)
|
|
127
|
+
expect(getCached('k')).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not cache a rejection', async () => {
|
|
131
|
+
// A thrown error must not be stored as an answer — the next caller has to be free to retry.
|
|
132
|
+
await expect(withCache('k', 60_000, async () => { throw new Error('upstream down') }))
|
|
133
|
+
.rejects.toThrow('upstream down')
|
|
134
|
+
expect(getCached('k')).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -1815,3 +1815,43 @@ describe('Vercel buckets are labelled by the day they mostly cover', () => {
|
|
|
1815
1815
|
expect(formatInTimeZone(new Date(bucketStart), 'America/Los_Angeles')).toBe('2026-09-04')
|
|
1816
1816
|
})
|
|
1817
1817
|
})
|
|
1818
|
+
|
|
1819
|
+
describe('an order window is half-open, so no order lands in two ranges', () => {
|
|
1820
|
+
it('excludes the end instant rather than including it', async () => {
|
|
1821
|
+
// `_createdAt < $end` with $end being the START of the day after — so day N's window ends
|
|
1822
|
+
// exactly where day N+1's begins. Making it `<=` would count an order placed at that instant
|
|
1823
|
+
// in both the current window and the next, in the figure the tool calls exact.
|
|
1824
|
+
const queries: string[] = []
|
|
1825
|
+
const client = {
|
|
1826
|
+
fetch: (query: string) => { queries.push(query); return Promise.resolve([]) },
|
|
1827
|
+
} as never
|
|
1828
|
+
|
|
1829
|
+
await countOrders(client, orderQueryOptions(
|
|
1830
|
+
{ documentType: 'order', statusField: 'orderStatus.status', countedStatuses: ['verified'] },
|
|
1831
|
+
{ start: '2026-08-20', end: '2026-08-26', timezone: 'UTC' },
|
|
1832
|
+
))
|
|
1833
|
+
|
|
1834
|
+
expect(queries[0]).toContain('_createdAt >= $start')
|
|
1835
|
+
expect(queries[0]).toContain('_createdAt < $end')
|
|
1836
|
+
expect(queries[0]).not.toContain('_createdAt <= $end')
|
|
1837
|
+
})
|
|
1838
|
+
|
|
1839
|
+
it('ends the window at the start of the day after the last one', async () => {
|
|
1840
|
+
// The bound has to be exclusive AND cover the whole final day; an exclusive bound at the
|
|
1841
|
+
// final day's own start would silently drop every order placed on it. Asserted through the
|
|
1842
|
+
// params the query is actually run with, rather than by widening the module's exports.
|
|
1843
|
+
let params: Record<string, unknown> | undefined
|
|
1844
|
+
const client = {
|
|
1845
|
+
fetch: (_query: string, p: Record<string, unknown>) => { params = p; return Promise.resolve([]) },
|
|
1846
|
+
} as never
|
|
1847
|
+
|
|
1848
|
+
await countOrders(client, orderQueryOptions(
|
|
1849
|
+
{ documentType: 'order', statusField: 'orderStatus.status', countedStatuses: ['verified'] },
|
|
1850
|
+
{ start: '2026-08-20', end: '2026-08-26', timezone: 'UTC' },
|
|
1851
|
+
))
|
|
1852
|
+
|
|
1853
|
+
expect(params?.start).toBe('2026-08-20T00:00:00.000Z')
|
|
1854
|
+
expect(params?.end).toBe('2026-08-27T00:00:00.000Z')
|
|
1855
|
+
})
|
|
1856
|
+
|
|
1857
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the real Vercel client.
|
|
3
|
+
*
|
|
4
|
+
* It had none. The test double in `testing/fakes.ts` implements `pageviews()` by returning a
|
|
5
|
+
* fixture, so no test had ever reached the client's chunking, its day filtering, its totals
|
|
6
|
+
* derivation or its failure reporting — and a review's mutation table showed that deleting the
|
|
7
|
+
* range filter, hard-coding `incompleteWindows` to zero, or switching the total between its two
|
|
8
|
+
* sources all left the suite green.
|
|
9
|
+
*
|
|
10
|
+
* `fetch` is stubbed per test rather than mocked at module level, so each case states exactly what
|
|
11
|
+
* the API gave back and what the client made of it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
15
|
+
import { createVercelClient, dailyWindows, granularityFor } from './vercel'
|
|
16
|
+
|
|
17
|
+
/** One aggregate bucket, as the API returns it. */
|
|
18
|
+
function bucket(day: string, pageviews: number) {
|
|
19
|
+
return { timestamp: `${day}T00:00:00.000Z`, pageviews, visitors: pageviews }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stub `fetch` for the two endpoints the client calls.
|
|
24
|
+
*
|
|
25
|
+
* @param series - buckets to return from `visits/aggregate`, or a thrower per call index
|
|
26
|
+
* @param totals - what `visits/count` reports for the whole range
|
|
27
|
+
*/
|
|
28
|
+
function stubFetch(
|
|
29
|
+
series: Array<ReturnType<typeof bucket>> | ((call: number) => Array<ReturnType<typeof bucket>>),
|
|
30
|
+
totals: { pageviews?: number; visitors?: number } | null = { pageviews: 999, visitors: 500 },
|
|
31
|
+
) {
|
|
32
|
+
let aggregateCalls = 0
|
|
33
|
+
const urls: string[] = []
|
|
34
|
+
globalThis.fetch = (async (url: string) => {
|
|
35
|
+
urls.push(url)
|
|
36
|
+
const isCount = url.includes('visits/count')
|
|
37
|
+
const body = isCount
|
|
38
|
+
? { data: totals }
|
|
39
|
+
: { data: typeof series === 'function' ? series(aggregateCalls++) : series }
|
|
40
|
+
return { ok: true, status: 200, async json() { return body } }
|
|
41
|
+
}) as unknown as typeof fetch
|
|
42
|
+
return urls
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const client = () => createVercelClient('token', 'proj', 'team')
|
|
46
|
+
|
|
47
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
48
|
+
|
|
49
|
+
describe('the day series', () => {
|
|
50
|
+
it('keeps only days inside the requested range', async () => {
|
|
51
|
+
// The API is asked for a range but the response is not trusted to respect it.
|
|
52
|
+
stubFetch([bucket('2026-08-19', 10), bucket('2026-08-20', 100), bucket('2026-08-26', 200), bucket('2026-08-27', 20)])
|
|
53
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
54
|
+
expect(Object.keys(result.byDate).sort()).toEqual(['2026-08-20', '2026-08-26'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('sums repeated buckets for one day rather than overwriting', async () => {
|
|
58
|
+
// Chunked fetches can return the same day twice at a window boundary; the second must not
|
|
59
|
+
// silently replace the first.
|
|
60
|
+
stubFetch([bucket('2026-08-20', 100), bucket('2026-08-20', 50)])
|
|
61
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
62
|
+
expect(result.byDate['2026-08-20']).toBe(150)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('ignores a bucket with no timestamp instead of keying it undefined', async () => {
|
|
66
|
+
stubFetch([{ pageviews: 100 } as never, bucket('2026-08-21', 40)])
|
|
67
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
68
|
+
expect(Object.keys(result.byDate)).toEqual(['2026-08-21'])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('the range total', () => {
|
|
73
|
+
it('comes from the day series when that series is whole', async () => {
|
|
74
|
+
// Summing the days covers exactly the window asked for; the count endpoint's bare dates are
|
|
75
|
+
// read in UTC and can start and end elsewhere.
|
|
76
|
+
stubFetch([bucket('2026-08-20', 100), bucket('2026-08-21', 200)], { pageviews: 999, visitors: 40 })
|
|
77
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
78
|
+
expect(result.total).toBe(300)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('falls back to the count endpoint when a window failed', async () => {
|
|
82
|
+
// A hole in the series must not be published as a smaller total.
|
|
83
|
+
let call = 0
|
|
84
|
+
globalThis.fetch = (async (url: string) => {
|
|
85
|
+
if (url.includes('visits/count')) {
|
|
86
|
+
return { ok: true, status: 200, async json() { return { data: { pageviews: 999, visitors: 40 } } } }
|
|
87
|
+
}
|
|
88
|
+
call += 1
|
|
89
|
+
if (call === 1) throw new Error('window down')
|
|
90
|
+
return { ok: true, status: 200, async json() { return { data: [bucket('2026-08-20', 100)] } } }
|
|
91
|
+
}) as unknown as typeof fetch
|
|
92
|
+
|
|
93
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
94
|
+
expect(result.incompleteWindows).toBe(1)
|
|
95
|
+
expect(result.total).toBe(999)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('reports an absent visitor count as absent, never as zero', async () => {
|
|
99
|
+
// This panel exists to tell "measured nothing" from "did not measure", and has to hold itself
|
|
100
|
+
// to that first.
|
|
101
|
+
stubFetch([bucket('2026-08-20', 100)], {})
|
|
102
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
103
|
+
expect(result.visitors).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not sum daily visitors, which would double-count returning people', async () => {
|
|
107
|
+
// A person visiting on three days is three daily visitors and one range visitor.
|
|
108
|
+
stubFetch([bucket('2026-08-20', 100), bucket('2026-08-21', 100)], { pageviews: 999, visitors: 40 })
|
|
109
|
+
const result = await client().pageviews('2026-08-20', '2026-08-26')
|
|
110
|
+
expect(result.visitors).toBe(40)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('long ranges', () => {
|
|
115
|
+
it('splits a quarter into day-bucketed windows', async () => {
|
|
116
|
+
const urls = stubFetch(() => [bucket('2026-08-20', 10)])
|
|
117
|
+
await client().pageviews('2026-06-08', '2026-09-05')
|
|
118
|
+
const aggregates = urls.filter((u) => u.includes('visits/aggregate'))
|
|
119
|
+
expect(aggregates.length).toBe(dailyWindows('2026-06-08', '2026-09-05').length)
|
|
120
|
+
for (const url of aggregates) expect(url).toContain('by=day')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reports a coarse fallback as not daily, so its total is not published as one', async () => {
|
|
124
|
+
// Beyond the window cap the client makes one `by: month` call. Summing month buckets and
|
|
125
|
+
// filtering them by a day comparison drops one straddling the start and keeps one straddling
|
|
126
|
+
// the end — and `incompleteWindows` stays zero, so the flag built to say "do not trust this"
|
|
127
|
+
// reported it sound.
|
|
128
|
+
const urls = stubFetch([bucket('2025-09-01', 5000)])
|
|
129
|
+
const result = await client().pageviews('2025-08-15', '2026-09-05')
|
|
130
|
+
expect(urls.some((u) => u.includes(`by=${granularityFor('2025-08-15', '2026-09-05')}`))).toBe(true)
|
|
131
|
+
expect(result.daily).toBe(false)
|
|
132
|
+
expect(result.total).toBe(999)
|
|
133
|
+
})
|
|
134
|
+
})
|