From cd7207873da999a2b0781455ad5da3149948760a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:31:15 +0200 Subject: [PATCH] Fix historical market data gathering at daylight saving time change --- apps/api/jest-environment-tz.js | 30 +++++++ .../data-gathering.service.time-zone.spec.ts | 83 +++++++++++++++++++ .../data-gathering/data-gathering.service.ts | 5 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 apps/api/jest-environment-tz.js create mode 100644 apps/api/src/services/queues/data-gathering/data-gathering.service.time-zone.spec.ts diff --git a/apps/api/jest-environment-tz.js b/apps/api/jest-environment-tz.js new file mode 100644 index 000000000..ed05fec44 --- /dev/null +++ b/apps/api/jest-environment-tz.js @@ -0,0 +1,30 @@ +const NodeEnvironment = require('jest-environment-node').TestEnvironment; + +// Jest gives each test file a copy of `process`, so a test cannot change the +// time zone at run time. This environment sets `TZ` on the real process of the +// worker, which resets the internal date cache of Node. +class TimeZoneEnvironment extends NodeEnvironment { + constructor(config, context) { + super(config, context); + + this.timeZone = config.projectConfig.testEnvironmentOptions?.timeZone; + } + + async setup() { + this.previousTimeZone = process.env.TZ; + + if (this.timeZone) { + process.env.TZ = this.timeZone; + } + + await super.setup(); + } + + async teardown() { + await super.teardown(); + + process.env.TZ = this.previousTimeZone; + } +} + +module.exports = TimeZoneEnvironment; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.time-zone.spec.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.time-zone.spec.ts new file mode 100644 index 000000000..bd826420b --- /dev/null +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.time-zone.spec.ts @@ -0,0 +1,83 @@ +/** + * @jest-environment /jest-environment-tz.js + * @jest-environment-options {"timeZone": "Europe/Berlin"} + */ +import { DataGatheringService } from './data-gathering.service'; + +describe('DataGatheringService in a time zone with daylight saving time', () => { + let dataGatheringQueue: { addBulk: jest.Mock; clean: jest.Mock }; + let dataGatheringService: DataGatheringService; + let prismaService: { marketData: { groupBy: jest.Mock; upsert: jest.Mock } }; + + beforeEach(() => { + dataGatheringQueue = { + addBulk: jest.fn().mockResolvedValue([]), + clean: jest.fn().mockResolvedValue([]) + }; + prismaService = { + marketData: { + groupBy: jest.fn().mockResolvedValue([]), + upsert: jest.fn().mockResolvedValue({}) + } + }; + + dataGatheringService = new DataGatheringService( + null, + dataGatheringQueue as any, + { getHistoricalRaw: jest.fn() } as any, + null, + null, + prismaService as any, + null, + null + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getAssetProfileIdentifiersWithRecentMarketData', () => { + it('keeps a market price of yesterday when the clocks go forward', async () => { + // The clocks in Berlin go forward on 2026-03-29, so a local day is 23 hours + jest.useFakeTimers().setSystemTime(new Date('2026-03-29T23:00:00.000Z')); + + prismaService.marketData.groupBy.mockResolvedValue([ + { + _max: { date: new Date('2026-03-28T00:00:00.000Z') }, + dataSource: 'COINGECKO', + symbol: 'bitcoin' + } + ]); + + const assetProfileIdentifiers = + await dataGatheringService[ + 'getAssetProfileIdentifiersWithRecentMarketData' + ](); + + expect(assetProfileIdentifiers).toEqual([ + { dataSource: 'COINGECKO', symbol: 'bitcoin' } + ]); + }); + + it('drops a market price of two days ago when the clocks go back', async () => { + // The clocks in Berlin go back on 2026-10-25, so a local day is 25 hours + jest.useFakeTimers().setSystemTime(new Date('2026-10-26T00:00:00.000Z')); + + prismaService.marketData.groupBy.mockResolvedValue([ + { + _max: { date: new Date('2026-10-24T00:00:00.000Z') }, + dataSource: 'COINGECKO', + symbol: 'bitcoin' + } + ]); + + const assetProfileIdentifiers = + await dataGatheringService[ + 'getAssetProfileIdentifiersWithRecentMarketData' + ](); + + expect(assetProfileIdentifiers).toEqual([]); + }); + }); +}); diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 531f36f0d..9e12d6950 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -437,7 +437,10 @@ export class DataGatheringService { }) ) .filter(({ _max }) => { - return !isBefore(_max.date, getStartOfUtcDate(subDays(new Date(), 1))); + return !isBefore( + _max.date, + subMilliseconds(getStartOfUtcDate(new Date()), ms('1 day')) + ); }) .map(({ dataSource, symbol }) => { return { dataSource, symbol };