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 { MongoClient } = require('mongodb');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
|
||||||
// Will require insertPrices after implementation
|
// Will require insertPrices and getNow after implementation
|
||||||
let insertPrices;
|
let insertPrices;
|
||||||
|
let getNow;
|
||||||
|
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
let client;
|
let client;
|
||||||
@ -41,7 +42,7 @@ beforeAll(async () => {
|
|||||||
db = client.db('test_apartments');
|
db = client.db('test_apartments');
|
||||||
|
|
||||||
// Dynamically require to pick up implementation
|
// Dynamically require to pick up implementation
|
||||||
({ insertPrices } = require('../../services/scraperService'));
|
({ insertPrices, getNow } = require('../../services/scraperService'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -241,12 +242,12 @@ describe('insertPrices', () => {
|
|||||||
|
|
||||||
it('should include last_updated as an ISO timestamp string', async () => {
|
it('should include last_updated as an ISO timestamp string', async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const beforeTime = new Date().toISOString();
|
const beforeTime = getNow();
|
||||||
|
|
||||||
const units = [{ unit_code: 'F401', price: 1600 }];
|
const units = [{ unit_code: 'F401', price: 1600 }];
|
||||||
await insertPrices(db, units, TODAY, logger);
|
await insertPrices(db, units, TODAY, logger);
|
||||||
|
|
||||||
const afterTime = new Date().toISOString();
|
const afterTime = getNow();
|
||||||
|
|
||||||
const doc = await db.collection(PRICES_COLLECTION).findOne({});
|
const doc = await db.collection(PRICES_COLLECTION).findOne({});
|
||||||
expect(doc.last_updated).toBeDefined();
|
expect(doc.last_updated).toBeDefined();
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
|
|||||||
|
|
||||||
// Will require after implementation
|
// Will require after implementation
|
||||||
let recordScraperRun;
|
let recordScraperRun;
|
||||||
|
let getNow;
|
||||||
|
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
let client;
|
let client;
|
||||||
@ -29,7 +30,7 @@ beforeAll(async () => {
|
|||||||
db = client.db('test_apartments');
|
db = client.db('test_apartments');
|
||||||
|
|
||||||
// Dynamically require to pick up implementation
|
// Dynamically require to pick up implementation
|
||||||
({ recordScraperRun } = require('../../services/scraperService'));
|
({ recordScraperRun, getNow } = require('../../services/scraperService'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -269,26 +270,26 @@ describe('recordScraperRun', () => {
|
|||||||
// 6. recordedAt is a valid Date object
|
// 6. recordedAt is a valid Date object
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
describe('recordedAt field', () => {
|
describe('recordedAt field', () => {
|
||||||
it('should set recordedAt as a Date instance', async () => {
|
it('should set recordedAt as a string timestamp', async () => {
|
||||||
const runData = createSampleRunData();
|
const runData = createSampleRunData();
|
||||||
|
|
||||||
await recordScraperRun(db, runData, logger);
|
await recordScraperRun(db, runData, logger);
|
||||||
|
|
||||||
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
|
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 () => {
|
it('should set recordedAt close to current time', async () => {
|
||||||
const beforeTime = new Date();
|
const beforeTime = getNow();
|
||||||
|
|
||||||
const runData = createSampleRunData();
|
const runData = createSampleRunData();
|
||||||
await recordScraperRun(db, runData, logger);
|
await recordScraperRun(db, runData, logger);
|
||||||
|
|
||||||
const afterTime = new Date();
|
const afterTime = getNow();
|
||||||
|
|
||||||
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
|
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
|
||||||
expect(doc.recordedAt.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime());
|
expect(doc.recordedAt >= beforeTime).toBe(true);
|
||||||
expect(doc.recordedAt.getTime()).toBeLessThanOrEqual(afterTime.getTime());
|
expect(doc.recordedAt <= afterTime).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not overwrite any existing runData fields with recordedAt', async () => {
|
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
|
// Verify the original fields still exist alongside recordedAt
|
||||||
expect(doc.jobId).toBe(runData.jobId);
|
expect(doc.jobId).toBe(runData.jobId);
|
||||||
expect(doc.status).toBe(runData.status);
|
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' });
|
const saved = await db.collection('scraper_runs').findOne({ jobId: 'test-job-001' });
|
||||||
expect(saved).toBeTruthy();
|
expect(saved).toBeTruthy();
|
||||||
expect(saved.status).toBe('success');
|
expect(saved.status).toBe('success');
|
||||||
expect(saved.recordedAt).toBeInstanceOf(Date);
|
expect(typeof saved.recordedAt).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not throw when insert fails', async () => {
|
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' });
|
const runRecord = await db.collection('scraper_runs').findOne({ jobId: 'test-record-success' });
|
||||||
expect(runRecord).toBeTruthy();
|
expect(runRecord).toBeTruthy();
|
||||||
expect(runRecord.status).toBe('success');
|
expect(runRecord.status).toBe('success');
|
||||||
expect(runRecord.recordedAt).toBeInstanceOf(Date);
|
expect(typeof runRecord.recordedAt).toBe('string');
|
||||||
expect(runRecord.unitsProcessed).toBe(1);
|
expect(runRecord.unitsProcessed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -554,8 +554,8 @@ describe('updateDailySummary', () => {
|
|||||||
const doc = await db.collection(DAILY_SUMMARIES_COLLECTION).findOne({ date: '2026-02-05' });
|
const doc = await db.collection(DAILY_SUMMARIES_COLLECTION).findOne({ date: '2026-02-05' });
|
||||||
expect(doc.timestamp).toBeDefined();
|
expect(doc.timestamp).toBeDefined();
|
||||||
expect(typeof doc.timestamp).toBe('string');
|
expect(typeof doc.timestamp).toBe('string');
|
||||||
// Verify it is a valid ISO timestamp
|
// Verify it matches the Denver-local timestamp format (YYYY-MM-DDTHH:mm:ss.mmm)
|
||||||
expect(new Date(doc.timestamp).toISOString()).toBe(doc.timestamp);
|
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 { MongoClient } = require('mongodb');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
|
||||||
// We will require upsertUnits after implementation
|
// We will require upsertUnits and getNow after implementation
|
||||||
let upsertUnits;
|
let upsertUnits;
|
||||||
|
let getNow;
|
||||||
|
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
let client;
|
let client;
|
||||||
@ -39,7 +40,7 @@ beforeAll(async () => {
|
|||||||
db = client.db('test_apartments');
|
db = client.db('test_apartments');
|
||||||
|
|
||||||
// Dynamically require to pick up implementation
|
// Dynamically require to pick up implementation
|
||||||
({ upsertUnits } = require('../../services/scraperService'));
|
({ upsertUnits, getNow } = require('../../services/scraperService'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -145,12 +146,12 @@ describe('upsertUnits', () => {
|
|||||||
describe('$set fields', () => {
|
describe('$set fields', () => {
|
||||||
it('should set last_scraped timestamp on every upsert', async () => {
|
it('should set last_scraped timestamp on every upsert', async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const beforeTime = new Date().toISOString();
|
const beforeTime = getNow();
|
||||||
|
|
||||||
const units = [{ unit_code: 'T100', price: 1000 }];
|
const units = [{ unit_code: 'T100', price: 1000 }];
|
||||||
await upsertUnits(db, units, logger);
|
await upsertUnits(db, units, logger);
|
||||||
|
|
||||||
const afterTime = new Date().toISOString();
|
const afterTime = getNow();
|
||||||
|
|
||||||
const doc = await db.collection(UNITS_COLLECTION).findOne({ unit_code: 'T100' });
|
const doc = await db.collection(UNITS_COLLECTION).findOne({ unit_code: 'T100' });
|
||||||
expect(doc.last_scraped).toBeDefined();
|
expect(doc.last_scraped).toBeDefined();
|
||||||
@ -221,7 +222,7 @@ describe('upsertUnits', () => {
|
|||||||
describe('$setOnInsert for first_seen', () => {
|
describe('$setOnInsert for first_seen', () => {
|
||||||
it('should set first_seen on initial insert', async () => {
|
it('should set first_seen on initial insert', async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const beforeTime = new Date().toISOString();
|
const beforeTime = getNow();
|
||||||
|
|
||||||
await upsertUnits(db, [{ unit_code: 'F100', price: 1000 }], logger);
|
await upsertUnits(db, [{ unit_code: 'F100', price: 1000 }], logger);
|
||||||
|
|
||||||
|
|||||||
@ -403,7 +403,7 @@ function getYesterday(dateStr) {
|
|||||||
*/
|
*/
|
||||||
async function upsertUnits(db, units, logger) {
|
async function upsertUnits(db, units, logger) {
|
||||||
const collection = db.collection(config.COLLECTIONS.UNITS);
|
const collection = db.collection(config.COLLECTIONS.UNITS);
|
||||||
const now = new Date().toISOString();
|
const now = getNow();
|
||||||
|
|
||||||
const operations = units.map(unit => ({
|
const operations = units.map(unit => ({
|
||||||
updateOne: {
|
updateOne: {
|
||||||
@ -461,7 +461,7 @@ async function upsertUnits(db, units, logger) {
|
|||||||
*/
|
*/
|
||||||
async function insertPrices(db, units, date, logger) {
|
async function insertPrices(db, units, date, logger) {
|
||||||
const collection = db.collection(config.COLLECTIONS.PRICES);
|
const collection = db.collection(config.COLLECTIONS.PRICES);
|
||||||
const now = new Date().toISOString();
|
const now = getNow();
|
||||||
|
|
||||||
// Filter out units without prices
|
// Filter out units without prices
|
||||||
const unitsWithPrices = units.filter(u => u.price !== null);
|
const unitsWithPrices = units.filter(u => u.price !== null);
|
||||||
@ -581,7 +581,7 @@ async function updateDailySummary(db, summaryData, logger) {
|
|||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
date,
|
date,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: getNow(),
|
||||||
new_units: newUnits,
|
new_units: newUnits,
|
||||||
rented_units: rentedUnits,
|
rented_units: rentedUnits,
|
||||||
stale_units: [], // Stale units list is not tracked per PRD
|
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 });
|
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.
|
* Get the set of unit codes that had price records yesterday.
|
||||||
* Used to calculate new and rented units by comparison.
|
* 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 collection = db.collection(config.COLLECTIONS.SCRAPER_RUNS);
|
||||||
const result = await collection.insertOne({
|
const result = await collection.insertOne({
|
||||||
...runData,
|
...runData,
|
||||||
recordedAt: new Date()
|
recordedAt: getNow()
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -790,7 +810,7 @@ async function runScrape(db, options = {}) {
|
|||||||
trigger,
|
trigger,
|
||||||
dryRun,
|
dryRun,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: getNow(),
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
duration: null,
|
duration: null,
|
||||||
unitsProcessed: 0,
|
unitsProcessed: 0,
|
||||||
@ -875,7 +895,7 @@ async function runScrape(db, options = {}) {
|
|||||||
result.errors.push(cleanError.message);
|
result.errors.push(cleanError.message);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
result.completedAt = new Date().toISOString();
|
result.completedAt = getNow();
|
||||||
result.duration = Date.now() - startTime;
|
result.duration = Date.now() - startTime;
|
||||||
|
|
||||||
// Record run to history (always runs, even on failure)
|
// Record run to history (always runs, even on failure)
|
||||||
@ -905,6 +925,7 @@ module.exports = {
|
|||||||
createScraperIndexes,
|
createScraperIndexes,
|
||||||
// Export helpers for testing
|
// Export helpers for testing
|
||||||
getToday,
|
getToday,
|
||||||
|
getNow,
|
||||||
getYesterdayUnitCodes,
|
getYesterdayUnitCodes,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
parseInteger,
|
parseInteger,
|
||||||
|
|||||||
Reference in New Issue
Block a user