Use Denver local time for all scraper DB timestamps
All checks were successful
CI/CD Pipeline - Apartment API / Lint & Test (push) Successful in 44s
CI/CD Pipeline - Apartment API / Scan Dependencies (push) Successful in 13s
CI/CD Pipeline - Apartment API / Send Webhook Notification (push) Has been skipped
CI/CD Pipeline - Apartment API / Build & Push Image (push) Successful in 1m37s
CI/CD Pipeline - Apartment API / Deploy to Production (push) Successful in 15s
All checks were successful
CI/CD Pipeline - Apartment API / Lint & Test (push) Successful in 44s
CI/CD Pipeline - Apartment API / Scan Dependencies (push) Successful in 13s
CI/CD Pipeline - Apartment API / Send Webhook Notification (push) Has been skipped
CI/CD Pipeline - Apartment API / Build & Push Image (push) Successful in 1m37s
CI/CD Pipeline - Apartment API / Deploy to Production (push) Successful in 15s
The scraper used new Date().toISOString() for timestamp fields written to MongoDB, producing UTC times. This replaces all 6 DB timestamp sites with a new getNow() helper that uses Intl.DateTimeFormat with the configured SCRAPER_TIMEZONE to produce Denver-local timestamps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -15,8 +15,9 @@
|
||||
const { MongoClient } = require('mongodb');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
// Will require insertPrices after implementation
|
||||
// Will require insertPrices and getNow after implementation
|
||||
let insertPrices;
|
||||
let getNow;
|
||||
|
||||
let mongoServer;
|
||||
let client;
|
||||
@ -41,7 +42,7 @@ beforeAll(async () => {
|
||||
db = client.db('test_apartments');
|
||||
|
||||
// Dynamically require to pick up implementation
|
||||
({ insertPrices } = require('../../services/scraperService'));
|
||||
({ insertPrices, getNow } = require('../../services/scraperService'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@ -241,12 +242,12 @@ describe('insertPrices', () => {
|
||||
|
||||
it('should include last_updated as an ISO timestamp string', async () => {
|
||||
const logger = createMockLogger();
|
||||
const beforeTime = new Date().toISOString();
|
||||
const beforeTime = getNow();
|
||||
|
||||
const units = [{ unit_code: 'F401', price: 1600 }];
|
||||
await insertPrices(db, units, TODAY, logger);
|
||||
|
||||
const afterTime = new Date().toISOString();
|
||||
const afterTime = getNow();
|
||||
|
||||
const doc = await db.collection(PRICES_COLLECTION).findOne({});
|
||||
expect(doc.last_updated).toBeDefined();
|
||||
|
||||
@ -15,6 +15,7 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
// Will require after implementation
|
||||
let recordScraperRun;
|
||||
let getNow;
|
||||
|
||||
let mongoServer;
|
||||
let client;
|
||||
@ -29,7 +30,7 @@ beforeAll(async () => {
|
||||
db = client.db('test_apartments');
|
||||
|
||||
// Dynamically require to pick up implementation
|
||||
({ recordScraperRun } = require('../../services/scraperService'));
|
||||
({ recordScraperRun, getNow } = require('../../services/scraperService'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@ -269,26 +270,26 @@ describe('recordScraperRun', () => {
|
||||
// 6. recordedAt is a valid Date object
|
||||
// ---------------------------------------------------------------
|
||||
describe('recordedAt field', () => {
|
||||
it('should set recordedAt as a Date instance', async () => {
|
||||
it('should set recordedAt as a string timestamp', async () => {
|
||||
const runData = createSampleRunData();
|
||||
|
||||
await recordScraperRun(db, runData, logger);
|
||||
|
||||
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
|
||||
expect(doc.recordedAt).toBeInstanceOf(Date);
|
||||
expect(typeof doc.recordedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should set recordedAt close to current time', async () => {
|
||||
const beforeTime = new Date();
|
||||
const beforeTime = getNow();
|
||||
|
||||
const runData = createSampleRunData();
|
||||
await recordScraperRun(db, runData, logger);
|
||||
|
||||
const afterTime = new Date();
|
||||
const afterTime = getNow();
|
||||
|
||||
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
|
||||
expect(doc.recordedAt.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime());
|
||||
expect(doc.recordedAt.getTime()).toBeLessThanOrEqual(afterTime.getTime());
|
||||
expect(doc.recordedAt >= beforeTime).toBe(true);
|
||||
expect(doc.recordedAt <= afterTime).toBe(true);
|
||||
});
|
||||
|
||||
it('should not overwrite any existing runData fields with recordedAt', async () => {
|
||||
@ -301,7 +302,7 @@ describe('recordScraperRun', () => {
|
||||
// Verify the original fields still exist alongside recordedAt
|
||||
expect(doc.jobId).toBe(runData.jobId);
|
||||
expect(doc.status).toBe(runData.status);
|
||||
expect(doc.recordedAt).toBeInstanceOf(Date);
|
||||
expect(typeof doc.recordedAt).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -121,7 +121,7 @@ describe('recordScraperRun', () => {
|
||||
const saved = await db.collection('scraper_runs').findOne({ jobId: 'test-job-001' });
|
||||
expect(saved).toBeTruthy();
|
||||
expect(saved.status).toBe('success');
|
||||
expect(saved.recordedAt).toBeInstanceOf(Date);
|
||||
expect(typeof saved.recordedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should not throw when insert fails', async () => {
|
||||
@ -263,7 +263,7 @@ describe('runScrape', () => {
|
||||
const runRecord = await db.collection('scraper_runs').findOne({ jobId: 'test-record-success' });
|
||||
expect(runRecord).toBeTruthy();
|
||||
expect(runRecord.status).toBe('success');
|
||||
expect(runRecord.recordedAt).toBeInstanceOf(Date);
|
||||
expect(typeof runRecord.recordedAt).toBe('string');
|
||||
expect(runRecord.unitsProcessed).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@ -554,8 +554,8 @@ describe('updateDailySummary', () => {
|
||||
const doc = await db.collection(DAILY_SUMMARIES_COLLECTION).findOne({ date: '2026-02-05' });
|
||||
expect(doc.timestamp).toBeDefined();
|
||||
expect(typeof doc.timestamp).toBe('string');
|
||||
// Verify it is a valid ISO timestamp
|
||||
expect(new Date(doc.timestamp).toISOString()).toBe(doc.timestamp);
|
||||
// Verify it matches the Denver-local timestamp format (YYYY-MM-DDTHH:mm:ss.mmm)
|
||||
expect(doc.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -15,8 +15,9 @@
|
||||
const { MongoClient } = require('mongodb');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
// We will require upsertUnits after implementation
|
||||
// We will require upsertUnits and getNow after implementation
|
||||
let upsertUnits;
|
||||
let getNow;
|
||||
|
||||
let mongoServer;
|
||||
let client;
|
||||
@ -39,7 +40,7 @@ beforeAll(async () => {
|
||||
db = client.db('test_apartments');
|
||||
|
||||
// Dynamically require to pick up implementation
|
||||
({ upsertUnits } = require('../../services/scraperService'));
|
||||
({ upsertUnits, getNow } = require('../../services/scraperService'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@ -145,12 +146,12 @@ describe('upsertUnits', () => {
|
||||
describe('$set fields', () => {
|
||||
it('should set last_scraped timestamp on every upsert', async () => {
|
||||
const logger = createMockLogger();
|
||||
const beforeTime = new Date().toISOString();
|
||||
const beforeTime = getNow();
|
||||
|
||||
const units = [{ unit_code: 'T100', price: 1000 }];
|
||||
await upsertUnits(db, units, logger);
|
||||
|
||||
const afterTime = new Date().toISOString();
|
||||
const afterTime = getNow();
|
||||
|
||||
const doc = await db.collection(UNITS_COLLECTION).findOne({ unit_code: 'T100' });
|
||||
expect(doc.last_scraped).toBeDefined();
|
||||
@ -221,7 +222,7 @@ describe('upsertUnits', () => {
|
||||
describe('$setOnInsert for first_seen', () => {
|
||||
it('should set first_seen on initial insert', async () => {
|
||||
const logger = createMockLogger();
|
||||
const beforeTime = new Date().toISOString();
|
||||
const beforeTime = getNow();
|
||||
|
||||
await upsertUnits(db, [{ unit_code: 'F100', price: 1000 }], logger);
|
||||
|
||||
|
||||
@ -403,7 +403,7 @@ function getYesterday(dateStr) {
|
||||
*/
|
||||
async function upsertUnits(db, units, logger) {
|
||||
const collection = db.collection(config.COLLECTIONS.UNITS);
|
||||
const now = new Date().toISOString();
|
||||
const now = getNow();
|
||||
|
||||
const operations = units.map(unit => ({
|
||||
updateOne: {
|
||||
@ -461,7 +461,7 @@ async function upsertUnits(db, units, logger) {
|
||||
*/
|
||||
async function insertPrices(db, units, date, logger) {
|
||||
const collection = db.collection(config.COLLECTIONS.PRICES);
|
||||
const now = new Date().toISOString();
|
||||
const now = getNow();
|
||||
|
||||
// Filter out units without prices
|
||||
const unitsWithPrices = units.filter(u => u.price !== null);
|
||||
@ -581,7 +581,7 @@ async function updateDailySummary(db, summaryData, logger) {
|
||||
|
||||
const summary = {
|
||||
date,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: getNow(),
|
||||
new_units: newUnits,
|
||||
rented_units: rentedUnits,
|
||||
stale_units: [], // Stale units list is not tracked per PRD
|
||||
@ -633,6 +633,26 @@ function getToday() {
|
||||
return new Date().toLocaleDateString('en-CA', { timeZone: config.SCRAPER_TIMEZONE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in the configured timezone as an ISO-like string.
|
||||
* Returns format: YYYY-MM-DDTHH:mm:ss.mmm (no Z suffix, local time).
|
||||
* @returns {string} Current timestamp in configured timezone
|
||||
*/
|
||||
function getNow() {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: config.SCRAPER_TIMEZONE,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
formatter.formatToParts(now).map(({ type, value }) => [type, value])
|
||||
);
|
||||
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${ms}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of unit codes that had price records yesterday.
|
||||
* Used to calculate new and rented units by comparison.
|
||||
@ -718,7 +738,7 @@ async function recordScraperRun(db, runData, logger) {
|
||||
const collection = db.collection(config.COLLECTIONS.SCRAPER_RUNS);
|
||||
const result = await collection.insertOne({
|
||||
...runData,
|
||||
recordedAt: new Date()
|
||||
recordedAt: getNow()
|
||||
});
|
||||
|
||||
return result;
|
||||
@ -790,7 +810,7 @@ async function runScrape(db, options = {}) {
|
||||
trigger,
|
||||
dryRun,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
startedAt: getNow(),
|
||||
completedAt: null,
|
||||
duration: null,
|
||||
unitsProcessed: 0,
|
||||
@ -875,7 +895,7 @@ async function runScrape(db, options = {}) {
|
||||
result.errors.push(cleanError.message);
|
||||
|
||||
} finally {
|
||||
result.completedAt = new Date().toISOString();
|
||||
result.completedAt = getNow();
|
||||
result.duration = Date.now() - startTime;
|
||||
|
||||
// Record run to history (always runs, even on failure)
|
||||
@ -905,6 +925,7 @@ module.exports = {
|
||||
createScraperIndexes,
|
||||
// Export helpers for testing
|
||||
getToday,
|
||||
getNow,
|
||||
getYesterdayUnitCodes,
|
||||
getYesterday,
|
||||
parseInteger,
|
||||
|
||||
Reference in New Issue
Block a user