diff --git a/worker/src/data.ts b/worker/src/data.ts index 57392075..7ecf0893 100644 --- a/worker/src/data.ts +++ b/worker/src/data.ts @@ -13,6 +13,26 @@ export const BRANDS_DOMAINS_URL = "https://brands.home-assistant.io/domains.json"; export const VERSION_URL = "https://version.home-assistant.io/dev.json"; +export interface WorkerEnv { + KV: KVNamespace; + NETLIFY_BUILD_HOOK: string; + SENTRY_DSN: string; + WORKER_ENV: string; +} + +export interface WorkerEvent { + env: WorkerEnv; + ctx: ExecutionContext; +} + +export interface FetchWorkerEvent extends WorkerEvent { + request: CfRequest; +} + +export interface ScheduledWorkerEvent extends WorkerEvent { + controller: ScheduledController; +} + export enum UuidMetadataKey { ADDED = "a", COUNTRY = "c", diff --git a/worker/src/handlers/post.ts b/worker/src/handlers/post.ts index 7ddfed05..58412591 100644 --- a/worker/src/handlers/post.ts +++ b/worker/src/handlers/post.ts @@ -1,7 +1,7 @@ // Receive data from a Home Assistant installation import { Toucan } from "toucan-js"; import { - CfRequest, + FetchWorkerEvent, generateUuidMetadata, IncomingPayload, KV_PREFIX_UUID, @@ -16,11 +16,11 @@ const expirationTtl = 5184000; const withRegion = new Set(["US"]); export async function handlePostWrapper( - request: CfRequest, + event: FetchWorkerEvent, sentry: Toucan ): Promise { try { - return await handlePost(request, sentry); + return await handlePost(event, sentry); } catch (e: any) { const captureId = sentry.captureException(e); console.error(`${e?.message} (${captureId})`); @@ -29,9 +29,10 @@ export async function handlePostWrapper( } export async function handlePost( - request: CfRequest, + event: FetchWorkerEvent, sentry: Toucan ): Promise { + const { request } = event; let incomingPayload; sentry.addBreadcrumb({ message: "Process started" }); const request_json = await request.json>(); @@ -64,11 +65,12 @@ export async function handlePost( const stored: { value?: IncomingPayload | null; metadata?: UuidMetadata | null; - } = await KV.getWithMetadata(storageKey, "json"); + } = await event.env.KV.getWithMetadata(storageKey, "json"); if (!stored || !stored.value) { sentry.addBreadcrumb({ message: "First contact for UUID, store payload" }); await storePayload( + event, storageKey, incomingPayload, stringifiedPayload, @@ -89,6 +91,7 @@ export async function handlePost( if (!deepEqual(stored.value, JSON.parse(stringifiedPayload))) { sentry.addBreadcrumb({ message: "Payload changed, update stored data" }); await storePayload( + event, storageKey, incomingPayload, stringifiedPayload, @@ -107,6 +110,7 @@ export async function handlePost( }); await storePayload( + event, storageKey, incomingPayload, stringifiedPayload, @@ -120,13 +124,14 @@ export async function handlePost( } async function storePayload( + event: FetchWorkerEvent, storageKey: string, payload: IncomingPayload, stringifiedPayload: string, currentTimestamp: number, metadata?: UuidMetadata | null ) { - await KV.put(storageKey, stringifiedPayload, { + await event.env.KV.put(storageKey, stringifiedPayload, { expirationTtl, metadata: generateUuidMetadata(payload, currentTimestamp, metadata), }); diff --git a/worker/src/handlers/schedule.ts b/worker/src/handlers/schedule.ts index 100d7ca6..638a4196 100644 --- a/worker/src/handlers/schedule.ts +++ b/worker/src/handlers/schedule.ts @@ -25,27 +25,28 @@ import { BRANDS_DOMAINS_URL, VERSION_URL, VersionResponse, + ScheduledWorkerEvent, } from "../data"; import { groupVersions } from "../utils/group-versions"; import { median } from "../utils/median"; import { migrateAnalyticsData } from "../utils/migrate"; export async function handleSchedule( - event: ScheduledEvent, + event: ScheduledWorkerEvent, sentry: Toucan ): Promise { - const scheduledTask = event.cron; + const scheduledTask = event.controller.cron; try { if (scheduledTask === ScheduledTask.PROCESS_QUEUE) { // Runs every 2 minutes - await processQueue(sentry); + await processQueue(event, sentry); } else if (scheduledTask === ScheduledTask.RESET_QUEUE) { // Runs every day - await resetQueue(sentry); + await resetQueue(event, sentry); } else if (scheduledTask === ScheduledTask.UPDATE_HISTORY) { // Runs every hour - await updateHistory(sentry); + await updateHistory(event, sentry); } else { throw new Error(`Unexpected schedule task: ${scheduledTask}`); } @@ -55,46 +56,55 @@ export async function handleSchedule( } } -const getQueueData = async (): Promise => - (await KV.get(KV_KEY_QUEUE, "json")) || createQueueDefaults(); +const getQueueData = async (event: ScheduledWorkerEvent): Promise => + (await event.env.KV.get(KV_KEY_QUEUE, "json")) || + createQueueDefaults(); -const getAnalyticsData = async (): Promise => { - const data = await KV.get(KV_KEY_CORE_ANALYTICS, "json"); +const getAnalyticsData = async ( + event: ScheduledWorkerEvent +): Promise => { + const data = await event.env.KV.get(KV_KEY_CORE_ANALYTICS, "json"); return migrateAnalyticsData(data); }; -async function resetQueue(sentry: Toucan): Promise { +async function resetQueue( + event: ScheduledWorkerEvent, + sentry: Toucan +): Promise { sentry.setTag("scheduled-task", "RESET_QUEUE"); sentry.addBreadcrumb({ message: "Process started" }); - const queue = await getQueueData(); + const queue = await getQueueData(event); sentry.setExtra("queue", queue); if (queue.entries.length === 0 && queue.process_complete) { sentry.addBreadcrumb({ message: "Store reset queue" }); - await KV.put(KV_KEY_QUEUE, JSON.stringify(createQueueDefaults())); + await event.env.KV.put(KV_KEY_QUEUE, JSON.stringify(createQueueDefaults())); } sentry.addBreadcrumb({ message: "Process complete" }); } -async function updateHistory(sentry: Toucan): Promise { +async function updateHistory( + event: ScheduledWorkerEvent, + sentry: Toucan +): Promise { sentry.setTag("scheduled-task", "UPDATE_HISTORY"); sentry.addBreadcrumb({ message: "Process started" }); let data = createQueueData(); sentry.addBreadcrumb({ message: "Get current data" }); - const analyticsData = await getAnalyticsData(); + const analyticsData = await getAnalyticsData(event); const timestamp = new Date().getTime(); const timestampString = String(timestamp); sentry.addBreadcrumb({ message: "List UUID entries" }); - const kv_list = await listKV(KV_PREFIX_UUID); + const kv_list = await listKV(event, KV_PREFIX_UUID); const missingMetata = kv_list.filter((entry) => !entry.metadata); async function handleMissingMetadata(entry: ListEntry) { - const value = await KV.get(entry.name, "json"); - await KV.put(entry.name, JSON.stringify(value), { + const value = await event.env.KV.get(entry.name, "json"); + await event.env.KV.put(entry.name, JSON.stringify(value), { expiration: entry.expiration, metadata: generateUuidMetadata(value!, timestamp), }); @@ -136,26 +146,29 @@ async function updateHistory(sentry: Toucan): Promise { timestamp: timestampString, active_installations, installation_types: data.installation_types, - versions: groupVersions(data.versions), + versions: groupVersions(event, data.versions), }); sentry.addBreadcrumb({ message: "Trigger Netlify build" }); - const resp = await fetch(NETLIFY_BUILD_HOOK, { method: "POST" }); + const resp = await fetch(event.env.NETLIFY_BUILD_HOOK, { method: "POST" }); if (!resp.ok) { throw new Error("Failed to call Netlify build hook"); } sentry.addBreadcrumb({ message: "Store data" }); - await KV.put(KV_KEY_CORE_ANALYTICS, JSON.stringify(analyticsData)); + await event.env.KV.put(KV_KEY_CORE_ANALYTICS, JSON.stringify(analyticsData)); sentry.addBreadcrumb({ message: "Process complete" }); } -async function processQueue(sentry: Toucan): Promise { +async function processQueue( + event: ScheduledWorkerEvent, + sentry: Toucan +): Promise { sentry.setTag("scheduled-task", "PROCESS_QUEUE"); sentry.addBreadcrumb({ message: "Process started" }); let maxWorkerInvocations = KV_MAX_PROCESS_ENTRIES; - let queue = await getQueueData(); + let queue = await getQueueData(event); sentry.setExtra("queue", queue); @@ -174,7 +187,7 @@ async function processQueue(sentry: Toucan): Promise { // Reset queue queue = createQueueDefaults(); - const kv_list = await listKV(KV_PREFIX_UUID); + const kv_list = await listKV(event, KV_PREFIX_UUID); maxWorkerInvocations -= Math.floor(kv_list.length / 1000); sentry.addBreadcrumb({ @@ -227,7 +240,7 @@ async function processQueue(sentry: Toucan): Promise { async function handleEntry(entryKey: string) { let entryData; - entryData = await KV.get(entryKey, "json"); + entryData = await event.env.KV.get(entryKey, "json"); if (entryData !== undefined && entryData !== null) { queue.data = combineEntryData( @@ -257,7 +270,7 @@ async function processQueue(sentry: Toucan): Promise { const timestampString = String(timestamp); const queue_data = processQueueData(queue.data); - const storedAnalytics = await getAnalyticsData(); + const storedAnalytics = await getAnalyticsData(event); storedAnalytics.current = { ...queue_data, @@ -266,38 +279,41 @@ async function processQueue(sentry: Toucan): Promise { }; sentry.addBreadcrumb({ message: "Trigger Netlify build" }); - const resp = await fetch(NETLIFY_BUILD_HOOK, { method: "POST" }); + const resp = await fetch(event.env.NETLIFY_BUILD_HOOK, { method: "POST" }); if (!resp.ok) { throw new Error("Failed to call Netlify build hook"); } sentry.addBreadcrumb({ message: "Store data" }); await Promise.all([ - KV.put( + event.env.KV.put( `${KV_PREFIX_HISTORY}:${timestampString}`, JSON.stringify(queue_data) ), - KV.put(KV_KEY_CORE_ANALYTICS, JSON.stringify(storedAnalytics)), - KV.put( + event.env.KV.put(KV_KEY_CORE_ANALYTICS, JSON.stringify(storedAnalytics)), + event.env.KV.put( KV_KEY_CUSTOM_INTEGRATIONS, JSON.stringify(queue.data.custom_integrations) ), - KV.put(KV_KEY_ADDONS, JSON.stringify(queue.data.addons)), + event.env.KV.put(KV_KEY_ADDONS, JSON.stringify(queue.data.addons)), ]); queue = createQueueDefaults(); queue.process_complete = true; } sentry.addBreadcrumb({ message: "Process complete" }); - await KV.put(KV_KEY_QUEUE, JSON.stringify(queue)); + await event.env.KV.put(KV_KEY_QUEUE, JSON.stringify(queue)); } -async function listKV(prefix: string): Promise { +async function listKV( + event: ScheduledWorkerEvent, + prefix: string +): Promise { let entries: ListEntry[] = []; let lastResponse; while (lastResponse === undefined || !lastResponse.list_complete) { - lastResponse = await KV.list({ + lastResponse = await event.env.KV.list({ prefix, cursor: lastResponse !== undefined ? lastResponse.cursor : undefined, }); diff --git a/worker/src/index.ts b/worker/src/index.ts index be9ad38c..95bc20ac 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,40 +1,50 @@ import { Toucan } from "toucan-js"; import { handlePostWrapper } from "./handlers/post"; import { handleSchedule } from "./handlers/schedule"; +import { FetchWorkerEvent, ScheduledWorkerEvent } from "./data"; -declare global { - const KV: KVNamespace; - const NETLIFY_BUILD_HOOK: string; - const SENTRY_DSN: string; - const WORKER_ENV: string; -} - -const sentryClient = (event: FetchEvent | ScheduledEvent, handler: string) => { +const sentryClient = ( + event: FetchWorkerEvent | ScheduledWorkerEvent, + handler: string +) => { const client = new Toucan({ - dsn: SENTRY_DSN, + dsn: event.env.SENTRY_DSN, requestDataOptions: { allowedHeaders: ["user-agent", "cf-ray"], }, // request does not exist on ScheduledEvent request: "request" in event ? event.request : undefined, - context: event, - environment: WORKER_ENV, + context: event.ctx, + environment: event.env.WORKER_ENV, + initialScope: { + tags: { + handler, + }, + }, }); - client.setTag("handler", handler); return client; }; -addEventListener("fetch", (event: FetchEvent) => { - if (event.request.method === "POST") { - event.respondWith( - handlePostWrapper(event.request, sentryClient(event, "post")) - ); - } else { - event.respondWith(new Response(null, { status: 405 })); - } -}); - -addEventListener("scheduled", (event) => { - event.waitUntil(handleSchedule(event, sentryClient(event, "schedule"))); -}); +export default { + fetch: async ( + request: FetchWorkerEvent["request"], + env: FetchWorkerEvent["env"], + ctx: FetchWorkerEvent["ctx"] + ) => { + const event: FetchWorkerEvent = { request, env, ctx }; + if (request.method === "POST") { + return await handlePostWrapper(event, sentryClient(event, "post")); + } else { + return new Response(null, { status: 405 }); + } + }, + scheduled: async ( + controller: ScheduledWorkerEvent["controller"], + env: ScheduledWorkerEvent["env"], + ctx: ScheduledWorkerEvent["ctx"] + ) => { + const event: ScheduledWorkerEvent = { controller, env, ctx }; + await handleSchedule(event, sentryClient(event, "schedule")); + }, +} as ExportedHandler; diff --git a/worker/src/utils/group-versions.ts b/worker/src/utils/group-versions.ts index ad8470d3..f9bf0b87 100644 --- a/worker/src/utils/group-versions.ts +++ b/worker/src/utils/group-versions.ts @@ -1,7 +1,12 @@ -export const groupVersions = (versions: Record) => { +import { WorkerEvent } from "../data"; + +export const groupVersions = ( + event: WorkerEvent, + versions: Record +) => { const releases: Record = {}; const releases_filtered: Record = {}; - const filterLowerLimit = WORKER_ENV === "dev" ? 10 : 100; + const filterLowerLimit = event.env.WORKER_ENV === "dev" ? 10 : 100; Object.keys(versions).forEach((version) => { const key: string = version.split(".").slice(0, 2).join("."); diff --git a/worker/tests/handlers/post.spec.ts b/worker/tests/handlers/post.spec.ts index 09322cd6..cf00b67c 100644 --- a/worker/tests/handlers/post.spec.ts +++ b/worker/tests/handlers/post.spec.ts @@ -1,50 +1,47 @@ import { handlePost } from "../../src/handlers/post"; -import { MockedConsole, MockedKV, MockedSentry } from "../mock"; +import { + BASE_PAYLOAD, + MockedConsole, + MockedFetchEvent, + MockedSentry, +} from "../mock"; class MockResponse {} -const BASE_PAYLOAD = { - uuid: "12345678901234567890123456789012", - installation_type: "Unknown", - version: "1970.1.1", -}; - describe("post handler", function () { - let MockRequest; let MockSentry; - let MockKV; beforeEach(() => { MockSentry = MockedSentry(); - (global as any).KV = MockKV = MockedKV(); (global as any).console = MockedConsole(); - MockRequest = { - json: async () => ({ ...BASE_PAYLOAD }), - cf: { country: "XX" }, - }; (global as any).Response = MockResponse; }); it("First interaction", async () => { - await handlePost(MockRequest, MockSentry); - expect(MockKV.getWithMetadata).toBeCalledWith( + const event = MockedFetchEvent({}); + await handlePost(event, MockSentry); + expect(event.env.KV.getWithMetadata).toBeCalledWith( "uuid:12345678901234567890123456789012", "json" ); expect(MockSentry.addBreadcrumb).toBeCalledWith({ message: "First contact for UUID, store payload", }); - expect(MockKV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledTimes(1); }); it("Time has passed", async () => { - MockKV.getWithMetadata = jest.fn(async () => ({ - value: { ...BASE_PAYLOAD, country: "XX" }, - metadata: { u: 161892932 }, - })); + const event = MockedFetchEvent({}); - await handlePost(MockRequest, MockSentry); - expect(MockKV.getWithMetadata).toBeCalledWith( + (event.env.KV.getWithMetadata as jest.Mock).mockImplementation( + async () => ({ + value: { ...BASE_PAYLOAD, country: "XX" }, + metadata: { u: 161892932 }, + }) + ); + + await handlePost(event, MockSentry); + expect(event.env.KV.getWithMetadata).toBeCalledWith( "uuid:12345678901234567890123456789012", "json" ); @@ -53,38 +50,42 @@ describe("post handler", function () { message: "Threshold has passed, update stored data", }) ); - expect(MockKV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledTimes(1); }); it("Data changed", async () => { - MockKV.getWithMetadata = jest.fn(async () => ({ - value: { ...BASE_PAYLOAD }, - })); - MockRequest.json = async () => ({ - ...BASE_PAYLOAD, - installation_type: "Home Assistant OS", - integrations: ["awesome"], - integration_count: 1, - addons: [ - { - slug: "test_addon", - version: "1970.1.1", - protected: true, - auto_update: false, - }, - ], + const event = MockedFetchEvent({ + payload: { + installation_type: "Home Assistant OS", + integrations: ["awesome"], + integration_count: 1, + addons: [ + { + slug: "test_addon", + version: "1970.1.1", + protected: true, + auto_update: false, + }, + ], + }, }); - await handlePost(MockRequest, MockSentry); - expect(MockKV.getWithMetadata).toBeCalledWith( + (event.env.KV.getWithMetadata as jest.Mock).mockImplementation( + async () => ({ + value: { ...BASE_PAYLOAD }, + }) + ); + + await handlePost(event, MockSentry); + expect(event.env.KV.getWithMetadata).toBeCalledWith( "uuid:12345678901234567890123456789012", "json" ); expect(MockSentry.addBreadcrumb).toBeCalledWith({ message: "Payload changed, update stored data", }); - expect(MockKV.put).toBeCalledTimes(1); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledWith( "uuid:12345678901234567890123456789012", expect.any(String), expect.objectContaining({ @@ -99,17 +100,20 @@ describe("post handler", function () { }); it("Nothing changed, time has not passed", async () => { - MockKV.getWithMetadata = jest.fn(async () => ({ - value: { ...BASE_PAYLOAD, country: "XX" }, - metadata: { u: new Date().getTime() }, - })); + const event = MockedFetchEvent({}); + (event.env.KV.getWithMetadata as jest.Mock).mockImplementation( + async () => ({ + value: { ...BASE_PAYLOAD, country: "XX" }, + metadata: { u: new Date().getTime() }, + }) + ); - await handlePost(MockRequest, MockSentry); - expect(MockKV.getWithMetadata).toBeCalledWith( + await handlePost(event, MockSentry); + expect(event.env.KV.getWithMetadata).toBeCalledWith( "uuid:12345678901234567890123456789012", "json" ); - expect(MockKV.put).toBeCalledTimes(0); + expect(event.env.KV.put).toBeCalledTimes(0); }); }); diff --git a/worker/tests/handlers/schedule.spec.ts b/worker/tests/handlers/schedule.spec.ts index af8c3c16..fdb8f757 100644 --- a/worker/tests/handlers/schedule.spec.ts +++ b/worker/tests/handlers/schedule.spec.ts @@ -10,21 +10,14 @@ import { SCHEMA_VERSION_QUEUE, } from "../../src/data"; import { handleSchedule } from "../../src/handlers/schedule"; -import { - MockedConsole, - MockedKV, - MockedScheduledEvent, - MockedSentry, -} from "../mock"; +import { MockedConsole, MockedScheduledEvent, MockedSentry } from "../mock"; describe("schedule handler", function () { let MockSentry; - let MockKV; let MockFetch; beforeEach(() => { MockSentry = MockedSentry(); - (global as any).KV = MockKV = MockedKV(); (global as any).console = MockedConsole(); (global as any).fetch = MockFetch = jest.fn(async () => ({ ok: true, @@ -39,8 +32,8 @@ describe("schedule handler", function () { }); describe("Unexpected task", function () { - const event: ScheduledEvent = MockedScheduledEvent({ - cron: "test", + const event = MockedScheduledEvent({ + controller: { cron: "test" }, }); it("Unexpected cron trigger", async () => { await handleSchedule(event, MockSentry); @@ -51,31 +44,34 @@ describe("schedule handler", function () { }); describe("RESET_QUEUE", function () { - const event: ScheduledEvent = MockedScheduledEvent({ - cron: ScheduledTask.RESET_QUEUE, - }); it("Not ready to reset", async () => { - MockKV.get = jest.fn(async () => ({ + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.RESET_QUEUE }, + }); + (event.env.KV.get as jest.Mock).mockImplementation(async () => ({ process_complete: false, entries: [], })); await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); expect(MockSentry.setTag).toBeCalledWith("scheduled-task", "RESET_QUEUE"); - expect(MockKV.put).toBeCalledTimes(0); + expect(event.env.KV.put).toBeCalledTimes(0); }); it("Queue handing is done, reset queue", async () => { - MockKV.get = jest.fn(async () => ({ + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.RESET_QUEUE }, + }); + (event.env.KV.get as jest.Mock).mockImplementation(async () => ({ process_complete: true, entries: [], })); await handleSchedule(event, MockSentry); - expect(MockKV.put).toBeCalledTimes(1); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledWith( KV_KEY_QUEUE, JSON.stringify(createQueueDefaults()) ); @@ -83,15 +79,14 @@ describe("schedule handler", function () { }); describe("UPDATE_HISTORY", function () { - const event: ScheduledEvent = MockedScheduledEvent({ - cron: ScheduledTask.UPDATE_HISTORY, - }); it("With migration", async () => { - MockKV.get = jest.fn(async () => ({ + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.UPDATE_HISTORY }, + }); + (event.env.KV.get as jest.Mock).mockImplementation(async () => ({ "1234": { active_installations: 3 }, })); - - MockKV.list = jest.fn(async () => ({ + (event.env.KV.list as jest.Mock).mockImplementation(async () => ({ list_complete: true, keys: [ { name: "uuid:1", metadata: { v: "2021.1.1", i: "o" } }, @@ -101,26 +96,29 @@ describe("schedule handler", function () { await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_CORE_ANALYTICS, "json"); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_CORE_ANALYTICS, "json"); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "UPDATE_HISTORY" ); - expect(MockKV.put).toBeCalledTimes(1); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledWith( KV_KEY_CORE_ANALYTICS, expect.stringContaining('"extended_data_from":3') ); }); it("Update history and partial current", async () => { - MockKV.get = jest.fn(async () => ({ + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.UPDATE_HISTORY }, + }); + (event.env.KV.get as jest.Mock).mockImplementation(async () => ({ current: { extended_data_from: 3 }, history: [], schema_version: SCHEMA_VERSION_ANALYTICS, })); - MockKV.list = jest.fn(async () => ({ + (event.env.KV.list as jest.Mock).mockImplementation(async () => ({ list_complete: true, keys: [ { name: "uuid:1", metadata: { v: "2021.1.1", i: "o" } }, @@ -132,33 +130,39 @@ describe("schedule handler", function () { await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_CORE_ANALYTICS, "json"); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_CORE_ANALYTICS, "json"); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "UPDATE_HISTORY" ); - expect(MockKV.put).toBeCalledTimes(1); - expect(MockKV.put).toBeCalledWith( + + expect(event.env.KV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledWith( KV_KEY_CORE_ANALYTICS, expect.stringContaining('"extended_data_from":3') ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( KV_KEY_CORE_ANALYTICS, expect.stringContaining('"active_installations":4') ); }); it("Entries with missing metadata", async () => { - MockKV.get = jest.fn(async (key: string) => { - const KV_DATA = { - [KV_KEY_CORE_ANALYTICS]: { "1234": { active_installations: 3 } }, - "uuid:1": { version: "123456" }, - }; - - return KV_DATA[key]; + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.UPDATE_HISTORY }, }); + (event.env.KV.get as jest.Mock).mockImplementation( + async (key: string) => { + const KV_DATA = { + [KV_KEY_CORE_ANALYTICS]: { "1234": { active_installations: 3 } }, + "uuid:1": { version: "123456" }, + }; + + return KV_DATA[key]; + } + ); - MockKV.list = jest.fn(async () => ({ + (event.env.KV.list as jest.Mock).mockImplementation(async () => ({ list_complete: true, keys: [ { name: "uuid:1", expiration: 1234567 }, @@ -168,12 +172,12 @@ describe("schedule handler", function () { await handleSchedule(event, MockSentry); expect(MockFetch).not.toBeCalled(); - expect(MockKV.get).toBeCalledWith(KV_KEY_CORE_ANALYTICS, "json"); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_CORE_ANALYTICS, "json"); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "UPDATE_HISTORY" ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( "uuid:1", expect.any(String), expect.objectContaining({ @@ -184,13 +188,15 @@ describe("schedule handler", function () { }); describe("PROCESS_QUEUE", function () { - const event: ScheduledEvent = MockedScheduledEvent({ - cron: ScheduledTask.PROCESS_QUEUE, - }); it("No queue - list 2000 (with pagination)", async () => { - MockKV.get = jest.fn(async () => createQueueDefaults()); + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.PROCESS_QUEUE }, + }); + (event.env.KV.get as jest.Mock).mockImplementation(async () => + createQueueDefaults() + ); - MockKV.list = jest.fn( + (event.env.KV.list as jest.Mock).mockImplementation( async (data: { prefix: string; cursor?: string }) => ({ keys: Array.from({ length: 1000 }, (_, i) => ({ name: `uuid:${i}` })), cursor: "abc", @@ -200,111 +206,128 @@ describe("schedule handler", function () { await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); - expect(MockKV.list).toBeCalledTimes(2); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); + expect(event.env.KV.list).toBeCalledTimes(2); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "PROCESS_QUEUE" ); - expect(MockKV.put).toBeCalledWith(KV_KEY_QUEUE, expect.any(String)); - expect(MockKV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledWith(KV_KEY_QUEUE, expect.any(String)); + expect(event.env.KV.put).toBeCalledTimes(1); }); it("Continue queue - 2000 entries left", async () => { - MockKV.get = jest.fn(async (key: string) => { - if (key === KV_KEY_QUEUE) { - return { - schema_version: SCHEMA_VERSION_QUEUE, - process_complete: false, - entries: Array.from({ length: 2000 }, (_, i) => ({ - name: `uuid:${i}`, - })), - data: createQueueData(), - }; - } - - return {}; + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.PROCESS_QUEUE }, }); + (event.env.KV.get as jest.Mock).mockImplementation( + async (key: string) => { + if (key === KV_KEY_QUEUE) { + return { + schema_version: SCHEMA_VERSION_QUEUE, + process_complete: false, + entries: Array.from({ length: 2000 }, (_, i) => ({ + name: `uuid:${i}`, + })), + data: createQueueData(), + }; + } + + return {}; + } + ); await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); - expect(MockKV.list).not.toBeCalled(); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); + expect(event.env.KV.list).not.toBeCalled(); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "PROCESS_QUEUE" ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( KV_KEY_QUEUE, expect.stringContaining('"process_complete":false') ); - expect(MockKV.put).toBeCalledTimes(1); + expect(event.env.KV.put).toBeCalledTimes(1); }); it("Continue queue - 500 entries left", async () => { - MockKV.get = jest.fn(async (key: string) => { - if (key === KV_KEY_QUEUE) { + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.PROCESS_QUEUE }, + }); + (event.env.KV.get as jest.Mock).mockImplementation( + async (key: string) => { + if (key === KV_KEY_QUEUE) { + return { + schema_version: SCHEMA_VERSION_QUEUE, + process_complete: false, + entries: Array.from({ length: 500 }, (_, i) => ({ + name: `uuid:${i}`, + })), + data: createQueueData(), + }; + } + return { - schema_version: SCHEMA_VERSION_QUEUE, - process_complete: false, - entries: Array.from({ length: 500 }, (_, i) => ({ - name: `uuid:${i}`, - })), - data: createQueueData(), + integrations: ["core_valid"], + custom_integrations: [ + { domain: "custom_invalid", version: "1.2.3" }, + { domain: "custom_valid", version: "1.2.3" }, + ], + operating_system: { + board: "invalid_board", + version: "1.2.3", + }, }; } - - return { - integrations: ["core_valid"], - custom_integrations: [ - { domain: "custom_invalid", version: "1.2.3" }, - { domain: "custom_valid", version: "1.2.3" }, - ], - operating_system: { - board: "invalid_board", - version: "1.2.3", - }, - }; - }); + ); await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); - expect(MockKV.list).not.toBeCalled(); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); + expect(event.env.KV.list).not.toBeCalled(); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "PROCESS_QUEUE" ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( KV_KEY_QUEUE, expect.stringContaining('"process_complete":true') ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( KV_KEY_CORE_ANALYTICS, expect.stringContaining("core_valid") ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( KV_KEY_CORE_ANALYTICS, expect.not.stringContaining("invalid_board") ); - expect(MockKV.put).toBeCalledWith(KV_KEY_ADDONS, expect.any(String)); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( + KV_KEY_ADDONS, + expect.any(String) + ); + expect(event.env.KV.put).toBeCalledWith( KV_KEY_CUSTOM_INTEGRATIONS, '{"custom_valid":{"total":500,"versions":{"1.2.3":500}}}' ); - expect(MockKV.put).toBeCalledWith( + expect(event.env.KV.put).toBeCalledWith( expect.stringContaining("history:"), expect.any(String) ); expect(MockFetch).toBeCalledTimes(3); - expect(MockKV.put).toBeCalledTimes(5); + expect(event.env.KV.put).toBeCalledTimes(5); }); it("Wait for reset", async () => { - MockKV.get = jest.fn(async () => ({ + const event = MockedScheduledEvent({ + controller: { cron: ScheduledTask.PROCESS_QUEUE }, + }); + + (event.env.KV.get as jest.Mock).mockImplementation(async () => ({ entries: [], process_complete: true, schema_version: SCHEMA_VERSION_QUEUE, @@ -312,14 +335,14 @@ describe("schedule handler", function () { await handleSchedule(event, MockSentry); - expect(MockKV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); + expect(event.env.KV.get).toBeCalledWith(KV_KEY_QUEUE, "json"); expect(MockSentry.setTag).toBeCalledWith( "scheduled-task", "PROCESS_QUEUE" ); - expect(MockKV.put).not.toBeCalled(); - expect(MockKV.list).not.toBeCalled(); + expect(event.env.KV.put).not.toBeCalled(); + expect(event.env.KV.list).not.toBeCalled(); expect(MockSentry.addBreadcrumb).toBeCalledWith({ message: "Process complete, waiting for reset", diff --git a/worker/tests/mock.ts b/worker/tests/mock.ts index 450a9c77..bdb4724c 100644 --- a/worker/tests/mock.ts +++ b/worker/tests/mock.ts @@ -1,9 +1,13 @@ -export const MockedKV = () => ({ - put: jest.fn(async () => {}), - get: jest.fn(async () => {}), - list: jest.fn(async () => {}), - getWithMetadata: jest.fn(async () => {}), -}); +import { ScheduledWorkerEvent, FetchWorkerEvent } from "../src/data"; + +export const MockedKV = () => + ({ + put: jest.fn(async () => {}), + get: jest.fn(async () => ""), + list: jest.fn(async () => {}), + delete: jest.fn(async () => {}), + getWithMetadata: jest.fn(async () => {}), + } as unknown as KVNamespace); export const MockedConsole = () => ({ log: jest.fn(), @@ -21,28 +25,64 @@ export const MockedSentry = () => ({ captureMessage: jest.fn(), }); -export const MockedScheduledEvent = (options: any): ScheduledEvent => ({ - type: "cron", - eventPhase: 0, - composed: true, - bubbles: true, - cancelable: true, - defaultPrevented: false, - returnValue: false, - timeStamp: 0, - isTrusted: true, - cancelBubble: false, - stopImmediatePropagation: () => ({}), - preventDefault: () => ({}), - stopPropagation: () => ({}), - composedPath: () => [], - NONE: 0, - CAPTURING_PHASE: 0, - AT_TARGET: 0, - BUBBLING_PHASE: 0, - cron: "", - scheduledTime: 0, - noRetry: () => ({}), - waitUntil: async (promise: Promise) => ({}), - ...options, +export const BASE_PAYLOAD = { + uuid: "12345678901234567890123456789012", + installation_type: "Unknown", + version: "1970.1.1", +}; + +export const MockedFetchEvent = (options: { + request?: Partial; + env?: Partial; + ctx?: Partial; + payload?: Record; +}): FetchWorkerEvent => ({ + request: { + cf: { + country: "XX", + ...options.request?.cf, + } as IncomingRequestCfProperties, + json: async () => ({ + ...BASE_PAYLOAD, + ...options.payload, + }), + ...options.request, + } as FetchWorkerEvent["request"], + env: { + SENTRY_DSN: "", + WORKER_ENV: "testing", + NETLIFY_BUILD_HOOK: "https://somesite/hook", + KV: MockedKV(), + ...options.env, + }, + ctx: { + waitUntil: () => {}, + passThroughOnException: () => {}, + ...options.ctx, + }, +}); + +export const MockedScheduledEvent = (options: { + controller?: Partial; + env?: Partial; + ctx?: Partial; +}): ScheduledWorkerEvent => ({ + controller: { + scheduledTime: 1234, + cron: "", + noRetry: () => {}, + ...options.controller, + }, + env: { + SENTRY_DSN: "", + WORKER_ENV: "testing", + NETLIFY_BUILD_HOOK: "https://somesite/hook", + KV: MockedKV(), + ...options.env, + }, + ctx: { + waitUntil: () => {}, + passThroughOnException: () => {}, + ...options.ctx, + }, }); diff --git a/worker/tests/utils/group-versions.spec.ts b/worker/tests/utils/group-versions.spec.ts index c10b3f76..c80353d4 100644 --- a/worker/tests/utils/group-versions.spec.ts +++ b/worker/tests/utils/group-versions.spec.ts @@ -1,4 +1,5 @@ import { groupVersions } from "../../src/utils/group-versions"; +import { MockedScheduledEvent } from "../mock"; const sampleData = { "1970.1.0": 11, @@ -20,8 +21,9 @@ describe("groupVersions", function () { }); it("test dev", function () { - (global as any).WORKER_ENV = "dev"; - expect(groupVersions(sampleData)).toStrictEqual({ + const event = MockedScheduledEvent({ env: { WORKER_ENV: "dev" } }); + + expect(groupVersions(event, sampleData)).toStrictEqual({ "1970.1": 11, "2021.4": 110, "2021.5": 150, @@ -30,8 +32,8 @@ describe("groupVersions", function () { }); it("test production", function () { - (global as any).WORKER_ENV = "production"; - expect(groupVersions(sampleData)).toStrictEqual({ + const event = MockedScheduledEvent({ env: { WORKER_ENV: "production" } }); + expect(groupVersions(event, sampleData)).toStrictEqual({ "2021.4": 110, "2021.5": 150, "2021.6": 119,