fix(kobo): support GET request on analytics gettests endpoint

Kobo devices send GET requests to /v1/analytics/gettests during sync checks. Previously only POST was handled, causing 404 errors during subsequent syncs.

Closes #697
This commit is contained in:
neon
2026-07-22 17:39:45 +00:00
parent 068de92b33
commit fb93f6ec20
4 changed files with 51 additions and 6 deletions
@@ -1,3 +1,6 @@
import { RequestMethod } from '@nestjs/common';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { KoboDeviceController } from './kobo-device.controller';
function makeReply() {
@@ -160,14 +163,34 @@ describe('KoboDeviceController', () => {
expect(thumbnailService.serveThumbnail).toHaveBeenCalledWith(11, 420, undefined, reply);
});
it('returns static payload endpoints and analytics keys', async () => {
it('returns static payload endpoints and ingests analytics events', async () => {
expect(controller.affiliate()).toEqual({});
expect(controller.remainingBookSeries()).toEqual({ TotalResultCount: 0, SearchResults: [] });
expect(controller.productNextRead()).toEqual([]);
analyticsService.ingest.mockResolvedValue(undefined);
await expect(controller.analyticsEvent({ Events: [] }, { id: 3 } as never, { deviceToken: 't' } as never)).resolves.toEqual({});
expect(analyticsService.ingest).toHaveBeenCalledWith({ Events: [] }, { id: 3 }, { deviceToken: 't' });
expect(controller.getTests()).toEqual({ Result: 'Success', TestKey: expect.any(String) });
});
it('registers GET and POST handlers for the advertised gettests endpoint', () => {
expect(Reflect.getMetadata(PATH_METADATA, KoboDeviceController.prototype.getTests)).toBe('v1/analytics/gettests');
expect(Reflect.getMetadata(METHOD_METADATA, KoboDeviceController.prototype.getTests)).toBe(RequestMethod.GET);
expect(Reflect.getMetadata(PATH_METADATA, KoboDeviceController.prototype.postGetTests)).toBe('v1/analytics/gettests');
expect(Reflect.getMetadata(METHOD_METADATA, KoboDeviceController.prototype.postGetTests)).toBe(RequestMethod.POST);
});
it.each([
['GET', () => controller.getTests()],
['POST', () => controller.postGetTests()],
])('returns a Kobo-compatible success payload for %s gettests requests', (_method, request) => {
const response = request();
expect(response).toEqual({
Result: 'Success',
TestKey: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/),
Tests: {},
});
expect(proxyService.forward).not.toHaveBeenCalled();
});
it('returns {} when analytics ingest throws', async () => {
@@ -119,10 +119,16 @@ export class KoboDeviceController {
return [];
}
@Post('v1/analytics/gettests')
@Get('v1/analytics/gettests')
@HttpCode(HttpStatus.OK)
getTests() {
return { Result: 'Success', TestKey: randomUUID() };
return this.buildGetTestsResponse();
}
@Post('v1/analytics/gettests')
@HttpCode(HttpStatus.OK)
postGetTests() {
return this.buildGetTestsResponse();
}
@Post('v1/analytics/event')
@@ -143,6 +149,10 @@ export class KoboDeviceController {
await this.proxyService.forward(req, reply, device.deviceToken);
}
private buildGetTestsResponse() {
return { Result: 'Success', TestKey: randomUUID(), Tests: {} };
}
private async serveThumbnailOrProxy(
bookId: string,
ifNoneMatch: string | undefined,
@@ -1,7 +1,7 @@
{
"totalRoutes": 367,
"totalRoutes": 368,
"byPermission": {
"(none)": 164,
"(none)": 165,
"Permission.ManageMetadataConfig": 32,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 31,
@@ -195,6 +195,18 @@
"file": "server/src/modules/kobo/kobo-device.controller.ts",
"className": "KoboDeviceController",
"methodName": "getTests",
"httpMethod": "GET",
"path": "/kobo/:deviceToken/v1/analytics/gettests",
"permissions": [],
"libraryAccess": [],
"isPublic": true,
"allowDefault": false,
"useGuards": ["KoboTokenGuard"]
},
{
"file": "server/src/modules/kobo/kobo-device.controller.ts",
"className": "KoboDeviceController",
"methodName": "postGetTests",
"httpMethod": "POST",
"path": "/kobo/:deviceToken/v1/analytics/gettests",
"permissions": [],