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.processor.time-zone.spec.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.time-zone.spec.ts new file mode 100644 index 000000000..2d65b7384 --- /dev/null +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.time-zone.spec.ts @@ -0,0 +1,70 @@ +/** + * @jest-environment /jest-environment-tz.js + * @jest-environment-options {"timeZone": "America/New_York"} + */ +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Job } from 'bull'; + +import { DataGatheringProcessor } from './data-gathering.processor'; + +describe('DataGatheringProcessor in a time zone behind UTC', () => { + let dataGatheringProcessor: DataGatheringProcessor; + let dataProviderService: { getHistoricalRaw: jest.Mock }; + let marketDataService: { replaceForSymbol: jest.Mock; updateMany: jest.Mock }; + + beforeAll(() => { + // 2026-08-23 21:30 in New York, but already 2026-08-24 in UTC + jest.useFakeTimers().setSystemTime(new Date('2026-08-24T01:30:00.000Z')); + }); + + beforeEach(() => { + dataProviderService = { + getHistoricalRaw: jest.fn().mockResolvedValue({ + 'COINGECKO-bitcoin': { + '2026-08-21': { marketPrice: 5 }, + '2026-08-22': { marketPrice: 6 }, + '2026-08-23': { marketPrice: 7 } + } + }) + }; + marketDataService = { + replaceForSymbol: jest.fn(), + updateMany: jest.fn() + }; + + dataGatheringProcessor = new DataGatheringProcessor( + null, + dataProviderService as any, + marketDataService as any, + null + ); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('gathers up to the last complete UTC day', async () => { + await dataGatheringProcessor.gatherHistoricalMarketData({ + data: { + dataSource: 'COINGECKO', + symbol: 'bitcoin', + date: parseDate('2026-08-21').toISOString() + } + } as unknown as Job); + + const { data } = marketDataService.updateMany.mock.calls[0][0]; + + expect( + data.map(({ date }) => { + return date; + }) + ).toEqual([ + new Date('2026-08-21T00:00:00.000Z'), + new Date('2026-08-22T00:00:00.000Z'), + new Date('2026-08-23T00:00:00.000Z') + ]); + }); +}); diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts index b0e8faa81..69d98c052 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts @@ -60,6 +60,54 @@ describe('DataGatheringService', () => { ); }); + it('includes the Friday close when it runs on Saturday', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-22T14:00:00.000Z')); + + await dataGatheringService[ + 'getAssetProfileIdentifiersWithRecentMarketData' + ](); + + expect(prismaService.marketData.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + date: { gte: new Date('2026-08-21T00:00:00.000Z') } + }) + }) + ); + }); + + it('excludes the Friday close when it runs on Sunday', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-23T14:00:00.000Z')); + + await dataGatheringService[ + 'getAssetProfileIdentifiersWithRecentMarketData' + ](); + + expect(prismaService.marketData.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + date: { gte: new Date('2026-08-22T00:00:00.000Z') } + }) + }) + ); + }); + + it('excludes the Friday close when it runs on Monday', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-24T14:00:00.000Z')); + + await dataGatheringService[ + 'getAssetProfileIdentifiersWithRecentMarketData' + ](); + + expect(prismaService.marketData.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + date: { gte: new Date('2026-08-23T00:00:00.000Z') } + }) + }) + ); + }); + it('maps the query result to asset profile identifiers', async () => { prismaService.marketData.groupBy.mockResolvedValue([ { dataSource: 'COINGECKO', symbol: 'bitcoin' },