feat(admin): unify root visits and expose redirect settings #27
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@goto/web': minor
|
||||
'@goto/worker': minor
|
||||
'@goto/cli': minor
|
||||
---
|
||||
|
||||
Use one `(root)` inventory row to reach Root details and remove redundant Root summary navigation. Expose root/fallback response settings and named-link settings, inheritance, schedules and tags without changing existing URLs.
|
||||
|
||||
Count every tracked `/` request as a valid Root visit, independent of response status. Apply that meaning to legacy Root visits during report aggregation without rewriting raw rows. Details version 3 keeps totals, charts and browser/OS breakdowns consistent; older prepared details stay unavailable until normal report refresh. Invalid clicks remain limited to non-root traffic.
|
||||
+81
-50
@@ -34,7 +34,7 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
.getByRole('region', { name: 'Visits', exact: true })
|
||||
.locator('.admin-stat-value');
|
||||
await expect(page.getByRole('region', { name: 'Domains', exact: true })).toContainText('2');
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
|
||||
'Domains',
|
||||
'Links',
|
||||
@@ -57,18 +57,18 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
await expect(chartTable).toBeVisible();
|
||||
await expect(chartTable).toHaveAccessibleName('Visits');
|
||||
await expect(chartTable.getByRole('columnheader')).toHaveText(['Period (UTC)', 'Visits']);
|
||||
await expect(chartTable.getByRole('row', { name: 'Total 2', exact: true })).toBeVisible();
|
||||
await expect(chartTable.getByRole('row', { name: 'Total 3', exact: true })).toBeVisible();
|
||||
await expect(chartTable).not.toContainText('(partial)');
|
||||
await expect(trend.getByRole('region', { name: 'Visit data', exact: true })).toHaveAttribute(
|
||||
'tabindex',
|
||||
'0'
|
||||
);
|
||||
await expect(graph).toBeVisible();
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 3\./);
|
||||
const domainLink = page.getByRole('link', { name: 'go.example.org', exact: true });
|
||||
const domainRow = page.getByRole('listitem').filter({ has: domainLink });
|
||||
const domainCounts = domainRow.locator('.admin-inventory-number');
|
||||
await expect(domainCounts).toHaveText(['6', '1']);
|
||||
await expect(domainCounts).toHaveText(['6', '2']);
|
||||
await expect(domainRow.locator('.admin-inventory-label').first()).toHaveCSS(
|
||||
'clip-path',
|
||||
'inset(50%)'
|
||||
@@ -87,33 +87,33 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await expect(domainCounts).toHaveText(['6', '0']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 1\./);
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(domainCounts).toHaveText(['6', '1']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./);
|
||||
await timeframe.getByRole('button', { name: '3 months', exact: true }).click();
|
||||
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
await expect(domainCounts).toHaveText(['6', '2']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./);
|
||||
await expect(visitsValue).toHaveText('4');
|
||||
await expect(domainCounts).toHaveText(['6', '3']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 4\./);
|
||||
await page.goBack();
|
||||
await expect(timeframe.getByRole('button', { name: '7 days', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await expect(domainCounts).toHaveText(['6', '0']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 1\./);
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(domainCounts).toHaveText(['6', '1']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./);
|
||||
await page.goForward();
|
||||
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
await expect(domainCounts).toHaveText(['6', '2']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./);
|
||||
await expect(visitsValue).toHaveText('4');
|
||||
await expect(domainCounts).toHaveText(['6', '3']);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 4\./);
|
||||
page.off('request', recordSelectionRequest);
|
||||
expect(selectionRequests).toEqual([]);
|
||||
await page.reload();
|
||||
@@ -121,8 +121,8 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./);
|
||||
await expect(visitsValue).toHaveText('4');
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 4\./);
|
||||
expect(navigationWarnings).toEqual([]);
|
||||
await domainLink.click();
|
||||
await expect(page.getByRole('heading', { name: 'Domain', exact: true })).toBeVisible();
|
||||
@@ -144,24 +144,28 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
await expect(overviewLink).toHaveAttribute('href', '/admin?period=all');
|
||||
await expect(page.getByRole('link', { name: 'Overview', exact: true })).toHaveCount(0);
|
||||
await expect(graph).toHaveAttribute('aria-busy', 'false');
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 2\./);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./);
|
||||
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
|
||||
'Links',
|
||||
'Visits',
|
||||
'Invalid clicks',
|
||||
'Base visits'
|
||||
'Invalid clicks'
|
||||
]);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Invalid clicks', exact: true }).locator('.admin-stat-value')
|
||||
).toHaveText('1');
|
||||
const linksInventory = page.getByRole('region', { name: 'Links on go.example.org', exact: true });
|
||||
const domainDetails = page.getByRole('region', { name: 'Domain details', exact: true });
|
||||
const baseVisitsLink = page.getByRole('link', { name: 'Base visits', exact: true });
|
||||
const rootRow = linksInventory.getByRole('listitem').filter({
|
||||
has: page.getByRole('link', { name: '(root)', exact: true })
|
||||
});
|
||||
const rootLink = rootRow.getByRole('link', { name: '(root)', exact: true });
|
||||
await expect(rootRow.locator('.admin-inventory-secondary')).toHaveText('[not found]');
|
||||
await expect(rootRow.locator('.admin-inventory-state')).toHaveText('Active');
|
||||
const columns = ['Path', 'Destination', 'State', 'Visits'];
|
||||
await expect(linksInventory.locator('.admin-inventory-heading > span')).toHaveText(columns);
|
||||
const retainedLink = linksInventory
|
||||
@@ -180,18 +184,18 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
.getByRole('listbox', { name: 'Timeframe', exact: true })
|
||||
.getByRole('option', { name: '30 days', exact: true })
|
||||
.click();
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(retainedVisits).toHaveText('0');
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 1\./);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./);
|
||||
expect(
|
||||
await linksInventory.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await timeframe.getByRole('button', { name: '3 months', exact: true }).click();
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
await expect(retainedVisits).toHaveText('1');
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 2\./);
|
||||
expect(await cardSizes()).toEqual(overviewCardSizes);
|
||||
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./);
|
||||
expect(await cardSizes()).toEqual(overviewCardSizes.slice(0, 3));
|
||||
const historicalInvalid = linksInventory
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) });
|
||||
@@ -199,29 +203,22 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
await expect(
|
||||
domainDetails.getByRole('heading', { name: 'Domain details', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(domainDetails.locator('dt')).toHaveText([
|
||||
'Domain',
|
||||
'Base destination',
|
||||
'Base status'
|
||||
]);
|
||||
await expect(domainDetails.locator('dt')).toHaveText(['Domain', 'Root', 'Fallback']);
|
||||
await expect(domainDetails.locator('dd')).toHaveText([
|
||||
'go.example.org',
|
||||
'404 Not found',
|
||||
'Fallback'
|
||||
'Uses fallback',
|
||||
'404 Not found'
|
||||
]);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Base visits', exact: true }).locator('.admin-stat-value')
|
||||
).toHaveText('0');
|
||||
await expect(rootRow.locator('.admin-inventory-number')).toHaveText('1');
|
||||
await expect(domainDetails.getByRole('link', { name: 'Root', exact: true })).toHaveCount(0);
|
||||
await expect(domainDetails.getByRole('link', { name: 'Fallback', exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
linksInventory.getByText('https://example.org/first-domain', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
linksInventory.getByRole('link', { name: 'https://example.org/first-domain', exact: true })
|
||||
).toHaveCount(0);
|
||||
await expect(baseVisitsLink).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/domains/go.example.org/links?period=all'
|
||||
);
|
||||
await expect(rootLink).toHaveAttribute('href', '/admin/domains/go.example.org/links?period=all');
|
||||
|
||||
const linkDetails = page.getByRole('region', { name: 'Link details', exact: true });
|
||||
const domainDetailsLink = linkDetails.getByRole('link', { name: 'go.example.org', exact: true });
|
||||
@@ -233,7 +230,26 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
await expect(
|
||||
linkDetails.getByRole('heading', { name: 'Link details', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(linkDetails.locator('dt')).toHaveText(['Domain', 'Path', 'Destination']);
|
||||
await expect(linkDetails.locator('dt')).toHaveText([
|
||||
'Domain',
|
||||
'Path',
|
||||
'Destination',
|
||||
'HTTP status',
|
||||
'Forward query',
|
||||
'Starts',
|
||||
'Expires',
|
||||
'Tags'
|
||||
]);
|
||||
await expect(linkDetails.locator('time').first()).toBeVisible();
|
||||
await expect(linkDetails.locator('time').first()).toHaveAttribute(
|
||||
'datetime',
|
||||
'2020-01-01T00:00:00Z'
|
||||
);
|
||||
await expect(linkDetails.locator('time').last()).toHaveAttribute(
|
||||
'datetime',
|
||||
'2100-01-01T00:00:00Z'
|
||||
);
|
||||
await expect(linkDetails.getByText('work, docs', { exact: true })).toBeVisible();
|
||||
await expect(linkDetails.locator('dd').nth(1)).toHaveText('/docs');
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Visits', 'Status']);
|
||||
await expect(page.getByRole('region', { name: 'Status', exact: true })).toContainText('Active');
|
||||
@@ -281,17 +297,27 @@ test('shared credentials protect admin pages and sign-out removes access', async
|
||||
await expect(visitsValue).toHaveText('0');
|
||||
|
||||
await domainDetailsLink.click();
|
||||
await baseVisitsLink.click();
|
||||
await rootLink.click();
|
||||
await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links?period=all`);
|
||||
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
|
||||
await expect(page).toHaveTitle('Link — go.example.org/ — Goto');
|
||||
await expect(visitsValue).toHaveText('0');
|
||||
await domainDetailsLink.click();
|
||||
await expect(page.getByRole('heading', { name: 'Root', exact: true })).toBeVisible();
|
||||
await expect(page).toHaveTitle('Root — go.example.org — Goto');
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await expect(page.getByRole('region', { name: 'Status', exact: true })).toHaveCount(0);
|
||||
const rootDetails = page.getByRole('region', { name: 'Root details', exact: true });
|
||||
await expect(rootDetails.locator('dd')).toHaveText([
|
||||
'go.example.org',
|
||||
'go.example.org/',
|
||||
'Uses fallback'
|
||||
]);
|
||||
await expect(page.getByRole('region', { name: 'Fallback', exact: true })).toContainText(
|
||||
'404 Not found'
|
||||
);
|
||||
await rootDetails.getByRole('link', { name: 'go.example.org', exact: true }).click();
|
||||
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(visitsValue).toHaveText('3');
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Administration', exact: true })
|
||||
@@ -356,7 +382,12 @@ test.describe('analytics disabled', () => {
|
||||
).toHaveText(['Path', 'Destination', 'State']);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Domain details', exact: true }).locator('dt')
|
||||
).toHaveText(['Domain', 'Base destination', 'Base status']);
|
||||
).toHaveText(['Domain', 'Root', 'Fallback']);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: 'Links on go.example.org', exact: true })
|
||||
.getByRole('link', { name: '(root)', exact: true })
|
||||
).toBeVisible();
|
||||
await page.getByRole('link', { name: '/docs', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: 'Link details', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Browsers', exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -34,7 +34,12 @@ const configuration = {
|
||||
'/_app/version.json': { url: 'https://example.org/public-asset' },
|
||||
'/a/b': { url: 'https://example.org/decoded' },
|
||||
'/a%2Fb': { url: 'https://example.org/literal-percent' },
|
||||
'/docs': { url: 'https://example.org/first-domain' },
|
||||
'/docs': {
|
||||
url: 'https://example.org/first-domain',
|
||||
valid_from: '2020-01-01T00:00:00Z',
|
||||
valid_until: '2100-01-01T00:00:00Z',
|
||||
tags: ['work', 'docs']
|
||||
},
|
||||
'/healthz': { url: 'https://example.org/public-health' }
|
||||
}
|
||||
},
|
||||
@@ -70,6 +75,7 @@ async function seedVisits() {
|
||||
os: 'Linux'
|
||||
},
|
||||
{ domain: 'go.example.org', path: '/a/b', outcome: 'invalid' },
|
||||
// Legacy Root classification exercises report normalization without rewriting raw visits.
|
||||
{ domain: 'go.example.org', path: '/', outcome: 'invalid' },
|
||||
{
|
||||
domain: 'go.example.org',
|
||||
|
||||
@@ -75,7 +75,7 @@ test('the compiled writer persists outcomes, isolates locks and drains visits be
|
||||
)
|
||||
.all('go.example.org', '/', '/missing')
|
||||
).toEqual([
|
||||
{ path: '/', outcome: 'invalid' },
|
||||
{ path: '/', outcome: 'valid' },
|
||||
{ path: '/missing', outcome: 'invalid' }
|
||||
]);
|
||||
} finally {
|
||||
|
||||
@@ -38,10 +38,12 @@
|
||||
<a
|
||||
class="admin-inventory-row admin-inventory-link"
|
||||
href={`${link.href}?period=${timeframe}`}
|
||||
aria-label={link.path}
|
||||
aria-label={link.path === '/' ? '(root)' : link.path}
|
||||
>
|
||||
<span class="admin-inventory-primary"><code>{link.path}</code></span>
|
||||
<span class="admin-inventory-secondary">{link.url ?? '404 Not found'}</span>
|
||||
<span class="admin-inventory-primary"
|
||||
><code>{link.path === '/' ? '(root)' : link.path}</code></span
|
||||
>
|
||||
<span class="admin-inventory-secondary">{link.url ?? '[not found]'}</span>
|
||||
<span class="admin-inventory-state">
|
||||
{#if link.state === 'active'}Active
|
||||
{:else if link.state === 'disabled'}Disabled
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { formatUpdated, formatUpdatedCompact } from './format.js';
|
||||
import { formatUpdated, formatUpdatedCompact, formatUtc } from './format.js';
|
||||
|
||||
test('formats detail timestamps in UTC with seconds', () => {
|
||||
expect(formatUtc('2026-11-06T22:03:48Z')).toBe('Nov 6, 2026, 22:03:48 UTC');
|
||||
expect(formatUtc('2026-11-06T17:03:48-05:00')).toBe('Nov 6, 2026, 22:03:48 UTC');
|
||||
});
|
||||
|
||||
test('formats the snapshot update time in UTC', () => {
|
||||
const updatedAt = Date.parse('2026-11-06T22:03:00Z');
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
const utcFormatter = new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
|
||||
export function formatCount(value: number | null): string {
|
||||
return value === null ? '—' : value.toLocaleString('en');
|
||||
}
|
||||
|
||||
export function formatUtc(value: string): string {
|
||||
return `${utcFormatter.format(new Date(value))} UTC`;
|
||||
}
|
||||
|
||||
export function formatUpdated(value: number | null): string {
|
||||
if (value === null) return '';
|
||||
const formatter = new Intl.DateTimeFormat('en', {
|
||||
|
||||
@@ -315,20 +315,20 @@ describe('validity and fallback', () => {
|
||||
expect(missing.visit).toMatchObject({ path: '/home', outcome: 'invalid' });
|
||||
});
|
||||
|
||||
test('an unconfigured root uses the fallback response but remains an invalid base visit', () => {
|
||||
test('a root request using fallback is still a valid Root visit', () => {
|
||||
const domain = preparedDomain({}, fallback);
|
||||
const { response, visit } = resolveRedirect(domain, host, '/?incoming=ignored', 'GET');
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toBe(fallbackUrl);
|
||||
expect(visit).toMatchObject({ path: '/', outcome: 'invalid' });
|
||||
expect(visit).toMatchObject({ path: '/', outcome: 'valid' });
|
||||
});
|
||||
|
||||
test('an unconfigured root remains a base visit with an invalid outcome', () => {
|
||||
test('a root request returning 404 is still a valid Root visit', () => {
|
||||
const domain = preparedDomain();
|
||||
const { response, visit } = resolveRedirect(domain, host, '/', 'GET');
|
||||
expect(response.status).toBe(404);
|
||||
expect(visit).toMatchObject({ path: '/', outcome: 'invalid' });
|
||||
expect(visit).toMatchObject({ path: '/', outcome: 'valid' });
|
||||
});
|
||||
|
||||
test('default fallback options preserve the URL without appending the incoming path or query', () => {
|
||||
@@ -421,22 +421,22 @@ describe('query forwarding', () => {
|
||||
|
||||
describe('direct self-loops', () => {
|
||||
test.each([
|
||||
['/', 'https://GO.EXAMPLE.TEST:443/?changed=one#fragment'],
|
||||
['/%64ocs?incoming=one', 'https://go.example.test/docs?different=two'],
|
||||
['/a%2Fb', 'https://go.example.test/a/b#fragment'],
|
||||
['/docs', 'http://go.example.test:80/docs'],
|
||||
['/docs', 'https:///go.example.test/docs']
|
||||
])('runtime blocks a direct fallback loop: %s -> %s', (target, url) => {
|
||||
['/', 'https://GO.EXAMPLE.TEST:443/?changed=one#fragment', 'valid'],
|
||||
['/%64ocs?incoming=one', 'https://go.example.test/docs?different=two', 'invalid'],
|
||||
['/a%2Fb', 'https://go.example.test/a/b#fragment', 'invalid'],
|
||||
['/docs', 'http://go.example.test:80/docs', 'invalid'],
|
||||
['/docs', 'https:///go.example.test/docs', 'invalid']
|
||||
])('runtime blocks a direct fallback loop: %s -> %s', (target, url, expectedOutcome) => {
|
||||
const domain = preparedDomain({}, preparedResponse({ url, forward_query: true }));
|
||||
const { response, visit } = resolveRedirect(domain, host, target, 'GET');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(visit?.outcome).toBe('invalid');
|
||||
expect(visit?.outcome).toBe(expectedOutcome);
|
||||
expect(response.headers.has('location')).toBe(false);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
});
|
||||
|
||||
test('a direct root loop returns an invalid 404 without trying fallback', () => {
|
||||
test('a Root loop is blocked with 404 without making the Root request invalid', () => {
|
||||
const root = preparedResponse({
|
||||
url: 'https://GO.EXAMPLE.TEST:443/?owned=old#section',
|
||||
forward_query: true
|
||||
@@ -447,7 +447,7 @@ describe('direct self-loops', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.has('location')).toBe(false);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(visit).toMatchObject({ path: '/', outcome: 'invalid' });
|
||||
expect(visit).toMatchObject({ path: '/', outcome: 'valid' });
|
||||
});
|
||||
|
||||
test('a direct link loop returns an invalid 404 without trying fallback', () => {
|
||||
|
||||
@@ -91,13 +91,12 @@ function resolveVisitResponse(
|
||||
target: ParsedTarget,
|
||||
now: number
|
||||
): ResolvedResponse {
|
||||
const isRoot = target.path === '/';
|
||||
let redirect = domain.fallback;
|
||||
let outcome: VisitOutcome = 'invalid';
|
||||
if (target.path === '/') {
|
||||
if (domain.root !== null) {
|
||||
redirect = domain.root;
|
||||
outcome = 'valid';
|
||||
}
|
||||
if (isRoot) {
|
||||
outcome = 'valid';
|
||||
if (domain.root !== null) redirect = domain.root;
|
||||
} else {
|
||||
const link = findActiveLink(domain, target.path, now);
|
||||
if (link) {
|
||||
@@ -108,7 +107,7 @@ function resolveVisitResponse(
|
||||
|
||||
if (redirect === null) {
|
||||
const response = emptyResponse(404);
|
||||
return { response, outcome: 'invalid' };
|
||||
return { response, outcome };
|
||||
}
|
||||
|
||||
let destination = redirect.url;
|
||||
@@ -116,7 +115,8 @@ function resolveVisitResponse(
|
||||
const selfRedirect = isSelfRedirect(destination, host, target.path);
|
||||
if (selfRedirect) {
|
||||
const response = emptyResponse(404);
|
||||
return { response, outcome: 'invalid' };
|
||||
if (!isRoot) outcome = 'invalid';
|
||||
return { response, outcome };
|
||||
}
|
||||
|
||||
const response = redirectResponse(destination, redirect.status_code);
|
||||
|
||||
@@ -36,7 +36,7 @@ test('reads prepared output and hides snapshots from a different retention polic
|
||||
};
|
||||
const days = visitDays(period);
|
||||
const details: VisitDetails = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope: summary.scope,
|
||||
period,
|
||||
daily: days.map((day, index) => ({
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { countBrowserOsVisitsByTimeframe } from '@goto/analytics';
|
||||
import {
|
||||
availableVisitTimeframes,
|
||||
countVisitWindows,
|
||||
visitWindow,
|
||||
type VisitTimeframe
|
||||
} from '@goto/analytics/daily';
|
||||
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
|
||||
import { currentLinkState } from '#lib/server/link-state.js';
|
||||
import { readDomainDetails } from '#lib/server/visit-reports.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
@@ -36,35 +31,32 @@ export const load = (async ({ locals, params }) => {
|
||||
domainDetails ?? undefined,
|
||||
windows
|
||||
);
|
||||
const baseCountsByTimeframe = countVisitWindows(detailsByPath.get('/')?.daily, windows);
|
||||
const invalidClicksByTimeframe: Partial<Record<VisitTimeframe, number>> = {};
|
||||
for (const { timeframe } of windows) {
|
||||
const totals = countsByTimeframe[timeframe];
|
||||
if (!totals) continue;
|
||||
const base = baseCountsByTimeframe[timeframe];
|
||||
if (!base) continue;
|
||||
invalidClicksByTimeframe[timeframe] = totals.invalidVisits - base.invalidVisits;
|
||||
}
|
||||
|
||||
let rootUrl: string | null = null;
|
||||
if (prepared.root !== null) rootUrl = prepared.root.url;
|
||||
else if (prepared.fallback !== null) rootUrl = prepared.fallback.url;
|
||||
const rootCountsByTimeframe = countVisitWindows(detailsByPath.get('/')?.daily, windows);
|
||||
const rootHref = linkHref(domain, '/');
|
||||
const now = Date.now();
|
||||
const links = Object.entries(prepared.links).map(([path, link]) => {
|
||||
const daily = detailsByPath.get(path)?.daily;
|
||||
const countsByTimeframe = countVisitWindows(daily, windows);
|
||||
return {
|
||||
path,
|
||||
url: link.url,
|
||||
state: currentLinkState(link, now),
|
||||
countsByTimeframe,
|
||||
href: linkHref(domain, path)
|
||||
};
|
||||
});
|
||||
const hasRoot = prepared.root !== null;
|
||||
let baseUrl: string | null = null;
|
||||
if (prepared.root !== null) {
|
||||
baseUrl = prepared.root.url;
|
||||
} else if (prepared.fallback !== null) {
|
||||
baseUrl = prepared.fallback.url;
|
||||
}
|
||||
const links = [
|
||||
{
|
||||
path: '/',
|
||||
url: rootUrl,
|
||||
state: 'active' as const,
|
||||
countsByTimeframe: rootCountsByTimeframe,
|
||||
href: rootHref
|
||||
},
|
||||
...Object.entries(prepared.links).map(([path, link]) => {
|
||||
const daily = detailsByPath.get(path)?.daily;
|
||||
const countsByTimeframe = countVisitWindows(daily, windows);
|
||||
return {
|
||||
path,
|
||||
url: link.url,
|
||||
state: currentLinkState(link, now),
|
||||
countsByTimeframe,
|
||||
href: linkHref(domain, path)
|
||||
};
|
||||
})
|
||||
];
|
||||
const asOf = domainDetails ? Date.parse(domainDetails.period.asOf) : null;
|
||||
const chart = domainDetails ? { daily: domainDetails.daily, period: domainDetails.period } : null;
|
||||
return {
|
||||
@@ -73,18 +65,13 @@ export const load = (async ({ locals, params }) => {
|
||||
collectionEnabled,
|
||||
available: domainDetails !== null,
|
||||
asOf,
|
||||
linkCount: links.length,
|
||||
linkCount: currentPaths.length,
|
||||
timeframes,
|
||||
countsByTimeframe,
|
||||
browserOsVisitsByTimeframe,
|
||||
invalidClicksByTimeframe,
|
||||
chart,
|
||||
base: {
|
||||
url: baseUrl,
|
||||
hasRoot,
|
||||
countsByTimeframe: baseCountsByTimeframe,
|
||||
href: linkHref(domain, '/')
|
||||
},
|
||||
root: prepared.root,
|
||||
fallback: prepared.fallback,
|
||||
links
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
data.chart ? visitGranularity(timeframe, data.chart.period.retentionMonths) : 'day'
|
||||
);
|
||||
let visits = $derived(data.countsByTimeframe[timeframe]?.validVisits ?? null);
|
||||
let baseVisits = $derived(data.base.countsByTimeframe[timeframe]?.validVisits ?? null);
|
||||
let invalidClicks = $derived(data.invalidClicksByTimeframe[timeframe] ?? null);
|
||||
let invalidClicks = $derived(data.countsByTimeframe[timeframe]?.invalidVisits ?? null);
|
||||
let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null);
|
||||
</script>
|
||||
|
||||
@@ -47,13 +46,6 @@
|
||||
{#if data.collectionEnabled}
|
||||
<StatCard label="Visits" value={visits} />
|
||||
<StatCard label="Invalid clicks" value={invalidClicks} />
|
||||
<a
|
||||
class="base-visits-link"
|
||||
href={`${data.base.href}?period=${timeframe}`}
|
||||
aria-label="Base visits"
|
||||
>
|
||||
<StatCard label="Base visits" value={baseVisits} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<section class="admin-info" aria-labelledby="domain-details-heading">
|
||||
@@ -61,14 +53,17 @@
|
||||
<dl>
|
||||
<dt>Domain</dt>
|
||||
<dd>{data.domain}</dd>
|
||||
<dt>Base destination</dt>
|
||||
<dt>Root</dt>
|
||||
<dd>
|
||||
{#if data.base.url}<a class="admin-destination" href={data.base.url}>{data.base.url}</a>
|
||||
{:else}404 Not found{/if}
|
||||
{#if data.root}
|
||||
<a class="admin-destination" href={data.root.url}>{data.root.url}</a>
|
||||
{:else}Uses fallback{/if}
|
||||
</dd>
|
||||
<dt>Base status</dt>
|
||||
<dt>Fallback</dt>
|
||||
<dd>
|
||||
{#if data.base.hasRoot}Active{:else}Fallback{/if}
|
||||
{#if data.fallback}
|
||||
<a class="admin-destination" href={data.fallback.url}>{data.fallback.url}</a>
|
||||
{:else}404 Not found{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -80,22 +75,11 @@
|
||||
{/if}
|
||||
<section>
|
||||
<h2>Links</h2>
|
||||
{#if data.links.length === 0}<p>No links configured.</p>
|
||||
{:else}
|
||||
<LinkInventory
|
||||
label={`Links on ${data.domain}`}
|
||||
links={data.links}
|
||||
{timeframe}
|
||||
collectionEnabled={data.collectionEnabled}
|
||||
/>
|
||||
{/if}
|
||||
<LinkInventory
|
||||
label={`Links on ${data.domain}`}
|
||||
links={data.links}
|
||||
{timeframe}
|
||||
collectionEnabled={data.collectionEnabled}
|
||||
/>
|
||||
</section>
|
||||
</AdminPage>
|
||||
|
||||
<style>
|
||||
.base-visits-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { countBrowserOsVisitsByTimeframe } from '@goto/analytics';
|
||||
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
|
||||
import { readVisitDetails } from '#lib/server/visit-reports.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async ({ locals, params }) => {
|
||||
const snapshot = locals.snapshot;
|
||||
const domain = params.domain;
|
||||
if (!Object.hasOwn(snapshot.domains, domain)) throw error(404, 'Domain not configured');
|
||||
const prepared = snapshot.domains[domain]!;
|
||||
const response = prepared.root;
|
||||
const fallback = prepared.fallback;
|
||||
const configuration = snapshot.configuration;
|
||||
const retentionMonths = configuration.analytics.retention_months;
|
||||
const collectionEnabled = retentionMonths !== 0;
|
||||
const scope = { kind: 'link' as const, domain, path: '/' };
|
||||
const details = collectionEnabled ? await readVisitDetails(configuration, scope) : null;
|
||||
const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths);
|
||||
const windows = details
|
||||
? timeframes.map((timeframe) => ({ timeframe, window: visitWindow(details.period, timeframe) }))
|
||||
: [];
|
||||
const countsByTimeframe = countVisitWindows(details?.daily, windows);
|
||||
const browserOsVisitsByTimeframe = countBrowserOsVisitsByTimeframe(details ?? undefined, windows);
|
||||
const asOf = details ? Date.parse(details.period.asOf) : null;
|
||||
const chart = details ? { daily: details.daily, period: details.period } : null;
|
||||
|
||||
return {
|
||||
domain,
|
||||
domainHref: `/admin/domains/${encodeURIComponent(domain)}`,
|
||||
response,
|
||||
fallback,
|
||||
fallbackSource: prepared.provenance.fallback,
|
||||
retentionMonths,
|
||||
collectionEnabled,
|
||||
available: details !== null,
|
||||
asOf,
|
||||
timeframes,
|
||||
countsByTimeframe,
|
||||
browserOsVisitsByTimeframe,
|
||||
chart
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolveVisitTimeframe, visitGranularity, visitWindow } from '@goto/analytics/daily';
|
||||
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
|
||||
import BrowserOsBreakdowns from '#lib/components/admin/BrowserOsBreakdowns.svelte';
|
||||
import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte';
|
||||
import AdminPage from '#lib/components/admin/AdminPage.svelte';
|
||||
import StatCard from '#lib/components/admin/StatCard.svelte';
|
||||
import ReportNotice from '#lib/components/admin/ReportNotice.svelte';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let currentUrl = $derived(page.shallow?.url ?? page.url);
|
||||
let timeframe = $derived(
|
||||
resolveVisitTimeframe(data.timeframes, currentUrl.searchParams.get('period'))
|
||||
);
|
||||
let window = $derived(data.chart ? visitWindow(data.chart.period, timeframe) : null);
|
||||
let granularity = $derived(
|
||||
data.chart ? visitGranularity(timeframe, data.chart.period.retentionMonths) : 'day'
|
||||
);
|
||||
let visits = $derived(data.countsByTimeframe[timeframe]?.validVisits ?? null);
|
||||
let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null);
|
||||
</script>
|
||||
|
||||
<AdminPage
|
||||
siteTitle={data.title}
|
||||
title="Root"
|
||||
documentTitle={`Root — ${data.domain}`}
|
||||
updatedAt={data.asOf}
|
||||
overviewHref={`/admin?period=${timeframe}`}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if data.available && data.timeframes.length > 0}
|
||||
<VisitTimeframes
|
||||
timeframes={data.timeframes}
|
||||
retentionMonths={data.retentionMonths}
|
||||
value={timeframe}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<ReportNotice collectionEnabled={data.collectionEnabled} available={data.available} />
|
||||
<div class="admin-cards">
|
||||
{#if data.collectionEnabled}
|
||||
<StatCard label="Visits" value={visits} />
|
||||
{/if}
|
||||
</div>
|
||||
<section class="admin-info" aria-labelledby="root-details-heading">
|
||||
<h2 id="root-details-heading">Root details</h2>
|
||||
<dl>
|
||||
<dt>Domain</dt>
|
||||
<dd><a href={`${data.domainHref}?period=${timeframe}`}>{data.domain}</a></dd>
|
||||
<dt>Address</dt>
|
||||
<dd>{data.domain}/</dd>
|
||||
<dt>Destination</dt>
|
||||
<dd>
|
||||
{#if data.response}
|
||||
<a class="admin-destination" href={data.response.url}>
|
||||
{data.response.url}
|
||||
</a>
|
||||
{:else}
|
||||
<a href="#fallback">Uses fallback</a>
|
||||
{/if}
|
||||
</dd>
|
||||
{#if data.response}
|
||||
<dt>HTTP status</dt>
|
||||
<dd>{data.response.status_code}</dd>
|
||||
<dt>Forward query</dt>
|
||||
<dd>{data.response.forward_query ? 'Yes' : 'No'}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
<section id="fallback" class="admin-info" aria-labelledby="fallback-heading">
|
||||
<h2 id="fallback-heading">Fallback</h2>
|
||||
<dl>
|
||||
<dt>Source</dt>
|
||||
<dd>
|
||||
{#if data.fallbackSource === 'domain'}Domain
|
||||
{:else if data.fallbackSource === 'global'}Global
|
||||
{:else}Default{/if}
|
||||
</dd>
|
||||
<dt>Destination</dt>
|
||||
<dd>
|
||||
{#if data.fallback}
|
||||
<a class="admin-destination" href={data.fallback.url}>
|
||||
{data.fallback.url}
|
||||
</a>
|
||||
{:else}404 Not found{/if}
|
||||
</dd>
|
||||
{#if data.fallback}
|
||||
<dt>HTTP status</dt>
|
||||
<dd>{data.fallback.status_code}</dd>
|
||||
<dt>Forward query</dt>
|
||||
<dd>{data.fallback.forward_query ? 'Yes' : 'No'}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
{#if data.chart && window}
|
||||
<VisitsChart daily={data.chart.daily} {window} {granularity} />
|
||||
{/if}
|
||||
{#if browserOsVisits && visits !== null}
|
||||
<BrowserOsBreakdowns {browserOsVisits} totalVisits={visits} />
|
||||
{/if}
|
||||
</AdminPage>
|
||||
+16
-23
@@ -1,7 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { countBrowserOsVisitsByTimeframe } from '@goto/analytics';
|
||||
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
|
||||
import { currentLinkState, type LinkState } from '#lib/server/link-state.js';
|
||||
import { currentLinkState } from '#lib/server/link-state.js';
|
||||
import { readVisitDetails } from '#lib/server/visit-reports.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
@@ -12,26 +12,16 @@ export const load = (async ({ locals, params }) => {
|
||||
const prepared = snapshot.domains[domain]!;
|
||||
// Kit has decoded route parameters; preserve literal percent paths as-is.
|
||||
const path = `/${params.path ?? ''}`;
|
||||
if (path !== '/' && !Object.hasOwn(prepared.links, path)) throw error(404, 'Link not configured');
|
||||
let url: string | null = null;
|
||||
let state: LinkState | 'fallback';
|
||||
if (path === '/') {
|
||||
const root = prepared.root;
|
||||
if (root !== null) {
|
||||
url = root.url;
|
||||
state = 'active';
|
||||
} else {
|
||||
state = 'fallback';
|
||||
}
|
||||
} else {
|
||||
const link = prepared.links[path]!;
|
||||
url = link.url;
|
||||
state = currentLinkState(link, Date.now());
|
||||
}
|
||||
let fallback: string | 404 = 404;
|
||||
if (prepared.fallback !== null) {
|
||||
fallback = prepared.fallback.url;
|
||||
}
|
||||
if (!Object.hasOwn(prepared.links, path)) throw error(404, 'Link not configured');
|
||||
const link = prepared.links[path]!;
|
||||
const response = {
|
||||
url: link.url,
|
||||
status_code: link.status_code,
|
||||
forward_query: link.forward_query
|
||||
};
|
||||
const state = currentLinkState(link, Date.now());
|
||||
const validFrom = link.valid_from ?? null;
|
||||
const validUntil = link.valid_until ?? null;
|
||||
const configuration = snapshot.configuration;
|
||||
const retentionMonths = configuration.analytics.retention_months;
|
||||
const collectionEnabled = retentionMonths !== 0;
|
||||
@@ -50,9 +40,12 @@ export const load = (async ({ locals, params }) => {
|
||||
domain,
|
||||
path,
|
||||
domainHref: `/admin/domains/${encodeURIComponent(domain)}`,
|
||||
url,
|
||||
fallback,
|
||||
response,
|
||||
state,
|
||||
validFrom,
|
||||
validUntil,
|
||||
tags: link.tags,
|
||||
sources: link.provenance,
|
||||
retentionMonths,
|
||||
collectionEnabled,
|
||||
available: details !== null,
|
||||
|
||||
+48
-4
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolveVisitTimeframe, visitGranularity, visitWindow } from '@goto/analytics/daily';
|
||||
import type { SettingSource } from '@goto/config';
|
||||
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
|
||||
import BrowserOsBreakdowns from '#lib/components/admin/BrowserOsBreakdowns.svelte';
|
||||
import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte';
|
||||
import AdminPage from '#lib/components/admin/AdminPage.svelte';
|
||||
import StatCard from '#lib/components/admin/StatCard.svelte';
|
||||
import ReportNotice from '#lib/components/admin/ReportNotice.svelte';
|
||||
import { formatUtc } from '#lib/components/admin/format.js';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -22,6 +24,15 @@
|
||||
let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null);
|
||||
</script>
|
||||
|
||||
{#snippet sourceHint(source: SettingSource)}
|
||||
<span class="source-hint">
|
||||
{#if source === 'builtin'}Built-in
|
||||
{:else if source === 'global'}Global defaults
|
||||
{:else if source === 'domain'}Domain defaults
|
||||
{:else}Link override{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<AdminPage
|
||||
siteTitle={data.title}
|
||||
title="Link"
|
||||
@@ -51,8 +62,6 @@
|
||||
<StatCard label="Status" value="Scheduled" />
|
||||
{:else if data.state === 'expired'}
|
||||
<StatCard label="Status" value="Expired" />
|
||||
{:else}
|
||||
<StatCard label="Status" value="Fallback" />
|
||||
{/if}
|
||||
</div>
|
||||
<section class="admin-info" aria-labelledby="link-details-heading">
|
||||
@@ -64,9 +73,36 @@
|
||||
<dd>{data.path}</dd>
|
||||
<dt>Destination</dt>
|
||||
<dd>
|
||||
{#if data.url}<a class="admin-destination" href={data.url}>{data.url}</a>
|
||||
{:else if data.fallback === 404}404 Not found{:else}{data.fallback}{/if}
|
||||
<a class="admin-destination" href={data.response.url}>
|
||||
{data.response.url}
|
||||
</a>
|
||||
</dd>
|
||||
<dt>HTTP status</dt>
|
||||
<dd>
|
||||
{data.response.status_code}
|
||||
{@render sourceHint(data.sources.status_code)}
|
||||
</dd>
|
||||
<dt>Forward query</dt>
|
||||
<dd>
|
||||
{data.response.forward_query ? 'Yes' : 'No'}
|
||||
{@render sourceHint(data.sources.forward_query)}
|
||||
</dd>
|
||||
{#if data.validFrom}
|
||||
<dt>Starts</dt>
|
||||
<dd>
|
||||
<time datetime={data.validFrom}>{formatUtc(data.validFrom)}</time>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if data.validUntil}
|
||||
<dt>Expires</dt>
|
||||
<dd>
|
||||
<time datetime={data.validUntil}>{formatUtc(data.validUntil)}</time>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if data.tags.length > 0}
|
||||
<dt>Tags</dt>
|
||||
<dd>{data.tags.join(', ')}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
{#if data.chart && window}
|
||||
@@ -76,3 +112,11 @@
|
||||
<BrowserOsBreakdowns {browserOsVisits} totalVisits={visits} />
|
||||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
<style>
|
||||
.source-hint {
|
||||
margin-inline-start: calc(var(--space-1) * 2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
+50
-55
@@ -1,4 +1,4 @@
|
||||
import { validateConfig } from '@goto/config';
|
||||
import { validateConfig, type DomainConfiguration } from '@goto/config';
|
||||
import { createVisitPeriod, visitDays, type VisitDetails } from '@goto/analytics';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { prepareSnapshot } from '#lib/server/snapshot.js';
|
||||
@@ -9,27 +9,43 @@ vi.mock('#lib/server/visit-reports.js', () => ({ readVisitDetails: vi.fn() }));
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
const domain = 'go.example.test';
|
||||
function request(path = '/docs', host = domain, retention_months = 3, rootUrl?: string) {
|
||||
const linkMetadata = {
|
||||
response: { url: 'https://example.test/docs', status_code: 308, forward_query: true },
|
||||
state: 'active',
|
||||
validFrom: '2000-01-01T00:00:00Z',
|
||||
validUntil: '2100-01-01T00:00:00Z',
|
||||
tags: ['guides', 'reference'],
|
||||
sources: { status_code: 'link', forward_query: 'domain' }
|
||||
};
|
||||
|
||||
function request(path = '/docs', options: { host?: string; retentionMonths?: number } = {}) {
|
||||
const domainConfiguration: Partial<DomainConfiguration> = {
|
||||
link_defaults: { forward_query: true },
|
||||
links: {
|
||||
'/docs': {
|
||||
url: 'https://example.test/docs',
|
||||
status_code: 308,
|
||||
enabled: true,
|
||||
valid_from: '2000-01-01T00:00:00Z',
|
||||
valid_until: '2100-01-01T00:00:00Z',
|
||||
tags: ['guides', 'reference']
|
||||
},
|
||||
'/a/b': { url: 'https://example.test/nested', enabled: true, tags: [] },
|
||||
'/a%2Fb': { url: 'https://example.test/percent', enabled: false, tags: [] }
|
||||
}
|
||||
};
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
auth: { credentials: { username: 'test', password: 'link-test-password' } },
|
||||
analytics: { retention_months },
|
||||
domains: {
|
||||
[domain]: {
|
||||
...(rootUrl ? { root: { url: rootUrl } } : {}),
|
||||
links: {
|
||||
'/docs': { url: 'https://example.test/docs' },
|
||||
'/a/b': { url: 'https://example.test/nested' },
|
||||
'/a%2Fb': { url: 'https://example.test/percent', enabled: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
analytics: { retention_months: options.retentionMonths ?? 3 },
|
||||
link_defaults: { status_code: 301 },
|
||||
domains: { [domain]: domainConfiguration }
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
return {
|
||||
locals: { snapshot: prepareSnapshot(prepared) },
|
||||
params: { domain: host, path: path.slice(1) }
|
||||
params: { domain: options.host ?? domain, path: path.slice(1) }
|
||||
} as Parameters<typeof load>[0];
|
||||
}
|
||||
|
||||
@@ -48,7 +64,7 @@ const browserOsCounts = Object.entries(counts).map(([day, [validVisits, invalidV
|
||||
invalidVisits
|
||||
}));
|
||||
const details: VisitDetails = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope: { kind: 'link', domain, path: '/docs' },
|
||||
period,
|
||||
daily: visitDays(period).map((day) => {
|
||||
@@ -59,7 +75,7 @@ const details: VisitDetails = {
|
||||
operatingSystems: browserOsCounts
|
||||
};
|
||||
|
||||
test('projects only the requested link series and compact window counts and breakdowns', async () => {
|
||||
test('projects only requested link settings, series and compact window counts and breakdowns', async () => {
|
||||
vi.mocked(readVisitDetails).mockResolvedValue(details);
|
||||
const data = await load(request());
|
||||
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
|
||||
@@ -71,9 +87,7 @@ test('projects only the requested link series and compact window counts and brea
|
||||
domain,
|
||||
path: '/docs',
|
||||
domainHref: '/admin/domains/go.example.test',
|
||||
url: 'https://example.test/docs',
|
||||
fallback: 404,
|
||||
state: 'active',
|
||||
...linkMetadata,
|
||||
retentionMonths: 3,
|
||||
collectionEnabled: true,
|
||||
available: true,
|
||||
@@ -102,15 +116,21 @@ test('projects only the requested link series and compact window counts and brea
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps decoded nested and literal-percent identities distinct and supports historical root traffic', async () => {
|
||||
test('keeps decoded nested and literal-percent identities distinct', async () => {
|
||||
vi.mocked(readVisitDetails)
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a/b' } })
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a%2Fb' } })
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/' } });
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a%2Fb' } });
|
||||
const nested = await load(request('/a/b'));
|
||||
const percent = await load(request('/a%2Fb'));
|
||||
const root = await load(request('/'));
|
||||
expect(nested.path).toBe('/a/b');
|
||||
expect(nested).toMatchObject({
|
||||
response: { url: 'https://example.test/nested', status_code: 301, forward_query: true },
|
||||
state: 'active',
|
||||
validFrom: null,
|
||||
validUntil: null,
|
||||
tags: [],
|
||||
sources: { status_code: 'global', forward_query: 'domain' }
|
||||
});
|
||||
expect(percent.path).toBe('/a%2Fb');
|
||||
expect(percent.state).toBe('disabled');
|
||||
expect(percent.countsByTimeframe.all?.validVisits).toBe(15);
|
||||
@@ -124,38 +144,14 @@ test('keeps decoded nested and literal-percent identities distinct and supports
|
||||
domain,
|
||||
path: '/a%2Fb'
|
||||
});
|
||||
expect(readVisitDetails).toHaveBeenNthCalledWith(3, expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/'
|
||||
});
|
||||
expect(root.path).toBe('/');
|
||||
expect(root.state).toBe('fallback');
|
||||
expect(root.url).toBeNull();
|
||||
expect(root.fallback).toBe(404);
|
||||
expect(root.countsByTimeframe.all?.validVisits).toBe(15);
|
||||
});
|
||||
|
||||
test('shows the configured root response while preserving its historical analytics scope', async () => {
|
||||
vi.mocked(readVisitDetails).mockResolvedValue({
|
||||
...details,
|
||||
scope: { kind: 'link', domain, path: '/' }
|
||||
});
|
||||
const data = await load(request('/', domain, 3, 'https://example.test/start'));
|
||||
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/'
|
||||
});
|
||||
expect(data.path).toBe('/');
|
||||
expect(data.state).toBe('active');
|
||||
expect(data.url).toBe('https://example.test/start');
|
||||
expect(data.countsByTimeframe.all).toEqual({ validVisits: 15, invalidVisits: 5 });
|
||||
});
|
||||
|
||||
test('rejects unknown scopes before reading and keeps unavailable counts absent', async () => {
|
||||
await expect(load(request('/docs', 'unknown.test'))).rejects.toMatchObject({ status: 404 });
|
||||
await expect(load(request('/docs', { host: 'unknown.test' }))).rejects.toMatchObject({
|
||||
status: 404
|
||||
});
|
||||
await expect(load(request('/not-configured'))).rejects.toMatchObject({ status: 404 });
|
||||
await expect(load(request('/'))).rejects.toMatchObject({ status: 404 });
|
||||
expect(readVisitDetails).not.toHaveBeenCalled();
|
||||
vi.mocked(readVisitDetails).mockResolvedValue(null);
|
||||
const data = await load(request());
|
||||
@@ -164,12 +160,12 @@ test('rejects unknown scopes before reading and keeps unavailable counts absent'
|
||||
expect(data.chart).toBeNull();
|
||||
expect(data.asOf).toBeNull();
|
||||
expect(data.countsByTimeframe).toEqual({});
|
||||
expect(data.url).toBe('https://example.test/docs');
|
||||
expect(data).toMatchObject(linkMetadata);
|
||||
expect(data.retentionMonths).toBe(3);
|
||||
});
|
||||
|
||||
test('disabled analytics skips the report read and leaves link details usable', async () => {
|
||||
const data = await load(request('/docs', domain, 0));
|
||||
const data = await load(request('/docs', { retentionMonths: 0 }));
|
||||
expect(readVisitDetails).not.toHaveBeenCalled();
|
||||
expect(data.collectionEnabled).toBe(false);
|
||||
expect(data.browserOsVisitsByTimeframe).toEqual({});
|
||||
@@ -178,6 +174,5 @@ test('disabled analytics skips the report read and leaves link details usable',
|
||||
expect(data.timeframes).toEqual([]);
|
||||
expect(data.chart).toBeNull();
|
||||
expect(data.countsByTimeframe).toEqual({});
|
||||
expect(data.url).toBe('https://example.test/docs');
|
||||
expect(data.state).toBe('active');
|
||||
expect(data).toMatchObject(linkMetadata);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { validateConfig, type DomainConfiguration, type RedirectResponse } from '@goto/config';
|
||||
import { createVisitPeriod, visitDays, type VisitDetails } from '@goto/analytics';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { prepareSnapshot } from '#lib/server/snapshot.js';
|
||||
import { readVisitDetails } from '#lib/server/visit-reports.js';
|
||||
import { load } from './+page.server.js';
|
||||
|
||||
vi.mock('#lib/server/visit-reports.js', () => ({ readVisitDetails: vi.fn() }));
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
const domain = 'go.example.test';
|
||||
const fallback: RedirectResponse = {
|
||||
url: 'https://example.test/fallback',
|
||||
status_code: 307,
|
||||
forward_query: true
|
||||
};
|
||||
|
||||
function request(options: { host?: string; root?: RedirectResponse } = {}) {
|
||||
const domainConfiguration: Partial<DomainConfiguration> = {
|
||||
fallback,
|
||||
link_defaults: { forward_query: true },
|
||||
links: {}
|
||||
};
|
||||
if (options.root) domainConfiguration.root = options.root;
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
auth: { credentials: { username: 'test', password: 'link-test-password' } },
|
||||
analytics: { retention_months: 3 },
|
||||
link_defaults: { status_code: 301 },
|
||||
domains: { [domain]: domainConfiguration }
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
return {
|
||||
locals: { snapshot: prepareSnapshot(prepared) },
|
||||
params: { domain: options.host ?? domain }
|
||||
} as Parameters<typeof load>[0];
|
||||
}
|
||||
|
||||
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
|
||||
const rootBrowserOsCounts = [
|
||||
{ day: period.from.slice(0, 10), name: 'Other', validVisits: 7, invalidVisits: 0 }
|
||||
];
|
||||
const rootDetails: VisitDetails = {
|
||||
version: 3,
|
||||
period,
|
||||
scope: { kind: 'link', domain, path: '/' },
|
||||
daily: visitDays(period).map((day, index) => ({
|
||||
...day,
|
||||
validVisits: index === 0 ? 7 : 0,
|
||||
invalidVisits: 0
|
||||
})),
|
||||
browsers: rootBrowserOsCounts,
|
||||
operatingSystems: rootBrowserOsCounts
|
||||
};
|
||||
|
||||
test('supports historical root traffic without a configured root', async () => {
|
||||
vi.mocked(readVisitDetails).mockResolvedValue(rootDetails);
|
||||
const data = await load(request());
|
||||
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/'
|
||||
});
|
||||
expect(data).toMatchObject({
|
||||
domain,
|
||||
domainHref: '/admin/domains/go.example.test',
|
||||
response: null,
|
||||
fallback,
|
||||
fallbackSource: 'domain'
|
||||
});
|
||||
expect(data.countsByTimeframe.all?.validVisits).toBe(7);
|
||||
});
|
||||
|
||||
test('keeps root HTTP settings independent of link defaults and fallback with historical analytics scope', async () => {
|
||||
vi.mocked(readVisitDetails).mockResolvedValue(rootDetails);
|
||||
const response: RedirectResponse = {
|
||||
url: 'https://example.test/start',
|
||||
status_code: 302,
|
||||
forward_query: false
|
||||
};
|
||||
const data = await load(request({ root: response }));
|
||||
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/'
|
||||
});
|
||||
expect(data).toMatchObject({
|
||||
response,
|
||||
fallback,
|
||||
fallbackSource: 'domain'
|
||||
});
|
||||
expect(data.countsByTimeframe.all).toEqual({ validVisits: 7, invalidVisits: 0 });
|
||||
});
|
||||
|
||||
test('keeps absent root and default or global fallback metadata usable without analytics', async () => {
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
auth: { credentials: { username: 'test', password: 'link-test-password' } },
|
||||
analytics: { retention_months: 0 },
|
||||
domains: { [domain]: { links: {} } }
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
const event = {
|
||||
locals: { snapshot: prepareSnapshot(prepared) },
|
||||
params: { domain }
|
||||
} as Parameters<typeof load>[0];
|
||||
const root = await load(event);
|
||||
expect(root).toMatchObject({
|
||||
response: null,
|
||||
fallback: null,
|
||||
fallbackSource: 'builtin'
|
||||
});
|
||||
const global = validateConfig(
|
||||
{
|
||||
auth: { credentials: { username: 'test', password: 'link-test-password' } },
|
||||
analytics: { retention_months: 0 },
|
||||
fallback,
|
||||
domains: { [domain]: { links: {} } }
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
event.locals.snapshot = prepareSnapshot(global);
|
||||
const inherited = await load(event);
|
||||
expect(inherited).toMatchObject({
|
||||
response: null,
|
||||
fallback,
|
||||
fallbackSource: 'global'
|
||||
});
|
||||
expect(readVisitDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects unknown domains before reading root traffic', async () => {
|
||||
await expect(load(request({ host: 'unknown.test' }))).rejects.toMatchObject({ status: 404 });
|
||||
expect(readVisitDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { validateConfig } from '@goto/config';
|
||||
import { validateConfig, type DomainConfiguration, type RedirectResponse } from '@goto/config';
|
||||
import { createVisitPeriod, visitDays, type ReportScope, type VisitDetails } from '@goto/analytics';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { prepareSnapshot } from '#lib/server/snapshot.js';
|
||||
@@ -11,20 +11,33 @@ beforeEach(() => vi.resetAllMocks());
|
||||
const domain = 'go.example.test';
|
||||
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
|
||||
|
||||
function request(host = domain, rootUrl?: string, retentionMonths = 3) {
|
||||
const links: Record<string, { url: string; enabled?: boolean }> = {
|
||||
function request(
|
||||
host = domain,
|
||||
settings: {
|
||||
root?: RedirectResponse;
|
||||
fallback?: RedirectResponse;
|
||||
globalFallback?: RedirectResponse;
|
||||
retentionMonths?: number;
|
||||
links?: Record<string, { url: string; enabled?: boolean }>;
|
||||
} = {}
|
||||
) {
|
||||
const links = settings.links ?? {
|
||||
'/docs': { url: 'https://example.test/docs' },
|
||||
'/a/b': { url: 'https://example.test/nested' },
|
||||
'/a%2Fb': { url: 'https://example.test/percent', enabled: false }
|
||||
};
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
auth: { credentials: { username: 'test', password: 'domain-test-password' } },
|
||||
analytics: { retention_months: retentionMonths },
|
||||
domains: { [domain]: { ...(rootUrl ? { root: { url: rootUrl } } : {}), links } }
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
const domainConfiguration: Pick<DomainConfiguration, 'root' | 'fallback'> & {
|
||||
links: typeof links;
|
||||
} = { links };
|
||||
if (settings.root) domainConfiguration.root = settings.root;
|
||||
if (settings.fallback) domainConfiguration.fallback = settings.fallback;
|
||||
const configuration: Record<string, unknown> = {
|
||||
auth: { credentials: { username: 'test', password: 'domain-test-password' } },
|
||||
analytics: { retention_months: settings.retentionMonths ?? 3 },
|
||||
domains: { [domain]: domainConfiguration }
|
||||
};
|
||||
if (settings.globalFallback) configuration.fallback = settings.globalFallback;
|
||||
const prepared = validateConfig(configuration, { env: {} });
|
||||
return {
|
||||
locals: { snapshot: prepareSnapshot(prepared) },
|
||||
params: { domain: host }
|
||||
@@ -45,7 +58,7 @@ function details(scope: ReportScope, counts: Record<string, [number, number]>):
|
||||
invalidVisits
|
||||
}));
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope,
|
||||
period,
|
||||
daily,
|
||||
@@ -57,20 +70,20 @@ function details(scope: ReportScope, counts: Record<string, [number, number]>):
|
||||
const domainDetails = details(
|
||||
{ kind: 'domain', domain },
|
||||
{
|
||||
'2026-03-15': [3, 2],
|
||||
'2026-03-15': [4, 1],
|
||||
'2026-05-16': [5, 4],
|
||||
'2026-06-08': [2, 3],
|
||||
'2026-06-09': [1, 2],
|
||||
'2026-06-15': [4, 3]
|
||||
'2026-06-08': [3, 2],
|
||||
'2026-06-09': [2, 1],
|
||||
'2026-06-15': [5, 2]
|
||||
}
|
||||
);
|
||||
const baseDetails = details(
|
||||
const rootDetails = details(
|
||||
{ kind: 'link', domain, path: '/' },
|
||||
{
|
||||
'2026-03-15': [3, 1],
|
||||
'2026-06-08': [0, 1],
|
||||
'2026-06-09': [0, 1],
|
||||
'2026-06-15': [0, 1]
|
||||
'2026-03-15': [4, 0],
|
||||
'2026-06-08': [1, 0],
|
||||
'2026-06-09': [1, 0],
|
||||
'2026-06-15': [1, 0]
|
||||
}
|
||||
);
|
||||
const docsDetails = details(
|
||||
@@ -85,15 +98,15 @@ const emptyDetails = details({ kind: 'link', domain, path: '/a%2Fb' }, {});
|
||||
const report = {
|
||||
domain: domainDetails,
|
||||
links: [
|
||||
{ path: '/', details: baseDetails },
|
||||
{ path: '/', details: rootDetails },
|
||||
{ path: '/docs', details: docsDetails },
|
||||
{ path: '/a%2Fb', details: emptyDetails }
|
||||
]
|
||||
};
|
||||
const baseCounts = {
|
||||
'7d': { validVisits: 0, invalidVisits: 2 },
|
||||
'30d': { validVisits: 0, invalidVisits: 3 },
|
||||
all: { validVisits: 3, invalidVisits: 4 }
|
||||
const rootCounts = {
|
||||
'7d': { validVisits: 2, invalidVisits: 0 },
|
||||
'30d': { validVisits: 3, invalidVisits: 0 },
|
||||
all: { validVisits: 7, invalidVisits: 0 }
|
||||
};
|
||||
|
||||
test('projects domain counts and browser/OS visits without exposing link series or dated browser/OS counts', async () => {
|
||||
@@ -113,33 +126,35 @@ test('projects domain counts and browser/OS visits without exposing link series
|
||||
linkCount: 3,
|
||||
timeframes: ['7d', '30d', 'all'],
|
||||
countsByTimeframe: {
|
||||
'7d': { validVisits: 5, invalidVisits: 5 },
|
||||
'30d': { validVisits: 7, invalidVisits: 8 },
|
||||
all: { validVisits: 15, invalidVisits: 14 }
|
||||
'7d': { validVisits: 7, invalidVisits: 3 },
|
||||
'30d': { validVisits: 10, invalidVisits: 5 },
|
||||
all: { validVisits: 19, invalidVisits: 10 }
|
||||
},
|
||||
browserOsVisitsByTimeframe: {
|
||||
'7d': {
|
||||
browsers: [{ name: 'Other', visits: 5 }],
|
||||
operatingSystems: [{ name: 'Other', visits: 5 }]
|
||||
},
|
||||
'30d': {
|
||||
browsers: [{ name: 'Other', visits: 7 }],
|
||||
operatingSystems: [{ name: 'Other', visits: 7 }]
|
||||
},
|
||||
'30d': {
|
||||
browsers: [{ name: 'Other', visits: 10 }],
|
||||
operatingSystems: [{ name: 'Other', visits: 10 }]
|
||||
},
|
||||
all: {
|
||||
browsers: [{ name: 'Other', visits: 15 }],
|
||||
operatingSystems: [{ name: 'Other', visits: 15 }]
|
||||
browsers: [{ name: 'Other', visits: 19 }],
|
||||
operatingSystems: [{ name: 'Other', visits: 19 }]
|
||||
}
|
||||
},
|
||||
invalidClicksByTimeframe: { '7d': 3, '30d': 5, all: 10 },
|
||||
chart: { daily: domainDetails.daily, period },
|
||||
base: {
|
||||
url: null,
|
||||
hasRoot: false,
|
||||
countsByTimeframe: baseCounts,
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
},
|
||||
root: null,
|
||||
fallback: null,
|
||||
links: [
|
||||
{
|
||||
path: '/',
|
||||
url: null,
|
||||
state: 'active',
|
||||
countsByTimeframe: rootCounts,
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
},
|
||||
{
|
||||
path: '/docs',
|
||||
url: 'https://example.test/docs',
|
||||
@@ -173,53 +188,132 @@ test('projects domain counts and browser/OS visits without exposing link series
|
||||
});
|
||||
});
|
||||
|
||||
test('shows an active root destination without reclassifying retained outcomes or subtracting valid root traffic', async () => {
|
||||
test('keeps root and fallback responses separate and includes all normalized root history in valid domain visits', async () => {
|
||||
vi.mocked(readDomainDetails).mockResolvedValue(report);
|
||||
const data = await load(request(domain, 'https://example.test/start'));
|
||||
expect(data.base.url).toBe('https://example.test/start');
|
||||
expect(data.base.hasRoot).toBe(true);
|
||||
expect(data.base.countsByTimeframe).toEqual(baseCounts);
|
||||
expect(data.countsByTimeframe.all?.validVisits).toBe(15);
|
||||
expect(data.invalidClicksByTimeframe.all).toBe(10);
|
||||
const root: RedirectResponse = {
|
||||
url: 'https://example.test/start',
|
||||
status_code: 307,
|
||||
forward_query: true
|
||||
};
|
||||
const fallback: RedirectResponse = {
|
||||
url: 'https://example.test/missing',
|
||||
status_code: 308,
|
||||
forward_query: false
|
||||
};
|
||||
const data = await load(
|
||||
request(domain, {
|
||||
root,
|
||||
fallback,
|
||||
globalFallback: { url: 'https://example.test/global', status_code: 302, forward_query: true }
|
||||
})
|
||||
);
|
||||
expect(data.root).toEqual(root);
|
||||
expect(data.fallback).toEqual(fallback);
|
||||
expect(data.links[0]).toEqual({
|
||||
path: '/',
|
||||
url: root.url,
|
||||
state: 'active',
|
||||
countsByTimeframe: rootCounts,
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
});
|
||||
expect(data.countsByTimeframe.all).toEqual({ validVisits: 19, invalidVisits: 10 });
|
||||
expect(data.linkCount).toBe(3);
|
||||
expect(data.links.map(({ path }) => path)).toEqual(['/docs', '/a/b', '/a%2Fb']);
|
||||
expect(data.links.map(({ path }) => path)).toEqual(['/', '/docs', '/a/b', '/a%2Fb']);
|
||||
});
|
||||
|
||||
test('missing base details leave base and non-base invalid counts unavailable, not zero', async () => {
|
||||
test('keeps root reachable through the domain fallback with no named links', async () => {
|
||||
vi.mocked(readDomainDetails).mockResolvedValue(report);
|
||||
const fallback: RedirectResponse = {
|
||||
url: 'https://example.test/missing',
|
||||
status_code: 302,
|
||||
forward_query: false
|
||||
};
|
||||
const data = await load(request(domain, { fallback, links: {} }));
|
||||
expect(readDomainDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), domain, []);
|
||||
expect(data.root).toBeNull();
|
||||
expect(data.fallback).toEqual(fallback);
|
||||
expect(data.linkCount).toBe(0);
|
||||
expect(data.links).toEqual([
|
||||
{
|
||||
path: '/',
|
||||
url: fallback.url,
|
||||
state: 'active',
|
||||
countsByTimeframe: rootCounts,
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
}
|
||||
]);
|
||||
expect(data.countsByTimeframe.all).toEqual({ validVisits: 19, invalidVisits: 10 });
|
||||
});
|
||||
|
||||
test('missing root details leave root visits unavailable without hiding non-root invalid counts', async () => {
|
||||
vi.mocked(readDomainDetails).mockResolvedValue({
|
||||
domain: domainDetails,
|
||||
links: [{ path: '/docs', details: docsDetails }]
|
||||
});
|
||||
const data = await load(request());
|
||||
expect(data.available).toBe(true);
|
||||
expect(data.base.countsByTimeframe).toEqual({});
|
||||
expect(data.invalidClicksByTimeframe).toEqual({});
|
||||
expect(data.countsByTimeframe.all?.validVisits).toBe(15);
|
||||
expect(data.links[0]).toEqual({
|
||||
path: '/',
|
||||
url: null,
|
||||
state: 'active',
|
||||
countsByTimeframe: {},
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
});
|
||||
expect(data.countsByTimeframe.all).toEqual({ validVisits: 19, invalidVisits: 10 });
|
||||
});
|
||||
|
||||
test('rejects unknown domains before reading and keeps unavailable inventory usable', async () => {
|
||||
await expect(load(request('unknown.test'))).rejects.toMatchObject({ status: 404 });
|
||||
expect(readDomainDetails).not.toHaveBeenCalled();
|
||||
vi.mocked(readDomainDetails).mockResolvedValue(null);
|
||||
const data = await load(request());
|
||||
const fallback: RedirectResponse = {
|
||||
url: 'https://example.test/global',
|
||||
status_code: 307,
|
||||
forward_query: true
|
||||
};
|
||||
const data = await load(request(domain, { globalFallback: fallback }));
|
||||
expect(data.root).toBeNull();
|
||||
expect(data.fallback).toEqual(fallback);
|
||||
expect(data.available).toBe(false);
|
||||
expect(data.browserOsVisitsByTimeframe).toEqual({});
|
||||
expect(data.chart).toBeNull();
|
||||
expect(data.countsByTimeframe).toEqual({});
|
||||
expect(data.invalidClicksByTimeframe).toEqual({});
|
||||
expect(data.links[0]?.countsByTimeframe).toEqual({});
|
||||
expect(data.links[0]).toEqual({
|
||||
path: '/',
|
||||
url: fallback.url,
|
||||
state: 'active',
|
||||
countsByTimeframe: {},
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
});
|
||||
expect(data.linkCount).toBe(3);
|
||||
});
|
||||
|
||||
test('disabled collection reads no reports and preserves current inventory', async () => {
|
||||
const data = await load(request(domain, undefined, 0));
|
||||
const root: RedirectResponse = {
|
||||
url: 'https://example.test/start',
|
||||
status_code: 301,
|
||||
forward_query: true
|
||||
};
|
||||
const fallback: RedirectResponse = {
|
||||
url: 'https://example.test/missing',
|
||||
status_code: 302,
|
||||
forward_query: false
|
||||
};
|
||||
const data = await load(request(domain, { root, fallback, retentionMonths: 0 }));
|
||||
expect(readDomainDetails).not.toHaveBeenCalled();
|
||||
expect(data.collectionEnabled).toBe(false);
|
||||
expect(data.browserOsVisitsByTimeframe).toEqual({});
|
||||
expect(data.timeframes).toEqual([]);
|
||||
expect(data.chart).toBeNull();
|
||||
expect(data.countsByTimeframe).toEqual({});
|
||||
expect(data.invalidClicksByTimeframe).toEqual({});
|
||||
expect(data.base.hasRoot).toBe(false);
|
||||
expect(data.root).toEqual(root);
|
||||
expect(data.fallback).toEqual(fallback);
|
||||
expect(data.links[0]).toEqual({
|
||||
path: '/',
|
||||
url: root.url,
|
||||
state: 'active',
|
||||
countsByTimeframe: {},
|
||||
href: '/admin/domains/go.example.test/links'
|
||||
});
|
||||
expect(data.linkCount).toBe(3);
|
||||
});
|
||||
@@ -4,21 +4,11 @@
|
||||
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
|
||||
import IconSpinner from 'virtual:icons/ph/spinner';
|
||||
import AdminPage from '#lib/components/admin/AdminPage.svelte';
|
||||
import { formatUtc } from '#lib/components/admin/format.js';
|
||||
import type { ActionData, PageData, SubmitFunction } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData | null } = $props();
|
||||
let pending = $state(false);
|
||||
const utcFormatter = new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
|
||||
function formatUtc(value: string): string {
|
||||
return `${utcFormatter.format(new Date(value))} UTC`;
|
||||
}
|
||||
|
||||
const checkWorker: SubmitFunction = () => {
|
||||
pending = true;
|
||||
return async ({ update }) => {
|
||||
@@ -41,11 +31,16 @@
|
||||
<dd>{data.appUrl}</dd>
|
||||
<dt>Authentication</dt>
|
||||
<dd>{data.authMode === 'oidc' ? 'OIDC' : 'Shared credentials'}</dd>
|
||||
<dt>Redirect status code</dt>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="admin-info" aria-labelledby="application-defaults-heading">
|
||||
<h2 id="application-defaults-heading">Application defaults</h2>
|
||||
<dl>
|
||||
<dt>Status code</dt>
|
||||
<dd>{data.redirectStatusCode}</dd>
|
||||
<dt>Forward query</dt>
|
||||
<dt>Query forwarding</dt>
|
||||
<dd>{data.forwardQuery ? 'Yes' : 'No'}</dd>
|
||||
<dt>Fallback destination</dt>
|
||||
<dt>Fallback</dt>
|
||||
<dd>{data.fallbackDestination === 404 ? '404 Not found' : data.fallbackDestination}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,7 @@ function request(overrides: Record<string, unknown> = {}) {
|
||||
function emptyOverview(): VisitDetails {
|
||||
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:34:56.000Z'));
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope: { kind: 'overview' },
|
||||
period,
|
||||
daily: visitDays(period).map((day) => ({ ...day, validVisits: 0, invalidVisits: 0 })),
|
||||
|
||||
@@ -58,7 +58,7 @@ function details(
|
||||
invalidVisits
|
||||
}));
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope,
|
||||
period,
|
||||
daily,
|
||||
|
||||
@@ -22,10 +22,10 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
|
||||
domain: null,
|
||||
path: null,
|
||||
day: '2026-05-31',
|
||||
validVisits: 1,
|
||||
invalidVisits: 4,
|
||||
baseValidVisits: 0,
|
||||
baseInvalidVisits: 2
|
||||
validVisits: 3,
|
||||
invalidVisits: 2,
|
||||
baseValidVisits: 2,
|
||||
baseInvalidVisits: 0
|
||||
}
|
||||
],
|
||||
browsers: [
|
||||
@@ -44,8 +44,8 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
|
||||
path: null,
|
||||
day: '2026-05-31',
|
||||
name: 'Firefox',
|
||||
validVisits: 1,
|
||||
invalidVisits: 4
|
||||
validVisits: 3,
|
||||
invalidVisits: 2
|
||||
}
|
||||
],
|
||||
operatingSystems: [
|
||||
@@ -64,8 +64,8 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
|
||||
path: null,
|
||||
day: '2026-05-31',
|
||||
name: 'Linux',
|
||||
validVisits: 1,
|
||||
invalidVisits: 4
|
||||
validVisits: 3,
|
||||
invalidVisits: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -77,18 +77,18 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
|
||||
version: 1,
|
||||
scope: { kind: 'overview' },
|
||||
period,
|
||||
totals: { validVisits: 3, invalidVisits: 5 },
|
||||
base: { validVisits: 1, invalidVisits: 2 },
|
||||
totals: { validVisits: 5, invalidVisits: 3 },
|
||||
base: { validVisits: 3, invalidVisits: 0 },
|
||||
domains: []
|
||||
});
|
||||
expect(report?.details.version).toBe(2);
|
||||
expect(report?.details.version).toBe(3);
|
||||
expect(report?.details.browsers).toEqual([
|
||||
{ day: '2026-02-28', name: 'Firefox', validVisits: 2, invalidVisits: 1 },
|
||||
{ day: '2026-05-31', name: 'Firefox', validVisits: 1, invalidVisits: 4 }
|
||||
{ day: '2026-05-31', name: 'Firefox', validVisits: 3, invalidVisits: 2 }
|
||||
]);
|
||||
expect(report?.details.operatingSystems).toEqual([
|
||||
{ day: '2026-02-28', name: 'Linux', validVisits: 2, invalidVisits: 1 },
|
||||
{ day: '2026-05-31', name: 'Linux', validVisits: 1, invalidVisits: 4 }
|
||||
{ day: '2026-05-31', name: 'Linux', validVisits: 3, invalidVisits: 2 }
|
||||
]);
|
||||
expect(report?.details.daily).toHaveLength(visitDays(period).length);
|
||||
expect(report?.details.daily[0]).toEqual({
|
||||
@@ -109,8 +109,8 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
|
||||
start: '2026-05-31T00:00:00.000Z',
|
||||
end: '2026-06-01T00:00:00.000Z',
|
||||
partial: true,
|
||||
validVisits: 1,
|
||||
invalidVisits: 4
|
||||
validVisits: 3,
|
||||
invalidVisits: 2
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,18 +126,18 @@ test('indexes explicit domain and link scopes, including empty links and root, w
|
||||
{
|
||||
...overview,
|
||||
day: '2026-06-01',
|
||||
validVisits: 8,
|
||||
invalidVisits: 3,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 1
|
||||
validVisits: 9,
|
||||
invalidVisits: 2,
|
||||
baseValidVisits: 2,
|
||||
baseInvalidVisits: 0
|
||||
},
|
||||
{
|
||||
...domain,
|
||||
day: '2026-06-01',
|
||||
validVisits: 4,
|
||||
invalidVisits: 2,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 1
|
||||
validVisits: 5,
|
||||
invalidVisits: 1,
|
||||
baseValidVisits: 2,
|
||||
baseInvalidVisits: 0
|
||||
},
|
||||
{
|
||||
...link,
|
||||
@@ -150,27 +150,27 @@ test('indexes explicit domain and link scopes, including empty links and root, w
|
||||
{
|
||||
...root,
|
||||
day: '2026-06-01',
|
||||
validVisits: 1,
|
||||
invalidVisits: 1,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 1
|
||||
validVisits: 2,
|
||||
invalidVisits: 0,
|
||||
baseValidVisits: 2,
|
||||
baseInvalidVisits: 0
|
||||
}
|
||||
],
|
||||
browsers: [
|
||||
{ ...overview, day: '2026-06-01', name: 'Other', validVisits: 8, invalidVisits: 3 },
|
||||
{ ...domain, day: '2026-06-01', name: 'Firefox', validVisits: 4, invalidVisits: 2 },
|
||||
{ ...overview, day: '2026-06-01', name: 'Other', validVisits: 9, invalidVisits: 2 },
|
||||
{ ...domain, day: '2026-06-01', name: 'Firefox', validVisits: 5, invalidVisits: 1 },
|
||||
{ ...link, day: '2026-06-01', name: 'Chrome', validVisits: 2, invalidVisits: 0 },
|
||||
{ ...root, day: '2026-06-01', name: 'Firefox', validVisits: 1, invalidVisits: 1 }
|
||||
{ ...root, day: '2026-06-01', name: 'Firefox', validVisits: 2, invalidVisits: 0 }
|
||||
],
|
||||
operatingSystems: [
|
||||
{ ...overview, day: '2026-06-01', name: 'Other', validVisits: 8, invalidVisits: 3 },
|
||||
{ ...domain, day: '2026-06-01', name: 'Linux', validVisits: 4, invalidVisits: 2 },
|
||||
{ ...overview, day: '2026-06-01', name: 'Other', validVisits: 9, invalidVisits: 2 },
|
||||
{ ...domain, day: '2026-06-01', name: 'Linux', validVisits: 5, invalidVisits: 1 },
|
||||
{ ...link, day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 },
|
||||
{ ...root, day: '2026-06-01', name: 'Linux', validVisits: 1, invalidVisits: 1 }
|
||||
{ ...root, day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 }
|
||||
]
|
||||
},
|
||||
[
|
||||
{ domain: 'go.example.test', paths: ['/docs', '/empty', '/'] },
|
||||
{ domain: 'go.example.test', paths: ['/docs', '/empty'] },
|
||||
{ domain: 'empty.example.test', paths: ['/docs'] }
|
||||
],
|
||||
period
|
||||
@@ -192,22 +192,21 @@ test('indexes explicit domain and link scopes, including empty links and root, w
|
||||
version: 1,
|
||||
scope: { kind: 'domain', domain: 'go.example.test' },
|
||||
period,
|
||||
totals: { validVisits: 4, invalidVisits: 2 },
|
||||
base: { validVisits: 1, invalidVisits: 1 },
|
||||
totals: { validVisits: 5, invalidVisits: 1 },
|
||||
base: { validVisits: 2, invalidVisits: 0 },
|
||||
links: [
|
||||
{ path: '/docs', totals: { validVisits: 2, invalidVisits: 0 } },
|
||||
{ path: '/empty', totals: { validVisits: 0, invalidVisits: 0 } },
|
||||
{ path: '/', totals: { validVisits: 1, invalidVisits: 1 } }
|
||||
{ path: '/empty', totals: { validVisits: 0, invalidVisits: 0 } }
|
||||
]
|
||||
});
|
||||
expect(overviewReport?.summary).toEqual({
|
||||
version: 1,
|
||||
scope: { kind: 'overview' },
|
||||
period,
|
||||
totals: { validVisits: 8, invalidVisits: 3 },
|
||||
base: { validVisits: 1, invalidVisits: 1 },
|
||||
totals: { validVisits: 9, invalidVisits: 2 },
|
||||
base: { validVisits: 2, invalidVisits: 0 },
|
||||
domains: [
|
||||
{ domain: 'go.example.test', totals: { validVisits: 4, invalidVisits: 2 } },
|
||||
{ domain: 'go.example.test', totals: { validVisits: 5, invalidVisits: 1 } },
|
||||
{ domain: 'empty.example.test', totals: { validVisits: 0, invalidVisits: 0 } }
|
||||
]
|
||||
});
|
||||
@@ -221,10 +220,21 @@ test('indexes explicit domain and link scopes, including empty links and root, w
|
||||
{ day: '2026-06-01', name: 'Chrome', validVisits: 2, invalidVisits: 0 }
|
||||
]);
|
||||
expect(domainReport?.details.browsers).toEqual([
|
||||
{ day: '2026-06-01', name: 'Firefox', validVisits: 4, invalidVisits: 2 }
|
||||
{ day: '2026-06-01', name: 'Firefox', validVisits: 5, invalidVisits: 1 }
|
||||
]);
|
||||
expect(base?.summary.totals).toEqual({ validVisits: 2, invalidVisits: 0 });
|
||||
expect(base?.details.daily.find(({ start }) => start === '2026-06-01T00:00:00.000Z')).toEqual({
|
||||
start: '2026-06-01T00:00:00.000Z',
|
||||
end: '2026-06-02T00:00:00.000Z',
|
||||
partial: false,
|
||||
validVisits: 2,
|
||||
invalidVisits: 0
|
||||
});
|
||||
expect(base?.details.browsers).toEqual([
|
||||
{ day: '2026-06-01', name: 'Firefox', validVisits: 2, invalidVisits: 0 }
|
||||
]);
|
||||
expect(base?.details.operatingSystems).toEqual([
|
||||
{ day: '2026-06-01', name: 'Linux', validVisits: 1, invalidVisits: 1 }
|
||||
{ day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 }
|
||||
]);
|
||||
expect(empty?.summary.totals).toEqual({ validVisits: 0, invalidVisits: 0 });
|
||||
expect(empty?.details.daily).toEqual(
|
||||
@@ -258,6 +268,7 @@ test('indexes explicit domain and link scopes, including empty links and root, w
|
||||
);
|
||||
expect(unconfiguredRoot?.summary.totals).toEqual({ validVisits: 0, invalidVisits: 0 });
|
||||
for (const report of reports) {
|
||||
expect(report.details.version).toBe(3);
|
||||
expect(report.details.scope).toEqual(report.summary.scope);
|
||||
expect(report.details.period).toEqual(period);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ function buildDetails(
|
||||
days: ReturnType<typeof visitDays>
|
||||
): VisitDetails {
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope,
|
||||
period,
|
||||
daily: buildDailyDetails(counts.daily, days),
|
||||
|
||||
@@ -47,6 +47,7 @@ const visit: ClassifiedVisit = {
|
||||
const overview = { kind: 'overview' as const };
|
||||
const domain = { kind: 'domain' as const, domain: 'go.example.test' };
|
||||
const docs = { kind: 'link' as const, domain: 'go.example.test', path: '/docs' };
|
||||
const root = { ...docs, path: '/' };
|
||||
|
||||
function sum(values: VisitTotals[]): VisitTotals {
|
||||
return values.reduce(
|
||||
@@ -69,10 +70,16 @@ test('publishes summary/detail pairs for all scopes, deletes expired history and
|
||||
.execute();
|
||||
await insertVisit(ctx.db, { ...visit, visited_at: cutoff });
|
||||
await insertVisit(ctx.db, { ...visit, path: '/', outcome: 'invalid', visited_at: now - 2000 });
|
||||
await insertVisit(ctx.db, {
|
||||
...visit,
|
||||
path: '/missing',
|
||||
outcome: 'invalid',
|
||||
visited_at: now - 1000
|
||||
});
|
||||
await prepareVisitReports(ctx, now);
|
||||
const summary = await readVisitSummary(ctx.db, domain);
|
||||
const details = await readVisitDetails(ctx.db, domain);
|
||||
expect(summary?.totals).toEqual({ validVisits: 1, invalidVisits: 1 });
|
||||
expect(summary?.totals).toEqual({ validVisits: 2, invalidVisits: 1 });
|
||||
expect(summary?.scope).toEqual(domain);
|
||||
expect(summary?.period.from).toBe('2026-02-28T12:00:00.000Z');
|
||||
expect(summary?.period.asOf).toBe('2026-05-31T12:00:00.000Z');
|
||||
@@ -83,17 +90,36 @@ test('publishes summary/detail pairs for all scopes, deletes expired history and
|
||||
invalidVisits: 0
|
||||
});
|
||||
expect(sum(details!.daily)).toEqual(summary?.totals);
|
||||
expect(details?.version).toBe(2);
|
||||
expect(details?.version).toBe(3);
|
||||
expect(details?.browsers).toEqual([
|
||||
{ day: '2026-02-28', name: 'Firefox', validVisits: 1, invalidVisits: 0 },
|
||||
{ day: '2026-05-31', name: 'Firefox', validVisits: 0, invalidVisits: 1 }
|
||||
{ day: '2026-05-31', name: 'Firefox', validVisits: 1, invalidVisits: 1 }
|
||||
]);
|
||||
expect(details?.operatingSystems).toEqual([
|
||||
{ day: '2026-02-28', name: 'Linux', validVisits: 1, invalidVisits: 0 },
|
||||
{ day: '2026-05-31', name: 'Linux', validVisits: 0, invalidVisits: 1 }
|
||||
{ day: '2026-05-31', name: 'Linux', validVisits: 1, invalidVisits: 1 }
|
||||
]);
|
||||
expect(await ctx.db.selectFrom('analytics_reports').select('id').execute()).toHaveLength(4);
|
||||
expect(await ctx.db.selectFrom('visits').select('id').execute()).toHaveLength(2);
|
||||
expect(await ctx.db.selectFrom('visits').select('id').execute()).toHaveLength(3);
|
||||
const rootDetails = await readVisitDetails(ctx.db, root);
|
||||
expect((await readVisitSummary(ctx.db, root))?.totals).toEqual({
|
||||
validVisits: 1,
|
||||
invalidVisits: 0
|
||||
});
|
||||
expect(sum(rootDetails!.daily)).toEqual({ validVisits: 1, invalidVisits: 0 });
|
||||
expect(rootDetails?.browsers).toEqual([
|
||||
{ day: '2026-05-31', name: 'Firefox', validVisits: 1, invalidVisits: 0 }
|
||||
]);
|
||||
expect(rootDetails?.operatingSystems).toEqual([
|
||||
{ day: '2026-05-31', name: 'Linux', validVisits: 1, invalidVisits: 0 }
|
||||
]);
|
||||
expect(
|
||||
await ctx.db
|
||||
.selectFrom('visits')
|
||||
.select(['path', 'outcome'])
|
||||
.where('path', '=', '/')
|
||||
.execute()
|
||||
).toEqual([{ path: '/', outcome: 'invalid' }]);
|
||||
await prepareVisitReports(ctx, now);
|
||||
expect((await readVisitSummary(ctx.db, overview))?.totals).toEqual(summary?.totals);
|
||||
} finally {
|
||||
|
||||
@@ -14,7 +14,7 @@ const summary: OverviewSummary = {
|
||||
domains: []
|
||||
};
|
||||
const details: VisitDetails = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope,
|
||||
period,
|
||||
daily: visitDays(period).map((day, index) => ({
|
||||
@@ -30,6 +30,12 @@ test('readers decode their self-contained document independently', () => {
|
||||
expect(parseVisitDetails(JSON.stringify(details), scope)).toEqual(details);
|
||||
});
|
||||
|
||||
test('details reader rejects version 2 reports with legacy Root classification', () => {
|
||||
expect(() => parseVisitDetails(JSON.stringify({ ...details, version: 2 }), scope)).toThrow(
|
||||
'Invalid analytics report'
|
||||
);
|
||||
});
|
||||
|
||||
test('details reader can require the same reporting policy and window', () => {
|
||||
expect(parseVisitDetails(JSON.stringify(details), scope, period)).toEqual(details);
|
||||
const nextPeriod = createVisitPeriod(3, Date.UTC(2026, 3, 2, 12));
|
||||
|
||||
@@ -34,7 +34,7 @@ function report(
|
||||
return {
|
||||
summary,
|
||||
details: {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope,
|
||||
period,
|
||||
daily: visitDays(period).map((day, index) => ({
|
||||
@@ -50,7 +50,11 @@ function report(
|
||||
}
|
||||
|
||||
test('encodes each summary variant and explicit root without changing document shapes', () => {
|
||||
const reports = [report(overview), report(domain), report(root, asOf, zeroVisitTotals())];
|
||||
const reports = [
|
||||
report(overview),
|
||||
report(domain),
|
||||
report(root, asOf, { validVisits: 2, invalidVisits: 0 })
|
||||
];
|
||||
const rows = encodeVisitReports(reports);
|
||||
|
||||
for (const [index, prepared] of reports.entries()) {
|
||||
|
||||
@@ -91,7 +91,7 @@ const dailyBrowserOsCounts = Type.Object(
|
||||
|
||||
export const VisitDetailsSchema = Type.Object(
|
||||
{
|
||||
version: Type.Literal(2),
|
||||
version: Type.Literal(3),
|
||||
scope: ReportScopeSchema,
|
||||
period: ReportPeriodSchema,
|
||||
daily: Type.Array(
|
||||
|
||||
@@ -71,7 +71,7 @@ async function seedVisits(): Promise<void> {
|
||||
.execute();
|
||||
}
|
||||
|
||||
test('aggregates explicit UTC scope identities, outcomes and root subsets', async () => {
|
||||
test('aggregates explicit UTC scopes with legacy Root outcomes normalized only in reports', async () => {
|
||||
await seedVisits();
|
||||
const result = await aggregateVisitReports(db, since, asOf, inventory);
|
||||
|
||||
@@ -93,10 +93,10 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
domain: null,
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
validVisits: 1,
|
||||
invalidVisits: 4,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 2
|
||||
validVisits: 3,
|
||||
invalidVisits: 2,
|
||||
baseValidVisits: 3,
|
||||
baseInvalidVisits: 0
|
||||
},
|
||||
{
|
||||
scopeKind: 'domain',
|
||||
@@ -113,10 +113,10 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
domain,
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
validVisits: 1,
|
||||
invalidVisits: 2,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 1
|
||||
validVisits: 2,
|
||||
invalidVisits: 1,
|
||||
baseValidVisits: 2,
|
||||
baseInvalidVisits: 0
|
||||
},
|
||||
{
|
||||
scopeKind: 'link',
|
||||
@@ -133,10 +133,10 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
domain,
|
||||
path: '/',
|
||||
day: '2026-02-01',
|
||||
validVisits: 1,
|
||||
invalidVisits: 1,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 1
|
||||
validVisits: 2,
|
||||
invalidVisits: 0,
|
||||
baseValidVisits: 2,
|
||||
baseInvalidVisits: 0
|
||||
},
|
||||
{
|
||||
scopeKind: 'domain',
|
||||
@@ -176,8 +176,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
name: 'Firefox',
|
||||
validVisits: 0,
|
||||
invalidVisits: 3
|
||||
validVisits: 2,
|
||||
invalidVisits: 1
|
||||
},
|
||||
{
|
||||
scopeKind: 'overview',
|
||||
@@ -214,8 +214,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
name: 'Linux',
|
||||
validVisits: 0,
|
||||
invalidVisits: 2
|
||||
validVisits: 2,
|
||||
invalidVisits: 0
|
||||
},
|
||||
{
|
||||
scopeKind: 'overview',
|
||||
@@ -254,8 +254,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
path: '/',
|
||||
day: '2026-02-01',
|
||||
name: 'Firefox',
|
||||
validVisits: 0,
|
||||
invalidVisits: 1
|
||||
validVisits: 1,
|
||||
invalidVisits: 0
|
||||
},
|
||||
{
|
||||
scopeKind: 'link',
|
||||
@@ -267,6 +267,54 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
|
||||
invalidVisits: 0
|
||||
}
|
||||
]);
|
||||
expect(result.browsers).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
scopeKind: 'domain',
|
||||
domain,
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
name: 'Firefox',
|
||||
validVisits: 1,
|
||||
invalidVisits: 0
|
||||
}
|
||||
])
|
||||
);
|
||||
expect(result.operatingSystems).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
scopeKind: 'domain',
|
||||
domain,
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
name: 'Linux',
|
||||
validVisits: 1,
|
||||
invalidVisits: 0
|
||||
},
|
||||
{
|
||||
scopeKind: 'link',
|
||||
domain,
|
||||
path: '/',
|
||||
day: '2026-02-01',
|
||||
name: 'Linux',
|
||||
validVisits: 1,
|
||||
invalidVisits: 0
|
||||
}
|
||||
])
|
||||
);
|
||||
expect(
|
||||
await db
|
||||
.selectFrom('visits')
|
||||
.select(['path', 'outcome'])
|
||||
.where('outcome', '=', 'invalid')
|
||||
.orderBy('visited_at')
|
||||
.execute()
|
||||
).toEqual([
|
||||
{ path: '/', outcome: 'invalid' },
|
||||
{ path: '/unconfigured', outcome: 'invalid' },
|
||||
{ path: '/', outcome: 'invalid' },
|
||||
{ path: '/docs', outcome: 'invalid' }
|
||||
]);
|
||||
expect(
|
||||
result.operatingSystems.filter(
|
||||
({ scopeKind, domain: rowDomain }) => scopeKind === 'link' && rowDomain === otherDomain
|
||||
@@ -304,10 +352,10 @@ test('empty inventory keeps historical overview traffic but no domain or link sc
|
||||
domain: null,
|
||||
path: null,
|
||||
day: '2026-02-01',
|
||||
validVisits: 1,
|
||||
invalidVisits: 4,
|
||||
baseValidVisits: 1,
|
||||
baseInvalidVisits: 2
|
||||
validVisits: 3,
|
||||
invalidVisits: 2,
|
||||
baseValidVisits: 3,
|
||||
baseInvalidVisits: 0
|
||||
}
|
||||
]);
|
||||
expect(
|
||||
|
||||
@@ -62,7 +62,8 @@ function scopedVisits(since: number, asOf: number, inventory: ReportInventory) {
|
||||
const currentLink = linkRules.length === 0 ? sql`0` : sql.join(linkRules, sql` OR `);
|
||||
return sql`
|
||||
WITH retained_visits AS (
|
||||
SELECT domain, path, outcome, visited_at, browser, os
|
||||
SELECT domain, path, CASE WHEN path = '/' THEN 'valid' ELSE outcome END AS outcome,
|
||||
visited_at, browser, os
|
||||
FROM visits
|
||||
WHERE visited_at >= ${since} AND visited_at < ${asOf}
|
||||
), scoped_visits AS (
|
||||
|
||||
@@ -56,7 +56,7 @@ function preparedReport(
|
||||
return {
|
||||
summary,
|
||||
details: {
|
||||
version: 2,
|
||||
version: 3,
|
||||
scope,
|
||||
period,
|
||||
daily: visitDays(period).map((day, index) => ({
|
||||
@@ -75,7 +75,7 @@ test('reads summary and details for each explicit SQL scope including root', asy
|
||||
const reports = [
|
||||
preparedReport(overviewScope, asOf, { validVisits: 2, invalidVisits: 1 }),
|
||||
preparedReport(domainScope, asOf, { validVisits: 1, invalidVisits: 1 }),
|
||||
preparedReport(rootScope)
|
||||
preparedReport(rootScope, asOf, { validVisits: 2, invalidVisits: 0 })
|
||||
];
|
||||
await publishVisitReports(db, encodeVisitReports(reports));
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ function requestPaths(links) {
|
||||
}
|
||||
|
||||
function visitOutcome(prepared, path, visitedAt) {
|
||||
if (path === '/') return prepared.root ? 'valid' : 'invalid';
|
||||
if (path === '/') return 'valid';
|
||||
const link = prepared.links[path];
|
||||
if (!link || !link.enabled) return 'invalid';
|
||||
if (link.valid_from !== undefined && visitedAt < Date.parse(link.valid_from)) return 'invalid';
|
||||
|
||||
@@ -91,7 +91,7 @@ test('fixed inputs produce repeatable retained traffic with request-time outcome
|
||||
assert(visit.user_agent.endsWith(' GotoSeed/42'));
|
||||
assert.deepEqual(classifyUserAgent(visit.user_agent), { browser: visit.browser, os: visit.os });
|
||||
if (visit.path === '/') {
|
||||
assert.equal(visit.outcome, visit.domain === 'one.example.test' ? 'valid' : 'invalid');
|
||||
assert.equal(visit.outcome, 'valid');
|
||||
}
|
||||
if (visit.path === '/future') assert.equal(visit.outcome, 'invalid');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user