From 7b17701e89a9aabd98bb3b4d8c182323577b3c1d Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 13:04:01 -0700 Subject: [PATCH 01/19] Changed app to export an app factory instead of an instance of the app, for easier testing --- server.ts | 2 +- src/app.ts | 64 ++++++++++--------- .../{routes => api}/v1/page_hit.test.ts | 2 +- test/integration/api/web_analytics.test.ts | 9 +++ test/integration/app.test.ts | 2 +- .../validation-error-logging.test.ts | 2 +- 6 files changed, 47 insertions(+), 34 deletions(-) rename test/integration/{routes => api}/v1/page_hit.test.ts (93%) create mode 100644 test/integration/api/web_analytics.test.ts diff --git a/server.ts b/server.ts index bb6a892e..83afc136 100644 --- a/server.ts +++ b/server.ts @@ -12,7 +12,7 @@ if (isWorkerMode) { app = workerModule.default; } else { const appModule = await import('./src/app'); - app = appModule.default; + app = appModule.default(); } // Start the server if this file is run directly diff --git a/src/app.ts b/src/app.ts index f1fd1fa2..c9966408 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,34 +9,38 @@ import {errorHandler} from './utils/error-handler'; import v1Routes from './routes/v1'; import replyFrom from '@fastify/reply-from'; -const app = fastify({ - logger: getLoggerConfig(), - disableRequestLogging: true, - trustProxy: process.env.TRUST_PROXY !== 'false' -}).withTypeProvider(); - -// Register global validation error handler -app.setErrorHandler(errorHandler()); - -// Register reply-from plugin -app.register(replyFrom); - -// Register CORS plugin -app.register(corsPlugin); - -// Register logging plugin -app.register(loggingPlugin); - -// Register proxy plugin -app.register(proxyPlugin); - -// Register v1 routes -app.register(v1Routes, {prefix: '/api/v1'}); - -// Routes -app.get('/', async () => { - return 'Hello World - Github Actions Deployment Test'; -}); - -export default app; +function createApp() { + const app = fastify({ + logger: getLoggerConfig(), + disableRequestLogging: true, + trustProxy: process.env.TRUST_PROXY !== 'false' + }).withTypeProvider(); + + // Register global validation error handler + app.setErrorHandler(errorHandler()); + + // Register reply-from plugin + app.register(replyFrom); + + // Register CORS plugin + app.register(corsPlugin); + + // Register logging plugin + app.register(loggingPlugin); + + // Register proxy plugin + app.register(proxyPlugin); + + // Register v1 routes + app.register(v1Routes, {prefix: '/api/v1'}); + + // Routes + app.get('/', async () => { + return 'Hello World - Github Actions Deployment Test'; + }); + + return app; +} + +export default createApp; diff --git a/test/integration/routes/v1/page_hit.test.ts b/test/integration/api/v1/page_hit.test.ts similarity index 93% rename from test/integration/routes/v1/page_hit.test.ts rename to test/integration/api/v1/page_hit.test.ts index 5bc85823..bc0b08d5 100644 --- a/test/integration/routes/v1/page_hit.test.ts +++ b/test/integration/api/v1/page_hit.test.ts @@ -6,7 +6,7 @@ describe('/api/v1/page_hit', () => { beforeAll(async function () { const appModule = await import('../../../../src/app'); - fastify = appModule.default; + fastify = appModule.default(); await fastify.ready(); }); diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts new file mode 100644 index 00000000..2f471c27 --- /dev/null +++ b/test/integration/api/web_analytics.test.ts @@ -0,0 +1,9 @@ +import {describe, it} from 'vitest'; + +describe('Event Ingestion Endpoint', function () { + describe('POST /tb/web_analytics', function () { + it('should accept a default request from the tracking script', async function () { + + }); + }); +}); \ No newline at end of file diff --git a/test/integration/app.test.ts b/test/integration/app.test.ts index 15e799f8..bd8584d1 100644 --- a/test/integration/app.test.ts +++ b/test/integration/app.test.ts @@ -79,7 +79,7 @@ describe('Fastify App', () => { // Import directly from the source const appModule = await import('../../src/app'); - app = appModule.default; + app = appModule.default(); await app.ready(); proxyServer = app.server; }); diff --git a/test/integration/validation-error-logging.test.ts b/test/integration/validation-error-logging.test.ts index 60f59c91..199342b2 100644 --- a/test/integration/validation-error-logging.test.ts +++ b/test/integration/validation-error-logging.test.ts @@ -48,7 +48,7 @@ describe('Validation Error Logging', () => { process.env.PROXY_TARGET = targetUrl; const appModule = await import('../../src/app'); - app = appModule.default; + app = appModule.default(); await app.ready(); proxyServer = app.server; }); From c8c726ce2a6eca060f3f9cd158688c304919157e Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 15:36:38 -0700 Subject: [PATCH 02/19] Added endpoint validation tests for the current web_analytics endpoint --- test/integration/api/web_analytics.test.ts | 49 +++++++++++++++++-- .../fixtures/defaultValidRequestBody.json | 23 +++++++++ .../fixtures/defaultValidRequestHeaders.json | 5 ++ .../fixtures/defaultValidRequestQuery.json | 3 ++ .../fixtures/headersWithoutSiteUuid.json | 4 ++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 test/utils/fixtures/defaultValidRequestBody.json create mode 100644 test/utils/fixtures/defaultValidRequestHeaders.json create mode 100644 test/utils/fixtures/defaultValidRequestQuery.json create mode 100644 test/utils/fixtures/headersWithoutSiteUuid.json diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index 2f471c27..14adca22 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -1,9 +1,50 @@ -import {describe, it} from 'vitest'; +import {describe, expect, it, beforeEach} from 'vitest'; +import createApp from '../../../src/app'; +import {FastifyInstance} from 'fastify'; +import defaultValidRequestHeaders from '../../utils/fixtures/defaultValidRequestHeaders.json'; +import defaultValidRequestBody from '../../utils/fixtures/defaultValidRequestBody.json'; +import defaultValidRequestQuery from '../../utils/fixtures/defaultValidRequestQuery.json'; +import headersWithoutSiteUuid from '../../utils/fixtures/headersWithoutSiteUuid.json'; -describe('Event Ingestion Endpoint', function () { - describe('POST /tb/web_analytics', function () { - it('should accept a default request from the tracking script', async function () { +function handlerStub(_request, reply, done) { + reply.code(202); + done(); +} +describe('Unversioned API Endpoint', function () { + let app: FastifyInstance; + + describe('POST /tb/web_analytics', () => { + beforeEach(async function () { + app = createApp(); + app.addHook('preHandler', handlerStub); }); + + describe('Request validation', function () { + it('should accept a default valid request from the tracking script', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody + }); + expect(response.statusCode).toBe(202); + }); + + it('should reject a request with a missing site uuid header', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: headersWithoutSiteUuid, + body: defaultValidRequestBody + }); + const bodyJson = JSON.parse(response.body); + expect(response.statusCode).toBe(400); + expect(bodyJson.error).toBe('Bad Request'); + expect(bodyJson.message).toBe('headers must have required property \'x-site-uuid\''); + }); + }); }); }); \ No newline at end of file diff --git a/test/utils/fixtures/defaultValidRequestBody.json b/test/utils/fixtures/defaultValidRequestBody.json new file mode 100644 index 00000000..508f01e7 --- /dev/null +++ b/test/utils/fixtures/defaultValidRequestBody.json @@ -0,0 +1,23 @@ +{ + "timestamp": "2025-07-02T20:09:42.591Z", + "action": "page_hit", + "version": "1", + "payload": { + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + "locale": "en-US", + "location": "US", + "parsedReferrer": { + "source": null, + "medium": null, + "url": null + }, + "pathname": "/", + "href": "https://www.chrisraible.com/", + "event_id": "70ce307c-0b09-4bb5-b7c7-e41cf1085720", + "site_uuid": "940b73e9-4952-4752-b23d-9486f999c47e", + "post_uuid": "undefined", + "post_type": "null", + "member_uuid": "undefined", + "member_status": "undefined" + } +} \ No newline at end of file diff --git a/test/utils/fixtures/defaultValidRequestHeaders.json b/test/utils/fixtures/defaultValidRequestHeaders.json new file mode 100644 index 00000000..4d47a981 --- /dev/null +++ b/test/utils/fixtures/defaultValidRequestHeaders.json @@ -0,0 +1,5 @@ +{ + "Content-Type": "application/json", + "x-site-uuid": "3bfa03e9-b0e7-40dc-a9de-f4b8640b88cc", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" +} \ No newline at end of file diff --git a/test/utils/fixtures/defaultValidRequestQuery.json b/test/utils/fixtures/defaultValidRequestQuery.json new file mode 100644 index 00000000..361356fd --- /dev/null +++ b/test/utils/fixtures/defaultValidRequestQuery.json @@ -0,0 +1,3 @@ +{ + "name": "analytics_events" +} \ No newline at end of file diff --git a/test/utils/fixtures/headersWithoutSiteUuid.json b/test/utils/fixtures/headersWithoutSiteUuid.json new file mode 100644 index 00000000..32e5a99c --- /dev/null +++ b/test/utils/fixtures/headersWithoutSiteUuid.json @@ -0,0 +1,4 @@ +{ + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" +} \ No newline at end of file From d94fd558b1b9ef40aad2a844a2f198a48e9a39aa Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 16:09:55 -0700 Subject: [PATCH 03/19] Added tests for invalid content type and missing user agent --- test/integration/api/web_analytics.test.ts | 64 +++++++++++++------ ...ectUnsupportedMediaTypeErrorWithMessage.ts | 9 +++ .../expectValidationErrorWithMessage.ts | 9 +++ test/utils/assertions/index.ts | 2 + .../headersWithInvalidContentType.json | 5 ++ .../fixtures/headersWithoutUserAgent.json | 4 ++ test/utils/fixtures/index.ts | 26 ++++++++ 7 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts create mode 100644 test/utils/assertions/expectValidationErrorWithMessage.ts create mode 100644 test/utils/assertions/index.ts create mode 100644 test/utils/fixtures/headersWithInvalidContentType.json create mode 100644 test/utils/fixtures/headersWithoutUserAgent.json create mode 100644 test/utils/fixtures/index.ts diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index 14adca22..55ec8c43 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -1,12 +1,10 @@ import {describe, expect, it, beforeEach} from 'vitest'; import createApp from '../../../src/app'; -import {FastifyInstance} from 'fastify'; -import defaultValidRequestHeaders from '../../utils/fixtures/defaultValidRequestHeaders.json'; -import defaultValidRequestBody from '../../utils/fixtures/defaultValidRequestBody.json'; -import defaultValidRequestQuery from '../../utils/fixtures/defaultValidRequestQuery.json'; -import headersWithoutSiteUuid from '../../utils/fixtures/headersWithoutSiteUuid.json'; +import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; +import fixtures from '../../utils/fixtures'; +import {expectValidationErrorWithMessage, expectUnsupportedMediaTypeErrorWithMessage} from '../../utils/assertions'; -function handlerStub(_request, reply, done) { +function handlerStub(_request: FastifyRequest, reply: FastifyReply, done: () => void) { reply.code(202); done(); } @@ -25,25 +23,51 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: defaultValidRequestHeaders, - body: defaultValidRequestBody + query: fixtures.defaultValidRequestQuery, + headers: fixtures.defaultValidRequestHeaders, + body: fixtures.defaultValidRequestBody }); expect(response.statusCode).toBe(202); }); - it('should reject a request with a missing site uuid header', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithoutSiteUuid, - body: defaultValidRequestBody + describe('requests with missing required headers', function () { + it('should reject a request with a missing site uuid header', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: fixtures.defaultValidRequestQuery, + headers: fixtures.headersWithoutSiteUuid, + body: fixtures.defaultValidRequestBody + }); + expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); + }); + + it('should reject a request with a missing user agent header', async function () { + // fastify.inject() adds a default user-agent header, so we need to remove it before validation + app.addHook('onRequest', async (request) => { + delete request.headers['user-agent']; + }); + + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: fixtures.defaultValidRequestQuery, + headers: fixtures.headersWithoutUserAgent, + body: fixtures.defaultValidRequestBody + }); + expectValidationErrorWithMessage(response, 'headers must have required property \'user-agent\''); + }); + + it('should reject a request with a content type other than application/json', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: fixtures.defaultValidRequestQuery, + headers: fixtures.headersWithInvalidContentType, + body: fixtures.defaultValidRequestBody + }); + expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); }); - const bodyJson = JSON.parse(response.body); - expect(response.statusCode).toBe(400); - expect(bodyJson.error).toBe('Bad Request'); - expect(bodyJson.message).toBe('headers must have required property \'x-site-uuid\''); }); }); }); diff --git a/test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts b/test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts new file mode 100644 index 00000000..8002876a --- /dev/null +++ b/test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts @@ -0,0 +1,9 @@ +import {expect} from 'vitest'; +import type {Response} from 'light-my-request'; + +export default function expectUnsupportedMediaTypeErrorWithMessage(response: Response, message: string) { + const bodyJson = JSON.parse(response.body); + expect(response.statusCode).toBe(415); + expect(bodyJson.error).toBe('Unsupported Media Type'); + expect(bodyJson.message).toBe(message); +} \ No newline at end of file diff --git a/test/utils/assertions/expectValidationErrorWithMessage.ts b/test/utils/assertions/expectValidationErrorWithMessage.ts new file mode 100644 index 00000000..fd238bcb --- /dev/null +++ b/test/utils/assertions/expectValidationErrorWithMessage.ts @@ -0,0 +1,9 @@ +import {expect} from 'vitest'; +import type {Response} from 'light-my-request'; + +export default function expectValidationErrorWithMessage(response: Response, message: string) { + const bodyJson = JSON.parse(response.body); + expect(response.statusCode).toBe(400); + expect(bodyJson.error).toBe('Bad Request'); + expect(bodyJson.message).toBe(message); +} \ No newline at end of file diff --git a/test/utils/assertions/index.ts b/test/utils/assertions/index.ts new file mode 100644 index 00000000..247677b1 --- /dev/null +++ b/test/utils/assertions/index.ts @@ -0,0 +1,2 @@ +export {default as expectValidationErrorWithMessage} from './expectValidationErrorWithMessage'; +export {default as expectUnsupportedMediaTypeErrorWithMessage} from './expectUnsupportedMediaTypeErrorWithMessage'; \ No newline at end of file diff --git a/test/utils/fixtures/headersWithInvalidContentType.json b/test/utils/fixtures/headersWithInvalidContentType.json new file mode 100644 index 00000000..8df94c5c --- /dev/null +++ b/test/utils/fixtures/headersWithInvalidContentType.json @@ -0,0 +1,5 @@ +{ + "Content-Type": "application/xml", + "x-site-uuid": "3bfa03e9-b0e7-40dc-a9de-f4b8640b88cc", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" +} \ No newline at end of file diff --git a/test/utils/fixtures/headersWithoutUserAgent.json b/test/utils/fixtures/headersWithoutUserAgent.json new file mode 100644 index 00000000..551ec3c2 --- /dev/null +++ b/test/utils/fixtures/headersWithoutUserAgent.json @@ -0,0 +1,4 @@ +{ + "Content-Type": "application/json", + "x-site-uuid": "3bfa03e9-b0e7-40dc-a9de-f4b8640b88cc" +} \ No newline at end of file diff --git a/test/utils/fixtures/index.ts b/test/utils/fixtures/index.ts new file mode 100644 index 00000000..49bf9444 --- /dev/null +++ b/test/utils/fixtures/index.ts @@ -0,0 +1,26 @@ +// Lazy load the fixtures to avoid loading all of them into memory at once +// Each fixture will be cached after first access using Node's require cache +const fixtures = { + get defaultValidRequestHeaders() { + return require('./defaultValidRequestHeaders.json'); + }, + get defaultValidRequestBody() { + return require('./defaultValidRequestBody.json'); + }, + get defaultValidRequestQuery() { + return require('./defaultValidRequestQuery.json'); + }, + get headersWithoutSiteUuid() { + return require('./headersWithoutSiteUuid.json'); + }, + get headersWithoutUserAgent() { + return require('./headersWithoutUserAgent.json'); + }, + get headersWithInvalidContentType() { + return require('./headersWithInvalidContentType.json'); + } +} as const; + +export type FixtureName = keyof typeof fixtures; + +export default fixtures; \ No newline at end of file From 49b53a6c746be3c4cb2eef5d9b65e1b5d5e668be Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 16:19:49 -0700 Subject: [PATCH 04/19] Refactored fixtures and assertions a bit --- test/integration/api/web_analytics.test.ts | 2 +- test/utils/assertions/assertions.ts | 16 +++++++++ ...ectUnsupportedMediaTypeErrorWithMessage.ts | 9 ----- .../expectValidationErrorWithMessage.ts | 9 ----- test/utils/assertions/index.ts | 3 +- test/utils/fixtures/index.ts | 34 ++++++------------- 6 files changed, 29 insertions(+), 44 deletions(-) create mode 100644 test/utils/assertions/assertions.ts delete mode 100644 test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts delete mode 100644 test/utils/assertions/expectValidationErrorWithMessage.ts diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index 55ec8c43..d09817a1 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -30,7 +30,7 @@ describe('Unversioned API Endpoint', function () { expect(response.statusCode).toBe(202); }); - describe('requests with missing required headers', function () { + describe('requests with missing or invalid required headers', function () { it('should reject a request with a missing site uuid header', async function () { const response = await app.inject({ method: 'POST', diff --git a/test/utils/assertions/assertions.ts b/test/utils/assertions/assertions.ts new file mode 100644 index 00000000..a0a35942 --- /dev/null +++ b/test/utils/assertions/assertions.ts @@ -0,0 +1,16 @@ +import {expect} from 'vitest'; +import type {Response} from 'light-my-request'; + +export function expectUnsupportedMediaTypeErrorWithMessage(response: Response, message: string) { + const bodyJson = JSON.parse(response.body); + expect(response.statusCode).toBe(415); + expect(bodyJson.error).toBe('Unsupported Media Type'); + expect(bodyJson.message).toBe(message); +} + +export function expectValidationErrorWithMessage(response: Response, message: string) { + const bodyJson = JSON.parse(response.body); + expect(response.statusCode).toBe(400); + expect(bodyJson.error).toBe('Bad Request'); + expect(bodyJson.message).toBe(message); +} \ No newline at end of file diff --git a/test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts b/test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts deleted file mode 100644 index 8002876a..00000000 --- a/test/utils/assertions/expectUnsupportedMediaTypeErrorWithMessage.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {expect} from 'vitest'; -import type {Response} from 'light-my-request'; - -export default function expectUnsupportedMediaTypeErrorWithMessage(response: Response, message: string) { - const bodyJson = JSON.parse(response.body); - expect(response.statusCode).toBe(415); - expect(bodyJson.error).toBe('Unsupported Media Type'); - expect(bodyJson.message).toBe(message); -} \ No newline at end of file diff --git a/test/utils/assertions/expectValidationErrorWithMessage.ts b/test/utils/assertions/expectValidationErrorWithMessage.ts deleted file mode 100644 index fd238bcb..00000000 --- a/test/utils/assertions/expectValidationErrorWithMessage.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {expect} from 'vitest'; -import type {Response} from 'light-my-request'; - -export default function expectValidationErrorWithMessage(response: Response, message: string) { - const bodyJson = JSON.parse(response.body); - expect(response.statusCode).toBe(400); - expect(bodyJson.error).toBe('Bad Request'); - expect(bodyJson.message).toBe(message); -} \ No newline at end of file diff --git a/test/utils/assertions/index.ts b/test/utils/assertions/index.ts index 247677b1..eafe9451 100644 --- a/test/utils/assertions/index.ts +++ b/test/utils/assertions/index.ts @@ -1,2 +1 @@ -export {default as expectValidationErrorWithMessage} from './expectValidationErrorWithMessage'; -export {default as expectUnsupportedMediaTypeErrorWithMessage} from './expectUnsupportedMediaTypeErrorWithMessage'; \ No newline at end of file +export * from './assertions'; \ No newline at end of file diff --git a/test/utils/fixtures/index.ts b/test/utils/fixtures/index.ts index 49bf9444..65b3b68a 100644 --- a/test/utils/fixtures/index.ts +++ b/test/utils/fixtures/index.ts @@ -1,26 +1,14 @@ -// Lazy load the fixtures to avoid loading all of them into memory at once -// Each fixture will be cached after first access using Node's require cache -const fixtures = { - get defaultValidRequestHeaders() { - return require('./defaultValidRequestHeaders.json'); - }, - get defaultValidRequestBody() { - return require('./defaultValidRequestBody.json'); - }, - get defaultValidRequestQuery() { - return require('./defaultValidRequestQuery.json'); - }, - get headersWithoutSiteUuid() { - return require('./headersWithoutSiteUuid.json'); - }, - get headersWithoutUserAgent() { - return require('./headersWithoutUserAgent.json'); - }, - get headersWithInvalidContentType() { - return require('./headersWithInvalidContentType.json'); - } -} as const; +import {readdirSync} from 'fs'; +import {join} from 'path'; -export type FixtureName = keyof typeof fixtures; +const fixtures: Record = {}; + +// Load all .json files in this directory +readdirSync(__dirname) + .filter(file => file.endsWith('.json')) + .forEach((file) => { + const name = file.replace('.json', ''); + fixtures[name] = require(join(__dirname, file)); + }); export default fixtures; \ No newline at end of file From dcd7c8145ca4f3695be070a494761bc4d7bbd3ce Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 16:31:08 -0700 Subject: [PATCH 05/19] Added hierarchy to fixtures object --- test/integration/api/web_analytics.test.ts | 24 +++++----- .../defaultValidRequestHeaders.json | 0 .../headersWithInvalidContentType.json | 0 .../{ => headers}/headersWithoutSiteUuid.json | 0 .../headersWithoutUserAgent.json | 0 test/utils/fixtures/index.ts | 47 +++++++++++++++---- .../defaultValidRequestBody.json | 0 .../requestBodyWithInvalidTimestamp.json | 23 +++++++++ .../defaultValidRequestQuery.json | 0 9 files changed, 73 insertions(+), 21 deletions(-) rename test/utils/fixtures/{ => headers}/defaultValidRequestHeaders.json (100%) rename test/utils/fixtures/{ => headers}/headersWithInvalidContentType.json (100%) rename test/utils/fixtures/{ => headers}/headersWithoutSiteUuid.json (100%) rename test/utils/fixtures/{ => headers}/headersWithoutUserAgent.json (100%) rename test/utils/fixtures/{ => page-hits}/defaultValidRequestBody.json (100%) create mode 100644 test/utils/fixtures/page-hits/requestBodyWithInvalidTimestamp.json rename test/utils/fixtures/{ => queryParams}/defaultValidRequestQuery.json (100%) diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index d09817a1..f21f536a 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -23,9 +23,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.defaultValidRequestQuery, - headers: fixtures.defaultValidRequestHeaders, - body: fixtures.defaultValidRequestBody + query: fixtures.queryParams.defaultValidRequestQuery, + headers: fixtures.headers.defaultValidRequestHeaders, + body: fixtures.pageHits.defaultValidRequestBody }); expect(response.statusCode).toBe(202); }); @@ -35,9 +35,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.defaultValidRequestQuery, - headers: fixtures.headersWithoutSiteUuid, - body: fixtures.defaultValidRequestBody + query: fixtures.queryParams.defaultValidRequestQuery, + headers: fixtures.headers.headersWithoutSiteUuid, + body: fixtures.pageHits.defaultValidRequestBody }); expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); }); @@ -51,9 +51,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.defaultValidRequestQuery, - headers: fixtures.headersWithoutUserAgent, - body: fixtures.defaultValidRequestBody + query: fixtures.queryParams.defaultValidRequestQuery, + headers: fixtures.headers.headersWithoutUserAgent, + body: fixtures.pageHits.defaultValidRequestBody }); expectValidationErrorWithMessage(response, 'headers must have required property \'user-agent\''); }); @@ -62,9 +62,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.defaultValidRequestQuery, - headers: fixtures.headersWithInvalidContentType, - body: fixtures.defaultValidRequestBody + query: fixtures.queryParams.defaultValidRequestQuery, + headers: fixtures.headers.headersWithInvalidContentType, + body: fixtures.pageHits.defaultValidRequestBody }); expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); }); diff --git a/test/utils/fixtures/defaultValidRequestHeaders.json b/test/utils/fixtures/headers/defaultValidRequestHeaders.json similarity index 100% rename from test/utils/fixtures/defaultValidRequestHeaders.json rename to test/utils/fixtures/headers/defaultValidRequestHeaders.json diff --git a/test/utils/fixtures/headersWithInvalidContentType.json b/test/utils/fixtures/headers/headersWithInvalidContentType.json similarity index 100% rename from test/utils/fixtures/headersWithInvalidContentType.json rename to test/utils/fixtures/headers/headersWithInvalidContentType.json diff --git a/test/utils/fixtures/headersWithoutSiteUuid.json b/test/utils/fixtures/headers/headersWithoutSiteUuid.json similarity index 100% rename from test/utils/fixtures/headersWithoutSiteUuid.json rename to test/utils/fixtures/headers/headersWithoutSiteUuid.json diff --git a/test/utils/fixtures/headersWithoutUserAgent.json b/test/utils/fixtures/headers/headersWithoutUserAgent.json similarity index 100% rename from test/utils/fixtures/headersWithoutUserAgent.json rename to test/utils/fixtures/headers/headersWithoutUserAgent.json diff --git a/test/utils/fixtures/index.ts b/test/utils/fixtures/index.ts index 65b3b68a..f46d9552 100644 --- a/test/utils/fixtures/index.ts +++ b/test/utils/fixtures/index.ts @@ -1,14 +1,43 @@ -import {readdirSync} from 'fs'; +import {readdirSync, statSync} from 'fs'; import {join} from 'path'; -const fixtures: Record = {}; - -// Load all .json files in this directory -readdirSync(__dirname) - .filter(file => file.endsWith('.json')) - .forEach((file) => { - const name = file.replace('.json', ''); - fixtures[name] = require(join(__dirname, file)); +/** + * Loads all JSON fixtures from a directory with hierarchy. + * + * Usage: + * + * ```ts + * import fixtures from './fixtures'; + * + * expect(fixtures.headers.defaultValidRequestHeaders).toEqual({...}); + * ``` + * + * @param dirPath - The path to the directory to load fixtures from. + * @returns An object containing all the fixtures. + */ +const loadFixturesFromDirectoryWithHierarchy = (dirPath: string): Record => { + const fixtures: Record = {}; + + readdirSync(dirPath).forEach((item) => { + const itemPath = join(dirPath, item); + const stats = statSync(itemPath); + + if (stats.isDirectory()) { + // Recursively load fixtures from subdirectories + const subFixtures = loadFixturesFromDirectoryWithHierarchy(itemPath); + + // Convert directory name to camelCase (e.g., 'page-hits' -> 'pageHits') + const dirName = item.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + fixtures[dirName] = subFixtures; + } else if (item.endsWith('.json')) { + const name = item.replace('.json', ''); + fixtures[name] = require(itemPath); + } }); + + return fixtures; +}; + +const fixtures = loadFixturesFromDirectoryWithHierarchy(__dirname); export default fixtures; \ No newline at end of file diff --git a/test/utils/fixtures/defaultValidRequestBody.json b/test/utils/fixtures/page-hits/defaultValidRequestBody.json similarity index 100% rename from test/utils/fixtures/defaultValidRequestBody.json rename to test/utils/fixtures/page-hits/defaultValidRequestBody.json diff --git a/test/utils/fixtures/page-hits/requestBodyWithInvalidTimestamp.json b/test/utils/fixtures/page-hits/requestBodyWithInvalidTimestamp.json new file mode 100644 index 00000000..508f01e7 --- /dev/null +++ b/test/utils/fixtures/page-hits/requestBodyWithInvalidTimestamp.json @@ -0,0 +1,23 @@ +{ + "timestamp": "2025-07-02T20:09:42.591Z", + "action": "page_hit", + "version": "1", + "payload": { + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + "locale": "en-US", + "location": "US", + "parsedReferrer": { + "source": null, + "medium": null, + "url": null + }, + "pathname": "/", + "href": "https://www.chrisraible.com/", + "event_id": "70ce307c-0b09-4bb5-b7c7-e41cf1085720", + "site_uuid": "940b73e9-4952-4752-b23d-9486f999c47e", + "post_uuid": "undefined", + "post_type": "null", + "member_uuid": "undefined", + "member_status": "undefined" + } +} \ No newline at end of file diff --git a/test/utils/fixtures/defaultValidRequestQuery.json b/test/utils/fixtures/queryParams/defaultValidRequestQuery.json similarity index 100% rename from test/utils/fixtures/defaultValidRequestQuery.json rename to test/utils/fixtures/queryParams/defaultValidRequestQuery.json From 1e439c9f236261f66d07f0a15fad042c5f372218 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 16:34:07 -0700 Subject: [PATCH 06/19] Updated git pre-commit hook --- .husky/pre-commit | 9 ++++++++- package.json | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 491b79cc..60a36953 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -18,10 +18,17 @@ fi # Run unit tests echo "Running yarn test:unit..." -yarn test:unit +yarn test if [ $? -ne 0 ]; then echo "Unit tests failed. Please fix the issues before committing." exit 1 fi +echo "Running yarn test:e2e..." +yarn test:e2e +if [ $? -ne 0 ]; then + echo "E2E tests failed. Please fix the issues before committing." + exit 1 +fi + echo "Pre-commit checks passed!" diff --git a/package.json b/package.json index b6ca2103..1690a1c6 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "start": "node dist/server.js", "start:worker": "WORKER_MODE=true node dist/server.js", "pretest": "yarn build", - "test": "docker compose --profile testing run --rm test yarn _test", + "test": "docker compose --profile testing run --rm -T test yarn _test", "_test": "yarn _test:types && yarn _test:unit && yarn _test:integration", - "test:types": "docker compose --profile testing run --no-deps --rm test yarn _test:types", + "test:types": "docker compose --profile testing run -T --no-deps --rm test yarn _test:types", "_test:types": "tsc --noEmit", "test:unit": "docker compose --profile testing run --no-deps --rm -T test yarn _test:unit", "_test:unit": "NODE_ENV=testing vitest run test/unit --coverage", @@ -26,7 +26,7 @@ "_test:integration": "NODE_ENV=testing vitest run --config vitest.config.integration.ts", "_test:integration:watch": "NODE_ENV=testing vitest watch --config vitest.config.integration.ts", "_test:e2e": "NODE_ENV=testing vitest run test/e2e --coverage=false", - "test:e2e": "env PROXY_TARGET=http://fake-tinybird:8080/v0/events COMPOSE_PROFILES=batch,proxy sh -c 'docker compose up -d --wait && docker compose run --rm e2e-test'", + "test:e2e": "env PROXY_TARGET=http://fake-tinybird:8080/v0/events COMPOSE_PROFILES=batch,proxy sh -c 'docker compose up -d --wait && docker compose run --rm -T e2e-test'", "test:healthchecks": "playwright test", "lint": "docker compose --profile testing run --no-deps --rm -T test yarn _lint", "_lint": "eslint src/ test/ scripts/ *.ts --ext .js,.ts --cache", From 76c51fb95f2b329151ce6360de419099e78d11c6 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 16:39:54 -0700 Subject: [PATCH 07/19] Added e2e tests to pre-commit hooks (opt-in) --- .husky/pre-commit | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 60a36953..52bcf57b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -17,18 +17,20 @@ if [ $? -ne 0 ]; then fi # Run unit tests -echo "Running yarn test:unit..." +echo "Running yarn test" yarn test if [ $? -ne 0 ]; then echo "Unit tests failed. Please fix the issues before committing." exit 1 fi -echo "Running yarn test:e2e..." -yarn test:e2e -if [ $? -ne 0 ]; then - echo "E2E tests failed. Please fix the issues before committing." - exit 1 +if [ "$ENABLE_PRE_COMMIT_HOOKS_E2E" = "true" ]; then + echo "Running yarn test:e2e..." + yarn test:e2e + if [ $? -ne 0 ]; then + echo "E2E tests failed. Please fix the issues before committing." + exit 1 + fi fi echo "Pre-commit checks passed!" From 046b105e8d61b79dc96bc129c4b3b3ca84b7ab11 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 17:47:00 -0700 Subject: [PATCH 08/19] Added pubsub-spy and first two tests for requests in batch mode --- src/services/events/publisher.ts | 15 ++- test/integration/api/web_analytics.test.ts | 84 +++++++++++++-- .../page-hits/defaultValidRequestBody.json | 2 +- test/utils/pubsub-spy.ts | 100 ++++++++++++++++++ 4 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 test/utils/pubsub-spy.ts diff --git a/src/services/events/publisher.ts b/src/services/events/publisher.ts index 0266f7f7..580402e0 100644 --- a/src/services/events/publisher.ts +++ b/src/services/events/publisher.ts @@ -11,8 +11,8 @@ class EventPublisher { private static instance: EventPublisher; private pubsub: PubSub; - private constructor() { - this.pubsub = new PubSub({ + private constructor(pubsub?: PubSub) { + this.pubsub = pubsub || new PubSub({ projectId: process.env.GOOGLE_CLOUD_PROJECT }); } @@ -24,6 +24,10 @@ class EventPublisher { return EventPublisher.instance; } + static resetInstance(pubsub?: PubSub): void { + EventPublisher.instance = new EventPublisher(pubsub); + } + async publishEvent({topic, payload, logger}: PublishEventOptions): Promise { try { const message = { @@ -32,7 +36,7 @@ class EventPublisher { }; const messageId = await this.pubsub.topic(topic).publishMessage(message); - + logger.info({ messageId, topic, @@ -55,4 +59,7 @@ class EventPublisher { export const publishEvent = async ({topic, payload, logger}: PublishEventOptions): Promise => { const publisher = EventPublisher.getInstance(); return publisher.publishEvent({topic, payload, logger}); -}; \ No newline at end of file +}; + +// Export for testing purposes +export {EventPublisher}; \ No newline at end of file diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index f21f536a..71af1e8e 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -1,8 +1,10 @@ -import {describe, expect, it, beforeEach} from 'vitest'; +import {describe, expect, it, vi, beforeEach} from 'vitest'; import createApp from '../../../src/app'; import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; import fixtures from '../../utils/fixtures'; import {expectValidationErrorWithMessage, expectUnsupportedMediaTypeErrorWithMessage} from '../../utils/assertions'; +import {createPubSubSpy} from '../../utils/pubsub-spy'; +import {EventPublisher} from '../../../src/services/events/publisher'; function handlerStub(_request: FastifyRequest, reply: FastifyReply, done: () => void) { reply.code(202); @@ -13,12 +15,13 @@ describe('Unversioned API Endpoint', function () { let app: FastifyInstance; describe('POST /tb/web_analytics', () => { - beforeEach(async function () { - app = createApp(); - app.addHook('preHandler', handlerStub); - }); - describe('Request validation', function () { + beforeEach(async function () { + app = createApp(); + // Handler stub is only called if validation passes + app.addHook('preHandler', handlerStub); + }); + it('should accept a default valid request from the tracking script', async function () { const response = await app.inject({ method: 'POST', @@ -69,6 +72,73 @@ describe('Unversioned API Endpoint', function () { expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); }); }); - }); + }); + + describe('Batch Mode - Publishing to pub/sub', function () { + const gcpProjectId: string = 'test-project'; + const pageHitsRawTopic: string = 'page-hits-raw'; + let pubSubSpy: ReturnType; + + beforeEach(async function () { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', gcpProjectId); + vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); + + // Create and inject PubSub spy + pubSubSpy = createPubSubSpy(); + EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); + + app = createApp(); + }); + + it('should transform the request body and publish to pub/sub', async function () { + await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: fixtures.queryParams.defaultValidRequestQuery, + headers: fixtures.headers.defaultValidRequestHeaders, + body: fixtures.pageHits.defaultValidRequestBody + }); + + pubSubSpy.expectPublishedMessageToTopic(pageHitsRawTopic).withMessageData({ + timestamp: expect.any(String), + action: 'page_hit', + version: '1', + site_uuid: fixtures.headers.defaultValidRequestHeaders['x-site-uuid'], + payload: { + event_id: fixtures.pageHits.defaultValidRequestBody.payload.event_id, + href: 'https://www.example.com/', + pathname: '/', + member_uuid: 'undefined', + member_status: 'undefined', + post_uuid: 'undefined', + post_type: 'null', + parsedReferrer: { + medium: '', + source: '', + url: '' + }, + locale: 'en-US', + location: 'US', + referrer: null + }, + meta: { + ip: expect.any(String), + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' + } + }); + }); + + it('should not publish a message if the request fails validation', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: fixtures.queryParams.defaultValidRequestQuery, + headers: fixtures.headers.headersWithoutSiteUuid, + body: fixtures.pageHits.defaultValidRequestBody + }); + expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); + pubSubSpy.expectNoMessagesPublished(); + }); + }); }); }); \ No newline at end of file diff --git a/test/utils/fixtures/page-hits/defaultValidRequestBody.json b/test/utils/fixtures/page-hits/defaultValidRequestBody.json index 508f01e7..25500308 100644 --- a/test/utils/fixtures/page-hits/defaultValidRequestBody.json +++ b/test/utils/fixtures/page-hits/defaultValidRequestBody.json @@ -12,7 +12,7 @@ "url": null }, "pathname": "/", - "href": "https://www.chrisraible.com/", + "href": "https://www.example.com/", "event_id": "70ce307c-0b09-4bb5-b7c7-e41cf1085720", "site_uuid": "940b73e9-4952-4752-b23d-9486f999c47e", "post_uuid": "undefined", diff --git a/test/utils/pubsub-spy.ts b/test/utils/pubsub-spy.ts new file mode 100644 index 00000000..069c0c44 --- /dev/null +++ b/test/utils/pubsub-spy.ts @@ -0,0 +1,100 @@ +import {vi, expect} from 'vitest'; + +/** + * Creates a PubSub spy that can be used to spy on the PubSub client. + * + * Usage: + * ```ts + * const pubSubSpy = createPubSubSpy(); + * EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); + * + * const response = await app.inject({ + * method: 'POST', + * url: '/tb/web_analytics', + * query: fixtures.queryParams.defaultValidRequestQuery, + * headers: fixtures.headers.defaultValidRequestHeaders, + * body: fixtures.pageHits.defaultValidRequestBody + * }); + * + * // You can use withMessage for low-level assertions: + * pubSubSpy.expectPublishedMessageToTopic(pubsubTopic).withMessage({ + * data: expect.any(Buffer), + * timestamp: expect.any(String) + * }); + * + * // Or use withMessageData to assert on the parsed data: + * pubSubSpy.expectPublishedMessageToTopic(pubsubTopic).withMessageData({ + * site_uuid: 'test-site-uuid', + * page_hits: expect.any(Array) + * }); + * ``` + * @returns + */ +export const createPubSubSpy = () => { + // Realistic mock message ID (15-digit number as string) + const DEFAULT_MESSAGE_ID = '384950293840593'; + + const publishMessageSpy = vi.fn().mockResolvedValue(DEFAULT_MESSAGE_ID); + const topicSpy = vi.fn(); + + const mockPubSub = { + topic: topicSpy.mockImplementation(() => ({ + publishMessage: publishMessageSpy + })) + }; + + const expectPublishedMessageToTopic = (expectedTopic: string) => { + return { + withMessageData: (expectedData: any) => { + expect(topicSpy).toHaveBeenCalledWith(expectedTopic); + + // Get the actual call arguments + const calls = publishMessageSpy.mock.calls; + expect(calls.length).toBeGreaterThan(0); + + const actualMessage = calls[calls.length - 1][0]; + + // Parse the buffer data if it exists + if (actualMessage.data && Buffer.isBuffer(actualMessage.data)) { + const parsedData = JSON.parse(actualMessage.data.toString()); + expect(parsedData).toEqual(expectedData); + } else { + // Fallback to direct comparison if not a buffer + expect(actualMessage).toEqual(expectedData); + } + + // Also check timestamp exists + expect(actualMessage).toHaveProperty('timestamp'); + expect(typeof actualMessage.timestamp).toBe('string'); + }, + withMessage: (messageMatcher: any) => { + expect(topicSpy).toHaveBeenCalledWith(expectedTopic); + expect(publishMessageSpy).toHaveBeenCalledWith(messageMatcher); + } + }; + }; + + const expectNoMessagesPublished = () => { + expect(publishMessageSpy).not.toHaveBeenCalled(); + expect(topicSpy).not.toHaveBeenCalled(); + }; + + const clearSpies = () => { + publishMessageSpy.mockClear(); + topicSpy.mockClear(); + }; + + const setMockMessageId = (messageId: string) => { + publishMessageSpy.mockResolvedValue(messageId); + }; + + return { + mockPubSub, + publishMessageSpy, + topicSpy, + expectPublishedMessageToTopic, + expectNoMessagesPublished, + clearSpies, + setMockMessageId + }; +}; \ No newline at end of file From a09b3b17c95f5e35185c65c0e00832ec769dcffb Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 18:00:30 -0700 Subject: [PATCH 09/19] Refactored test setup into reusable utility functions --- test/integration/api/web_analytics.test.ts | 29 +++++++------------- test/utils/setup-app.ts | 31 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 test/utils/setup-app.ts diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index 71af1e8e..fed61330 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -1,15 +1,13 @@ -import {describe, expect, it, vi, beforeEach} from 'vitest'; -import createApp from '../../../src/app'; +import {describe, expect, it, beforeEach} from 'vitest'; import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; import fixtures from '../../utils/fixtures'; import {expectValidationErrorWithMessage, expectUnsupportedMediaTypeErrorWithMessage} from '../../utils/assertions'; import {createPubSubSpy} from '../../utils/pubsub-spy'; -import {EventPublisher} from '../../../src/services/events/publisher'; +import {setupAppWithStubbedPreHandler, setupAppInBatchModeWithPubSubSpy} from '../../utils/setup-app'; -function handlerStub(_request: FastifyRequest, reply: FastifyReply, done: () => void) { +const preHandlerStub = async (_request: FastifyRequest, reply: FastifyReply) => { reply.code(202); - done(); -} +}; describe('Unversioned API Endpoint', function () { let app: FastifyInstance; @@ -17,9 +15,7 @@ describe('Unversioned API Endpoint', function () { describe('POST /tb/web_analytics', () => { describe('Request validation', function () { beforeEach(async function () { - app = createApp(); - // Handler stub is only called if validation passes - app.addHook('preHandler', handlerStub); + app = setupAppWithStubbedPreHandler(preHandlerStub); }); it('should accept a default valid request from the tracking script', async function () { @@ -75,19 +71,14 @@ describe('Unversioned API Endpoint', function () { }); describe('Batch Mode - Publishing to pub/sub', function () { - const gcpProjectId: string = 'test-project'; - const pageHitsRawTopic: string = 'page-hits-raw'; let pubSubSpy: ReturnType; + const pageHitsRawTopic: string = 'page-hits-raw'; beforeEach(async function () { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', gcpProjectId); - vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); - - // Create and inject PubSub spy - pubSubSpy = createPubSubSpy(); - EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); - - app = createApp(); + ({app, pubSubSpy} = setupAppInBatchModeWithPubSubSpy({ + gcpProjectId: 'test-project', + pageHitsRawTopic + })); }); it('should transform the request body and publish to pub/sub', async function () { diff --git a/test/utils/setup-app.ts b/test/utils/setup-app.ts new file mode 100644 index 00000000..68591776 --- /dev/null +++ b/test/utils/setup-app.ts @@ -0,0 +1,31 @@ +import {FastifyReply, FastifyRequest} from 'fastify'; +import {vi} from 'vitest'; +import createApp from '../../src/app'; +import {createPubSubSpy} from './pubsub-spy'; +import {EventPublisher} from '../../src/services/events/publisher'; + +export const setupAppWithStubbedPreHandler = (preHandlerStub: (request: FastifyRequest, reply: FastifyReply, done: () => void) => void) => { + const app = createApp(); + // Pre-handler stub is called after validation passes but before the request is processed + app.addHook('preHandler', preHandlerStub); + return app; +}; + +export const setupAppInBatchModeWithPubSubSpy = ({gcpProjectId, pageHitsRawTopic}: {gcpProjectId: string, pageHitsRawTopic: string}) => { + // If these variables are set, app will run in batch mode + // We should make this more explicit in the future, but for now this is how it works :/ + vi.stubEnv('GOOGLE_CLOUD_PROJECT', gcpProjectId); + vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); + // Create a spy for the pubsub client and inject it into the EventPublisher singleton + const pubSubSpy = createPubSubSpy(); + EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); + const app = createApp(); + return {app, pubSubSpy}; +}; + +export const setupAppInProxyMode = () => { + // If this variable is not set / undefined, the app defaults to proxy mode + vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', undefined); + const app = createApp(); + return app; +}; \ No newline at end of file From b065e51ad4641f21dc04a324ed8cdb7325666d2a Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 18:26:16 -0700 Subject: [PATCH 10/19] Removed change to pre-commit hook (for now) --- .husky/pre-commit | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 52bcf57b..491b79cc 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -17,20 +17,11 @@ if [ $? -ne 0 ]; then fi # Run unit tests -echo "Running yarn test" -yarn test +echo "Running yarn test:unit..." +yarn test:unit if [ $? -ne 0 ]; then echo "Unit tests failed. Please fix the issues before committing." exit 1 fi -if [ "$ENABLE_PRE_COMMIT_HOOKS_E2E" = "true" ]; then - echo "Running yarn test:e2e..." - yarn test:e2e - if [ $? -ne 0 ]; then - echo "E2E tests failed. Please fix the issues before committing." - exit 1 - fi -fi - echo "Pre-commit checks passed!" From cedd11d53a27eb82de1b08011ecc84310049fa18 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Jul 2025 18:26:54 -0700 Subject: [PATCH 11/19] Removed changes to package.json (for now) --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1690a1c6..93ef53dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "traffic-analytics", - "version": "0.0.45", + "version": "0.0.44", "repository": "git@github.com:TryGhost/TrafficAnalytics.git", "author": "Ghost Foundation", "license": "MIT", @@ -14,9 +14,9 @@ "start": "node dist/server.js", "start:worker": "WORKER_MODE=true node dist/server.js", "pretest": "yarn build", - "test": "docker compose --profile testing run --rm -T test yarn _test", + "test": "docker compose --profile testing run --rm test yarn _test", "_test": "yarn _test:types && yarn _test:unit && yarn _test:integration", - "test:types": "docker compose --profile testing run -T --no-deps --rm test yarn _test:types", + "test:types": "docker compose --profile testing run --no-deps --rm test yarn _test:types", "_test:types": "tsc --noEmit", "test:unit": "docker compose --profile testing run --no-deps --rm -T test yarn _test:unit", "_test:unit": "NODE_ENV=testing vitest run test/unit --coverage", @@ -26,7 +26,7 @@ "_test:integration": "NODE_ENV=testing vitest run --config vitest.config.integration.ts", "_test:integration:watch": "NODE_ENV=testing vitest watch --config vitest.config.integration.ts", "_test:e2e": "NODE_ENV=testing vitest run test/e2e --coverage=false", - "test:e2e": "env PROXY_TARGET=http://fake-tinybird:8080/v0/events COMPOSE_PROFILES=batch,proxy sh -c 'docker compose up -d --wait && docker compose run --rm -T e2e-test'", + "test:e2e": "env PROXY_TARGET=http://fake-tinybird:8080/v0/events COMPOSE_PROFILES=batch,proxy sh -c 'docker compose up -d --wait && docker compose run --rm e2e-test'", "test:healthchecks": "playwright test", "lint": "docker compose --profile testing run --no-deps --rm -T test yarn _lint", "_lint": "eslint src/ test/ scripts/ *.ts --ext .js,.ts --cache", @@ -41,7 +41,7 @@ "devDependencies": { "@playwright/test": "1.53.2", "@types/dotenv": "8.2.3", - "@types/node": "24.0.10", + "@types/node": "24.0.9", "@types/supertest": "6.0.3", "@types/ua-parser-js": "0.7.39", "@vitest/coverage-v8": "3.2.4", From 8290ccf498706150d59f2778f15a625fd83e694d Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 16:54:13 -0700 Subject: [PATCH 12/19] Updated yarn.lock --- yarn.lock | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 3e86d1f8..8b293d6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2529,13 +2529,20 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@24.0.10", "@types/node@>=13.7.0": +"@types/node@*", "@types/node@>=13.7.0": version "24.0.10" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.10.tgz#f65a169779bf0d70203183a1890be7bee8ca2ddb" integrity sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA== dependencies: undici-types "~7.8.0" +"@types/node@24.0.9": + version "24.0.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.9.tgz#0276ebb02550c9bec72d1fcec7642431dfaacad0" + integrity sha512-AVQN3mtPEWbtWfXryyHV8+8IBD4Ijm1reEVPjNWT1AvTQdLxlaroFy425Y+tKVSEJvUnKbyOGkaU6BH7Ox+NzQ== + dependencies: + undici-types "~7.8.0" + "@types/normalize-package-data@^2.4.0": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" From 149a7673f4f552a8b95c8323b974bf3d549c2383 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:00:56 -0700 Subject: [PATCH 13/19] Reverted dynamic fixture loader in favor of plain .json files --- test/integration/api/web_analytics.test.ts | 47 ++++++++++--------- .../defaultValidRequestBody.json | 0 .../defaultValidRequestHeaders.json | 0 .../defaultValidRequestQuery.json | 0 .../headersWithInvalidContentType.json | 0 .../{headers => }/headersWithoutSiteUuid.json | 0 .../headersWithoutUserAgent.json | 0 test/utils/fixtures/index.ts | 43 ----------------- .../requestBodyWithInvalidTimestamp.json | 0 9 files changed, 26 insertions(+), 64 deletions(-) rename test/utils/fixtures/{page-hits => }/defaultValidRequestBody.json (100%) rename test/utils/fixtures/{headers => }/defaultValidRequestHeaders.json (100%) rename test/utils/fixtures/{queryParams => }/defaultValidRequestQuery.json (100%) rename test/utils/fixtures/{headers => }/headersWithInvalidContentType.json (100%) rename test/utils/fixtures/{headers => }/headersWithoutSiteUuid.json (100%) rename test/utils/fixtures/{headers => }/headersWithoutUserAgent.json (100%) delete mode 100644 test/utils/fixtures/index.ts rename test/utils/fixtures/{page-hits => }/requestBodyWithInvalidTimestamp.json (100%) diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index fed61330..debf8581 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -1,9 +1,14 @@ import {describe, expect, it, beforeEach} from 'vitest'; import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; -import fixtures from '../../utils/fixtures'; import {expectValidationErrorWithMessage, expectUnsupportedMediaTypeErrorWithMessage} from '../../utils/assertions'; import {createPubSubSpy} from '../../utils/pubsub-spy'; import {setupAppWithStubbedPreHandler, setupAppInBatchModeWithPubSubSpy} from '../../utils/setup-app'; +import defaultValidRequestQuery from '../../utils/fixtures/defaultValidRequestQuery.json'; +import defaultValidRequestHeaders from '../../utils/fixtures/defaultValidRequestHeaders.json'; +import defaultValidRequestBody from '../../utils/fixtures/defaultValidRequestBody.json'; +import headersWithInvalidContentType from '../../utils/fixtures/headersWithInvalidContentType.json'; +import headersWithoutUserAgent from '../../utils/fixtures/headersWithoutUserAgent.json'; +import headersWithoutSiteUuid from '../../utils/fixtures/headersWithoutSiteUuid.json'; const preHandlerStub = async (_request: FastifyRequest, reply: FastifyReply) => { reply.code(202); @@ -22,9 +27,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.queryParams.defaultValidRequestQuery, - headers: fixtures.headers.defaultValidRequestHeaders, - body: fixtures.pageHits.defaultValidRequestBody + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody }); expect(response.statusCode).toBe(202); }); @@ -34,9 +39,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.queryParams.defaultValidRequestQuery, - headers: fixtures.headers.headersWithoutSiteUuid, - body: fixtures.pageHits.defaultValidRequestBody + query: defaultValidRequestQuery, + headers: headersWithoutSiteUuid, + body: defaultValidRequestBody }); expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); }); @@ -50,9 +55,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.queryParams.defaultValidRequestQuery, - headers: fixtures.headers.headersWithoutUserAgent, - body: fixtures.pageHits.defaultValidRequestBody + query: defaultValidRequestQuery, + headers: headersWithoutUserAgent, + body: defaultValidRequestBody }); expectValidationErrorWithMessage(response, 'headers must have required property \'user-agent\''); }); @@ -61,9 +66,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.queryParams.defaultValidRequestQuery, - headers: fixtures.headers.headersWithInvalidContentType, - body: fixtures.pageHits.defaultValidRequestBody + query: defaultValidRequestQuery, + headers: headersWithInvalidContentType, + body: defaultValidRequestBody }); expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); }); @@ -85,18 +90,18 @@ describe('Unversioned API Endpoint', function () { await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.queryParams.defaultValidRequestQuery, - headers: fixtures.headers.defaultValidRequestHeaders, - body: fixtures.pageHits.defaultValidRequestBody + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody }); pubSubSpy.expectPublishedMessageToTopic(pageHitsRawTopic).withMessageData({ timestamp: expect.any(String), action: 'page_hit', version: '1', - site_uuid: fixtures.headers.defaultValidRequestHeaders['x-site-uuid'], + site_uuid: defaultValidRequestHeaders['x-site-uuid'], payload: { - event_id: fixtures.pageHits.defaultValidRequestBody.payload.event_id, + event_id: defaultValidRequestBody.payload.event_id, href: 'https://www.example.com/', pathname: '/', member_uuid: 'undefined', @@ -123,9 +128,9 @@ describe('Unversioned API Endpoint', function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', - query: fixtures.queryParams.defaultValidRequestQuery, - headers: fixtures.headers.headersWithoutSiteUuid, - body: fixtures.pageHits.defaultValidRequestBody + query: defaultValidRequestQuery, + headers: headersWithoutSiteUuid, + body: defaultValidRequestBody }); expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); pubSubSpy.expectNoMessagesPublished(); diff --git a/test/utils/fixtures/page-hits/defaultValidRequestBody.json b/test/utils/fixtures/defaultValidRequestBody.json similarity index 100% rename from test/utils/fixtures/page-hits/defaultValidRequestBody.json rename to test/utils/fixtures/defaultValidRequestBody.json diff --git a/test/utils/fixtures/headers/defaultValidRequestHeaders.json b/test/utils/fixtures/defaultValidRequestHeaders.json similarity index 100% rename from test/utils/fixtures/headers/defaultValidRequestHeaders.json rename to test/utils/fixtures/defaultValidRequestHeaders.json diff --git a/test/utils/fixtures/queryParams/defaultValidRequestQuery.json b/test/utils/fixtures/defaultValidRequestQuery.json similarity index 100% rename from test/utils/fixtures/queryParams/defaultValidRequestQuery.json rename to test/utils/fixtures/defaultValidRequestQuery.json diff --git a/test/utils/fixtures/headers/headersWithInvalidContentType.json b/test/utils/fixtures/headersWithInvalidContentType.json similarity index 100% rename from test/utils/fixtures/headers/headersWithInvalidContentType.json rename to test/utils/fixtures/headersWithInvalidContentType.json diff --git a/test/utils/fixtures/headers/headersWithoutSiteUuid.json b/test/utils/fixtures/headersWithoutSiteUuid.json similarity index 100% rename from test/utils/fixtures/headers/headersWithoutSiteUuid.json rename to test/utils/fixtures/headersWithoutSiteUuid.json diff --git a/test/utils/fixtures/headers/headersWithoutUserAgent.json b/test/utils/fixtures/headersWithoutUserAgent.json similarity index 100% rename from test/utils/fixtures/headers/headersWithoutUserAgent.json rename to test/utils/fixtures/headersWithoutUserAgent.json diff --git a/test/utils/fixtures/index.ts b/test/utils/fixtures/index.ts deleted file mode 100644 index f46d9552..00000000 --- a/test/utils/fixtures/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {readdirSync, statSync} from 'fs'; -import {join} from 'path'; - -/** - * Loads all JSON fixtures from a directory with hierarchy. - * - * Usage: - * - * ```ts - * import fixtures from './fixtures'; - * - * expect(fixtures.headers.defaultValidRequestHeaders).toEqual({...}); - * ``` - * - * @param dirPath - The path to the directory to load fixtures from. - * @returns An object containing all the fixtures. - */ -const loadFixturesFromDirectoryWithHierarchy = (dirPath: string): Record => { - const fixtures: Record = {}; - - readdirSync(dirPath).forEach((item) => { - const itemPath = join(dirPath, item); - const stats = statSync(itemPath); - - if (stats.isDirectory()) { - // Recursively load fixtures from subdirectories - const subFixtures = loadFixturesFromDirectoryWithHierarchy(itemPath); - - // Convert directory name to camelCase (e.g., 'page-hits' -> 'pageHits') - const dirName = item.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); - fixtures[dirName] = subFixtures; - } else if (item.endsWith('.json')) { - const name = item.replace('.json', ''); - fixtures[name] = require(itemPath); - } - }); - - return fixtures; -}; - -const fixtures = loadFixturesFromDirectoryWithHierarchy(__dirname); - -export default fixtures; \ No newline at end of file diff --git a/test/utils/fixtures/page-hits/requestBodyWithInvalidTimestamp.json b/test/utils/fixtures/requestBodyWithInvalidTimestamp.json similarity index 100% rename from test/utils/fixtures/page-hits/requestBodyWithInvalidTimestamp.json rename to test/utils/fixtures/requestBodyWithInvalidTimestamp.json From a6fbee48c7f3723cfb3d941a454893d90e45b8b8 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:05:42 -0700 Subject: [PATCH 14/19] Updated worker-app to export a function instead of an app instance --- server.ts | 2 +- src/worker-app.ts | 44 ++++++++++++++++------------- test/integration/worker-app.test.ts | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/server.ts b/server.ts index 83afc136..200a00fa 100644 --- a/server.ts +++ b/server.ts @@ -9,7 +9,7 @@ const isWorkerMode = process.env.WORKER_MODE === 'true'; let app; if (isWorkerMode) { const workerModule = await import('./src/worker-app'); - app = workerModule.default; + app = workerModule.default(); } else { const appModule = await import('./src/app'); app = appModule.default(); diff --git a/src/worker-app.ts b/src/worker-app.ts index b0803ec7..cb559a14 100644 --- a/src/worker-app.ts +++ b/src/worker-app.ts @@ -4,25 +4,29 @@ import loggingPlugin from './plugins/logging'; import workerPlugin from './plugins/worker-plugin'; import {getLoggerConfig} from './utils/logger'; -const app = fastify({ - logger: getLoggerConfig(), - disableRequestLogging: true, - trustProxy: process.env.TRUST_PROXY !== 'false' -}); +function createApp() { + const app = fastify({ + logger: getLoggerConfig(), + disableRequestLogging: true, + trustProxy: process.env.TRUST_PROXY !== 'false' + }); + + // Register logging plugin for consistent log formatting + app.register(loggingPlugin); + + // Register worker plugin for heartbeat logging + app.register(workerPlugin); + + // Health endpoints for Cloud Run deployment + app.get('/', async () => { + return {status: 'worker-healthy'}; + }); + + app.get('/health', async () => { + return {status: 'worker-healthy'}; + }); -// Register logging plugin for consistent log formatting -app.register(loggingPlugin); + return app; +} -// Register worker plugin for heartbeat logging -app.register(workerPlugin); - -// Health endpoints for Cloud Run deployment -app.get('/', async () => { - return {status: 'worker-healthy'}; -}); - -app.get('/health', async () => { - return {status: 'worker-healthy'}; -}); - -export default app; \ No newline at end of file +export default createApp; \ No newline at end of file diff --git a/test/integration/worker-app.test.ts b/test/integration/worker-app.test.ts index 7652d89d..61c3b0ea 100644 --- a/test/integration/worker-app.test.ts +++ b/test/integration/worker-app.test.ts @@ -13,7 +13,7 @@ describe('Worker App', () => { // Import worker app fresh const workerModule = await import('../../src/worker-app'); - app = workerModule.default; + app = workerModule.default(); // Wait for app to be ready await app.ready(); From e8784625618f0ea0e61094da169ef9f8af1066d5 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:12:09 -0700 Subject: [PATCH 15/19] Refactored server.ts app initialization --- server.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/server.ts b/server.ts index 200a00fa..b6250839 100644 --- a/server.ts +++ b/server.ts @@ -3,18 +3,16 @@ import {fileURLToPath} from 'url'; const isMainModule = process.argv[1] === fileURLToPath(import.meta.url); const port: number = parseInt(process.env.PORT || '3000', 10); -const isWorkerMode = process.env.WORKER_MODE === 'true'; -// Load the appropriate app once -let app; -if (isWorkerMode) { - const workerModule = await import('./src/worker-app'); - app = workerModule.default(); -} else { - const appModule = await import('./src/app'); - app = appModule.default(); +async function initializeApp({isWorkerMode}: {isWorkerMode: boolean}) { + const appModulePath = isWorkerMode ? './src/worker-app' : './src/app'; + const appModule = await import(appModulePath); + return appModule.default(); } +// Load the appropriate app once +const app = await initializeApp({isWorkerMode: process.env.WORKER_MODE === 'true'}); + // Start the server if this file is run directly if (isMainModule) { const start = async (): Promise => { @@ -35,5 +33,4 @@ if (isMainModule) { start(); } -// Export the app -export default app; +export default app; \ No newline at end of file From 761a5db62bfce376e799a9b956a8db1406ecaa87 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:23:29 -0700 Subject: [PATCH 16/19] Refactored app initialization pattern --- server.ts | 12 +- src/initializeApp.ts | 5 + src/{app.ts => service-app.ts} | 0 test/integration/api/v1/page_hit.test.ts | 9 +- test/integration/api/web_analytics.test.ts | 195 +++++++++--------- test/integration/app.test.ts | 6 +- .../validation-error-logging.test.ts | 4 +- test/integration/worker-app.test.ts | 15 +- test/utils/setup-app.ts | 31 --- 9 files changed, 120 insertions(+), 157 deletions(-) create mode 100644 src/initializeApp.ts rename src/{app.ts => service-app.ts} (100%) delete mode 100644 test/utils/setup-app.ts diff --git a/server.ts b/server.ts index b6250839..88bf5669 100644 --- a/server.ts +++ b/server.ts @@ -1,17 +1,13 @@ import './src/utils/instrumentation'; import {fileURLToPath} from 'url'; +import {initializeApp} from './src/initializeApp'; const isMainModule = process.argv[1] === fileURLToPath(import.meta.url); const port: number = parseInt(process.env.PORT || '3000', 10); +const isWorkerMode = process.env.WORKER_MODE === 'true'; -async function initializeApp({isWorkerMode}: {isWorkerMode: boolean}) { - const appModulePath = isWorkerMode ? './src/worker-app' : './src/app'; - const appModule = await import(appModulePath); - return appModule.default(); -} - -// Load the appropriate app once -const app = await initializeApp({isWorkerMode: process.env.WORKER_MODE === 'true'}); +// Create an instance of the appropriate app +const app = await initializeApp({isWorkerMode}); // Start the server if this file is run directly if (isMainModule) { diff --git a/src/initializeApp.ts b/src/initializeApp.ts new file mode 100644 index 00000000..b974861f --- /dev/null +++ b/src/initializeApp.ts @@ -0,0 +1,5 @@ +export async function initializeApp({isWorkerMode}: {isWorkerMode: boolean}) { + const appModulePath = isWorkerMode ? './src/worker-app' : './src/service-app'; + const appModule = await import(appModulePath); + return appModule.default(); +} diff --git a/src/app.ts b/src/service-app.ts similarity index 100% rename from src/app.ts rename to src/service-app.ts diff --git a/test/integration/api/v1/page_hit.test.ts b/test/integration/api/v1/page_hit.test.ts index bc0b08d5..918566f6 100644 --- a/test/integration/api/v1/page_hit.test.ts +++ b/test/integration/api/v1/page_hit.test.ts @@ -1,17 +1,16 @@ import {describe, it, expect, beforeAll} from 'vitest'; import {FastifyInstance} from 'fastify'; +import {initializeApp} from '../../../../src/initializeApp'; describe('/api/v1/page_hit', () => { - let fastify: FastifyInstance; + let app: FastifyInstance; beforeAll(async function () { - const appModule = await import('../../../../src/app'); - fastify = appModule.default(); - await fastify.ready(); + app = await initializeApp({isWorkerMode: false}); }); it('should return 200', async function () { - const response = await fastify.inject({ + const response = await app.inject({ method: 'GET', url: '/api/v1/page_hit' }); diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts index debf8581..e7c3fbe4 100644 --- a/test/integration/api/web_analytics.test.ts +++ b/test/integration/api/web_analytics.test.ts @@ -1,140 +1,143 @@ -import {describe, expect, it, beforeEach} from 'vitest'; +import {describe, expect, it, beforeEach, beforeAll, vi} from 'vitest'; import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; import {expectValidationErrorWithMessage, expectUnsupportedMediaTypeErrorWithMessage} from '../../utils/assertions'; import {createPubSubSpy} from '../../utils/pubsub-spy'; -import {setupAppWithStubbedPreHandler, setupAppInBatchModeWithPubSubSpy} from '../../utils/setup-app'; import defaultValidRequestQuery from '../../utils/fixtures/defaultValidRequestQuery.json'; import defaultValidRequestHeaders from '../../utils/fixtures/defaultValidRequestHeaders.json'; import defaultValidRequestBody from '../../utils/fixtures/defaultValidRequestBody.json'; import headersWithInvalidContentType from '../../utils/fixtures/headersWithInvalidContentType.json'; import headersWithoutUserAgent from '../../utils/fixtures/headersWithoutUserAgent.json'; import headersWithoutSiteUuid from '../../utils/fixtures/headersWithoutSiteUuid.json'; +import {initializeApp} from '../../../src/initializeApp'; +import {EventPublisher} from '../../../src/services/events/publisher'; const preHandlerStub = async (_request: FastifyRequest, reply: FastifyReply) => { reply.code(202); }; -describe('Unversioned API Endpoint', function () { +describe('POST /tb/web_analytics', () => { let app: FastifyInstance; - describe('POST /tb/web_analytics', () => { - describe('Request validation', function () { - beforeEach(async function () { - app = setupAppWithStubbedPreHandler(preHandlerStub); + describe('Request validation', function () { + beforeEach(async function () { + app = await initializeApp({isWorkerMode: false}); + app.addHook('preHandler', preHandlerStub); + }); + + it('should accept a default valid request from the tracking script', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody }); + expect(response.statusCode).toBe(202); + }); - it('should accept a default valid request from the tracking script', async function () { + describe('requests with missing or invalid required headers', function () { + it('should reject a request with a missing site uuid header', async function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', query: defaultValidRequestQuery, - headers: defaultValidRequestHeaders, + headers: headersWithoutSiteUuid, body: defaultValidRequestBody }); - expect(response.statusCode).toBe(202); + expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); }); - - describe('requests with missing or invalid required headers', function () { - it('should reject a request with a missing site uuid header', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithoutSiteUuid, - body: defaultValidRequestBody - }); - expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); - }); - it('should reject a request with a missing user agent header', async function () { - // fastify.inject() adds a default user-agent header, so we need to remove it before validation - app.addHook('onRequest', async (request) => { - delete request.headers['user-agent']; - }); - - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithoutUserAgent, - body: defaultValidRequestBody - }); - expectValidationErrorWithMessage(response, 'headers must have required property \'user-agent\''); - }); - - it('should reject a request with a content type other than application/json', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithInvalidContentType, - body: defaultValidRequestBody - }); - expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); + it('should reject a request with a missing user agent header', async function () { + // fastify.inject() adds a default user-agent header, so we need to remove it before validation + app.addHook('onRequest', async (request) => { + delete request.headers['user-agent']; }); - }); - }); - - describe('Batch Mode - Publishing to pub/sub', function () { - let pubSubSpy: ReturnType; - const pageHitsRawTopic: string = 'page-hits-raw'; - - beforeEach(async function () { - ({app, pubSubSpy} = setupAppInBatchModeWithPubSubSpy({ - gcpProjectId: 'test-project', - pageHitsRawTopic - })); - }); - - it('should transform the request body and publish to pub/sub', async function () { - await app.inject({ + + const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', query: defaultValidRequestQuery, - headers: defaultValidRequestHeaders, + headers: headersWithoutUserAgent, body: defaultValidRequestBody }); - - pubSubSpy.expectPublishedMessageToTopic(pageHitsRawTopic).withMessageData({ - timestamp: expect.any(String), - action: 'page_hit', - version: '1', - site_uuid: defaultValidRequestHeaders['x-site-uuid'], - payload: { - event_id: defaultValidRequestBody.payload.event_id, - href: 'https://www.example.com/', - pathname: '/', - member_uuid: 'undefined', - member_status: 'undefined', - post_uuid: 'undefined', - post_type: 'null', - parsedReferrer: { - medium: '', - source: '', - url: '' - }, - locale: 'en-US', - location: 'US', - referrer: null - }, - meta: { - ip: expect.any(String), - 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' - } - }); + expectValidationErrorWithMessage(response, 'headers must have required property \'user-agent\''); }); - it('should not publish a message if the request fails validation', async function () { + it('should reject a request with a content type other than application/json', async function () { const response = await app.inject({ method: 'POST', url: '/tb/web_analytics', query: defaultValidRequestQuery, - headers: headersWithoutSiteUuid, + headers: headersWithInvalidContentType, body: defaultValidRequestBody }); - expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); - pubSubSpy.expectNoMessagesPublished(); + expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); + }); + }); + }); + + describe('Batch Mode - Publishing to pub/sub', function () { + let pubSubSpy: ReturnType; + const pageHitsRawTopic: string = 'page-hits-raw'; + + beforeAll(async function () { + vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); + }); + beforeEach(async function () { + app = await initializeApp({isWorkerMode: false}); + app.addHook('preHandler', preHandlerStub); + pubSubSpy = createPubSubSpy(); + EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); + }); + + it('should transform the request body and publish to pub/sub', async function () { + await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody + }); + + pubSubSpy.expectPublishedMessageToTopic(pageHitsRawTopic).withMessageData({ + timestamp: expect.any(String), + action: 'page_hit', + version: '1', + site_uuid: defaultValidRequestHeaders['x-site-uuid'], + payload: { + event_id: defaultValidRequestBody.payload.event_id, + href: 'https://www.example.com/', + pathname: '/', + member_uuid: 'undefined', + member_status: 'undefined', + post_uuid: 'undefined', + post_type: 'null', + parsedReferrer: { + medium: '', + source: '', + url: '' + }, + locale: 'en-US', + location: 'US', + referrer: null + }, + meta: { + ip: expect.any(String), + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' + } + }); + }); + + it('should not publish a message if the request fails validation', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: headersWithoutSiteUuid, + body: defaultValidRequestBody }); + expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); + pubSubSpy.expectNoMessagesPublished(); }); }); }); \ No newline at end of file diff --git a/test/integration/app.test.ts b/test/integration/app.test.ts index bd8584d1..8ea51e22 100644 --- a/test/integration/app.test.ts +++ b/test/integration/app.test.ts @@ -14,6 +14,7 @@ vi.mock('../../src/services/user-signature', () => ({ // Import the mocked service import {userSignatureService} from '../../src/services/user-signature'; +import {initializeApp} from '../../src/initializeApp'; const eventPayload = { timestamp: '2025-04-14T22:16:06.095Z', @@ -77,9 +78,8 @@ describe('Fastify App', () => { // Set the PROXY_TARGET environment variable before requiring the app process.env.PROXY_TARGET = targetUrl; - // Import directly from the source - const appModule = await import('../../src/app'); - app = appModule.default(); + // Create an instance of the app + app = await initializeApp({isWorkerMode: false}); await app.ready(); proxyServer = app.server; }); diff --git a/test/integration/validation-error-logging.test.ts b/test/integration/validation-error-logging.test.ts index 199342b2..8c899525 100644 --- a/test/integration/validation-error-logging.test.ts +++ b/test/integration/validation-error-logging.test.ts @@ -3,6 +3,7 @@ import request from 'supertest'; import createMockUpstream from '../utils/mock-upstream'; import {FastifyInstance} from 'fastify'; import {Server} from 'http'; +import {initializeApp} from '../../src/initializeApp'; // Mock the user signature service before importing the app vi.mock('../../src/services/user-signature', () => ({ @@ -47,8 +48,7 @@ describe('Validation Error Logging', () => { process.env.PROXY_TARGET = targetUrl; - const appModule = await import('../../src/app'); - app = appModule.default(); + app = await initializeApp({isWorkerMode: false}); await app.ready(); proxyServer = app.server; }); diff --git a/test/integration/worker-app.test.ts b/test/integration/worker-app.test.ts index 61c3b0ea..5fb7251c 100644 --- a/test/integration/worker-app.test.ts +++ b/test/integration/worker-app.test.ts @@ -1,21 +1,13 @@ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import request from 'supertest'; import {FastifyInstance} from 'fastify'; +import {initializeApp} from '../../src/initializeApp'; describe('Worker App', () => { let app: FastifyInstance; beforeEach(async () => { - // Clear environment variables to ensure clean state - delete process.env.WORKER_MODE; - - // Clear module cache to ensure fresh import - vi.resetModules(); - - // Import worker app fresh - const workerModule = await import('../../src/worker-app'); - app = workerModule.default(); - - // Wait for app to be ready + // Create an instance of the worker app + app = await initializeApp({isWorkerMode: true}); await app.ready(); }); @@ -23,7 +15,6 @@ describe('Worker App', () => { if (app) { await app.close(); } - // Note: Global setup handles resource cleanup }); describe('Health Endpoints', () => { diff --git a/test/utils/setup-app.ts b/test/utils/setup-app.ts deleted file mode 100644 index 68591776..00000000 --- a/test/utils/setup-app.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {FastifyReply, FastifyRequest} from 'fastify'; -import {vi} from 'vitest'; -import createApp from '../../src/app'; -import {createPubSubSpy} from './pubsub-spy'; -import {EventPublisher} from '../../src/services/events/publisher'; - -export const setupAppWithStubbedPreHandler = (preHandlerStub: (request: FastifyRequest, reply: FastifyReply, done: () => void) => void) => { - const app = createApp(); - // Pre-handler stub is called after validation passes but before the request is processed - app.addHook('preHandler', preHandlerStub); - return app; -}; - -export const setupAppInBatchModeWithPubSubSpy = ({gcpProjectId, pageHitsRawTopic}: {gcpProjectId: string, pageHitsRawTopic: string}) => { - // If these variables are set, app will run in batch mode - // We should make this more explicit in the future, but for now this is how it works :/ - vi.stubEnv('GOOGLE_CLOUD_PROJECT', gcpProjectId); - vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); - // Create a spy for the pubsub client and inject it into the EventPublisher singleton - const pubSubSpy = createPubSubSpy(); - EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); - const app = createApp(); - return {app, pubSubSpy}; -}; - -export const setupAppInProxyMode = () => { - // If this variable is not set / undefined, the app defaults to proxy mode - vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', undefined); - const app = createApp(); - return app; -}; \ No newline at end of file From 3e61db96449d4aa5dc7e77a50ea01b41c3d9ff17 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:35:19 -0700 Subject: [PATCH 17/19] Split request validation and batch mode tests into different files --- test/integration/api/web_analytics.test.ts | 143 ------------------ .../api/web_analytics/batch-mode.test.ts | 88 +++++++++++ .../web_analytics/request-validation.test.ts | 91 +++++++++++ test/utils/assertions.ts | 13 ++ test/utils/assertions/assertions.ts | 16 -- test/utils/assertions/index.ts | 1 - 6 files changed, 192 insertions(+), 160 deletions(-) delete mode 100644 test/integration/api/web_analytics.test.ts create mode 100644 test/integration/api/web_analytics/batch-mode.test.ts create mode 100644 test/integration/api/web_analytics/request-validation.test.ts create mode 100644 test/utils/assertions.ts delete mode 100644 test/utils/assertions/assertions.ts delete mode 100644 test/utils/assertions/index.ts diff --git a/test/integration/api/web_analytics.test.ts b/test/integration/api/web_analytics.test.ts deleted file mode 100644 index e7c3fbe4..00000000 --- a/test/integration/api/web_analytics.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import {describe, expect, it, beforeEach, beforeAll, vi} from 'vitest'; -import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; -import {expectValidationErrorWithMessage, expectUnsupportedMediaTypeErrorWithMessage} from '../../utils/assertions'; -import {createPubSubSpy} from '../../utils/pubsub-spy'; -import defaultValidRequestQuery from '../../utils/fixtures/defaultValidRequestQuery.json'; -import defaultValidRequestHeaders from '../../utils/fixtures/defaultValidRequestHeaders.json'; -import defaultValidRequestBody from '../../utils/fixtures/defaultValidRequestBody.json'; -import headersWithInvalidContentType from '../../utils/fixtures/headersWithInvalidContentType.json'; -import headersWithoutUserAgent from '../../utils/fixtures/headersWithoutUserAgent.json'; -import headersWithoutSiteUuid from '../../utils/fixtures/headersWithoutSiteUuid.json'; -import {initializeApp} from '../../../src/initializeApp'; -import {EventPublisher} from '../../../src/services/events/publisher'; - -const preHandlerStub = async (_request: FastifyRequest, reply: FastifyReply) => { - reply.code(202); -}; - -describe('POST /tb/web_analytics', () => { - let app: FastifyInstance; - - describe('Request validation', function () { - beforeEach(async function () { - app = await initializeApp({isWorkerMode: false}); - app.addHook('preHandler', preHandlerStub); - }); - - it('should accept a default valid request from the tracking script', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: defaultValidRequestHeaders, - body: defaultValidRequestBody - }); - expect(response.statusCode).toBe(202); - }); - - describe('requests with missing or invalid required headers', function () { - it('should reject a request with a missing site uuid header', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithoutSiteUuid, - body: defaultValidRequestBody - }); - expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); - }); - - it('should reject a request with a missing user agent header', async function () { - // fastify.inject() adds a default user-agent header, so we need to remove it before validation - app.addHook('onRequest', async (request) => { - delete request.headers['user-agent']; - }); - - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithoutUserAgent, - body: defaultValidRequestBody - }); - expectValidationErrorWithMessage(response, 'headers must have required property \'user-agent\''); - }); - - it('should reject a request with a content type other than application/json', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithInvalidContentType, - body: defaultValidRequestBody - }); - expectUnsupportedMediaTypeErrorWithMessage(response, 'Unsupported Media Type: application/xml'); - }); - }); - }); - - describe('Batch Mode - Publishing to pub/sub', function () { - let pubSubSpy: ReturnType; - const pageHitsRawTopic: string = 'page-hits-raw'; - - beforeAll(async function () { - vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); - }); - beforeEach(async function () { - app = await initializeApp({isWorkerMode: false}); - app.addHook('preHandler', preHandlerStub); - pubSubSpy = createPubSubSpy(); - EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); - }); - - it('should transform the request body and publish to pub/sub', async function () { - await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: defaultValidRequestHeaders, - body: defaultValidRequestBody - }); - - pubSubSpy.expectPublishedMessageToTopic(pageHitsRawTopic).withMessageData({ - timestamp: expect.any(String), - action: 'page_hit', - version: '1', - site_uuid: defaultValidRequestHeaders['x-site-uuid'], - payload: { - event_id: defaultValidRequestBody.payload.event_id, - href: 'https://www.example.com/', - pathname: '/', - member_uuid: 'undefined', - member_status: 'undefined', - post_uuid: 'undefined', - post_type: 'null', - parsedReferrer: { - medium: '', - source: '', - url: '' - }, - locale: 'en-US', - location: 'US', - referrer: null - }, - meta: { - ip: expect.any(String), - 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' - } - }); - }); - - it('should not publish a message if the request fails validation', async function () { - const response = await app.inject({ - method: 'POST', - url: '/tb/web_analytics', - query: defaultValidRequestQuery, - headers: headersWithoutSiteUuid, - body: defaultValidRequestBody - }); - expectValidationErrorWithMessage(response, 'headers must have required property \'x-site-uuid\''); - pubSubSpy.expectNoMessagesPublished(); - }); - }); -}); \ No newline at end of file diff --git a/test/integration/api/web_analytics/batch-mode.test.ts b/test/integration/api/web_analytics/batch-mode.test.ts new file mode 100644 index 00000000..96aa04ec --- /dev/null +++ b/test/integration/api/web_analytics/batch-mode.test.ts @@ -0,0 +1,88 @@ +import {describe, expect, it, beforeEach, beforeAll, vi} from 'vitest'; +import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; +import {expectResponse} from '../../../utils/assertions'; +import {createPubSubSpy} from '../../../utils/pubsub-spy'; +import defaultValidRequestQuery from '../../../utils/fixtures/defaultValidRequestQuery.json'; +import defaultValidRequestHeaders from '../../../utils/fixtures/defaultValidRequestHeaders.json'; +import defaultValidRequestBody from '../../../utils/fixtures/defaultValidRequestBody.json'; +import headersWithoutSiteUuid from '../../../utils/fixtures/headersWithoutSiteUuid.json'; +import {initializeApp} from '../../../../src/initializeApp'; +import {EventPublisher} from '../../../../src/services/events/publisher'; + +const preHandlerStub = async (_request: FastifyRequest, reply: FastifyReply) => { + reply.code(202); +}; + +describe('POST /tb/web_analytics', () => { + let app: FastifyInstance; + + describe('Batch Mode - Publishing to pub/sub', function () { + let pubSubSpy: ReturnType; + const pageHitsRawTopic: string = 'page-hits-raw'; + + beforeAll(async function () { + vi.stubEnv('PUBSUB_TOPIC_PAGE_HITS_RAW', pageHitsRawTopic); + }); + beforeEach(async function () { + app = await initializeApp({isWorkerMode: false}); + app.addHook('preHandler', preHandlerStub); + pubSubSpy = createPubSubSpy(); + EventPublisher.resetInstance(pubSubSpy.mockPubSub as any); + }); + + it('should transform the request body and publish to pub/sub', async function () { + await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody + }); + + pubSubSpy.expectPublishedMessageToTopic(pageHitsRawTopic).withMessageData({ + timestamp: expect.any(String), + action: 'page_hit', + version: '1', + site_uuid: defaultValidRequestHeaders['x-site-uuid'], + payload: { + event_id: defaultValidRequestBody.payload.event_id, + href: 'https://www.example.com/', + pathname: '/', + member_uuid: 'undefined', + member_status: 'undefined', + post_uuid: 'undefined', + post_type: 'null', + parsedReferrer: { + medium: '', + source: '', + url: '' + }, + locale: 'en-US', + location: 'US', + referrer: null + }, + meta: { + ip: expect.any(String), + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' + } + }); + }); + + it('should not publish a message if the request fails validation', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: headersWithoutSiteUuid, + body: defaultValidRequestBody + }); + expectResponse({ + response, + statusCode: 400, + errorType: 'Bad Request', + message: 'headers must have required property \'x-site-uuid\'' + }); + pubSubSpy.expectNoMessagesPublished(); + }); + }); +}); \ No newline at end of file diff --git a/test/integration/api/web_analytics/request-validation.test.ts b/test/integration/api/web_analytics/request-validation.test.ts new file mode 100644 index 00000000..69509880 --- /dev/null +++ b/test/integration/api/web_analytics/request-validation.test.ts @@ -0,0 +1,91 @@ +import {describe, it, beforeEach} from 'vitest'; +import {FastifyInstance, FastifyRequest, FastifyReply} from 'fastify'; +import {expectResponse} from '../../../utils/assertions'; +import defaultValidRequestQuery from '../../../utils/fixtures/defaultValidRequestQuery.json'; +import defaultValidRequestHeaders from '../../../utils/fixtures/defaultValidRequestHeaders.json'; +import defaultValidRequestBody from '../../../utils/fixtures/defaultValidRequestBody.json'; +import headersWithInvalidContentType from '../../../utils/fixtures/headersWithInvalidContentType.json'; +import headersWithoutUserAgent from '../../../utils/fixtures/headersWithoutUserAgent.json'; +import headersWithoutSiteUuid from '../../../utils/fixtures/headersWithoutSiteUuid.json'; +import {initializeApp} from '../../../../src/initializeApp'; + +const preHandlerStub = async (_request: FastifyRequest, reply: FastifyReply) => { + reply.code(202); +}; + +describe('POST /tb/web_analytics', () => { + let app: FastifyInstance; + + describe('Request validation', function () { + beforeEach(async function () { + app = await initializeApp({isWorkerMode: false}); + app.addHook('preHandler', preHandlerStub); + }); + + it('should accept a default valid request from the tracking script', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: defaultValidRequestHeaders, + body: defaultValidRequestBody + }); + expectResponse({response, statusCode: 202}); + }); + + describe('requests with missing or invalid required headers', function () { + it('should reject a request with a missing site uuid header', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: headersWithoutSiteUuid, + body: defaultValidRequestBody + }); + expectResponse({ + response, + statusCode: 400, + errorType: 'Bad Request', + message: 'headers must have required property \'x-site-uuid\'' + }); + }); + + it('should reject a request with a missing user agent header', async function () { + // fastify.inject() adds a default user-agent header, so we need to remove it before validation + app.addHook('onRequest', async (request) => { + delete request.headers['user-agent']; + }); + + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: headersWithoutUserAgent, + body: defaultValidRequestBody + }); + expectResponse({ + response, + statusCode: 400, + errorType: 'Bad Request', + message: 'headers must have required property \'user-agent\'' + }); + }); + + it('should reject a request with a content type other than application/json', async function () { + const response = await app.inject({ + method: 'POST', + url: '/tb/web_analytics', + query: defaultValidRequestQuery, + headers: headersWithInvalidContentType, + body: defaultValidRequestBody + }); + expectResponse({ + response, + statusCode: 415, + errorType: 'Unsupported Media Type', + message: 'Unsupported Media Type: application/xml' + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/utils/assertions.ts b/test/utils/assertions.ts new file mode 100644 index 00000000..047c62aa --- /dev/null +++ b/test/utils/assertions.ts @@ -0,0 +1,13 @@ +import {expect} from 'vitest'; +import type {Response} from 'light-my-request'; + +export function expectResponse({response, statusCode, errorType, message}: {response: Response, statusCode: number, errorType?: string, message?: string}) { + const bodyJson = JSON.parse(response.body); + expect(response.statusCode).toBe(statusCode); + if (errorType) { + expect(bodyJson.error).toBe(errorType); + } + if (message) { + expect(bodyJson.message).toBe(message); + } +} \ No newline at end of file diff --git a/test/utils/assertions/assertions.ts b/test/utils/assertions/assertions.ts deleted file mode 100644 index a0a35942..00000000 --- a/test/utils/assertions/assertions.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {expect} from 'vitest'; -import type {Response} from 'light-my-request'; - -export function expectUnsupportedMediaTypeErrorWithMessage(response: Response, message: string) { - const bodyJson = JSON.parse(response.body); - expect(response.statusCode).toBe(415); - expect(bodyJson.error).toBe('Unsupported Media Type'); - expect(bodyJson.message).toBe(message); -} - -export function expectValidationErrorWithMessage(response: Response, message: string) { - const bodyJson = JSON.parse(response.body); - expect(response.statusCode).toBe(400); - expect(bodyJson.error).toBe('Bad Request'); - expect(bodyJson.message).toBe(message); -} \ No newline at end of file diff --git a/test/utils/assertions/index.ts b/test/utils/assertions/index.ts deleted file mode 100644 index eafe9451..00000000 --- a/test/utils/assertions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './assertions'; \ No newline at end of file From c1e8f9ed3f2fd556167cd131e6acab8d5bd22246 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:37:50 -0700 Subject: [PATCH 18/19] Updated package.json version to match main --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 93ef53dd..b6ca2103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "traffic-analytics", - "version": "0.0.44", + "version": "0.0.45", "repository": "git@github.com:TryGhost/TrafficAnalytics.git", "author": "Ghost Foundation", "license": "MIT", @@ -41,7 +41,7 @@ "devDependencies": { "@playwright/test": "1.53.2", "@types/dotenv": "8.2.3", - "@types/node": "24.0.9", + "@types/node": "24.0.10", "@types/supertest": "6.0.3", "@types/ua-parser-js": "0.7.39", "@vitest/coverage-v8": "3.2.4", From f74bc11dd44ac33135e4297939a217e4b163ca5e Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 3 Jul 2025 17:42:25 -0700 Subject: [PATCH 19/19] Updated yarn.lock --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8b293d6c..3e86d1f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2529,20 +2529,13 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=13.7.0": +"@types/node@*", "@types/node@24.0.10", "@types/node@>=13.7.0": version "24.0.10" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.10.tgz#f65a169779bf0d70203183a1890be7bee8ca2ddb" integrity sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA== dependencies: undici-types "~7.8.0" -"@types/node@24.0.9": - version "24.0.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.9.tgz#0276ebb02550c9bec72d1fcec7642431dfaacad0" - integrity sha512-AVQN3mtPEWbtWfXryyHV8+8IBD4Ijm1reEVPjNWT1AvTQdLxlaroFy425Y+tKVSEJvUnKbyOGkaU6BH7Ox+NzQ== - dependencies: - undici-types "~7.8.0" - "@types/normalize-package-data@^2.4.0": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"