From 554eb8a8efba7253d12dbc77d68bac57488287b3 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 26 Mar 2026 13:38:12 +0000 Subject: [PATCH] Fixed timezone parsing in data seeder timestamps When seeder database timestamp strings were reparsed in non-UTC CI environments, they could shift into the future and make Faker reject inverted date ranges. Parsing those values as UTC and guarding the date range logic keeps the data generator test stable across timezones. --- .../importers/comment-reports-importer.js | 4 +-- .../seeders/importers/comments-importer.js | 5 ++-- .../importers/email-batches-importer.js | 6 ++--- .../importers/email-recipients-importer.js | 12 ++++----- .../data/seeders/importers/emails-importer.js | 7 ++--- .../members-click-events-importer.js | 2 +- .../members-created-events-importer.js | 3 ++- .../importers/members-feedback-importer.js | 7 +++-- .../members-login-events-importer.js | 5 ++-- .../members-status-events-importer.js | 3 +-- .../members-stripe-customers-importer.js | 3 ++- ...stripe-customers-subscriptions-importer.js | 6 +++-- .../members-subscribe-events-importer.js | 3 +-- ...rs-subscription-created-events-importer.js | 4 ++- .../importers/offer-redemptions-importer.js | 6 ++--- .../data/seeders/utils/database-date.js | 27 ++++++++++++++++++- .../data/seeders/data-generator.test.js | 26 ++++++++++++++++++ 17 files changed, 92 insertions(+), 37 deletions(-) diff --git a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js index a74b0dfd40b..a4599cf262e 100644 --- a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js @@ -49,9 +49,7 @@ class CommentReportsImporter extends TableImporter { return null; } - const commentCreatedAt = new Date(this.model.created_at); - const now = new Date(); - const reportTime = faker.date.between(commentCreatedAt, now); + const reportTime = dateToDatabaseString.randomBetween(this.model.created_at, new Date()); const reporter = this.possibleReporters[faker.datatype.number(this.possibleReporters.length - 1)]; diff --git a/ghost/core/core/server/data/seeders/importers/comments-importer.js b/ghost/core/core/server/data/seeders/importers/comments-importer.js index 5a47a98d9b1..1054e813bcd 100644 --- a/ghost/core/core/server/data/seeders/importers/comments-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comments-importer.js @@ -24,6 +24,7 @@ class CommentsImporter extends TableImporter { setReferencedModel(model) { this.model = model; + const publishedAt = dateToDatabaseString.parse(model.published_at); this.commentIds = []; // Store [id, parent_id, timestamp] tuples for reply-to-reply @@ -32,11 +33,11 @@ class CommentsImporter extends TableImporter { trend: 'negative', // Use commentsPerPost as a baseline with some variance (+/- 20%) total: Math.round(this.commentsPerPost * faker.datatype.float({min: 0.8, max: 1.2})), - startTime: new Date(model.published_at), + startTime: publishedAt, endTime: new Date() }).sort((a, b) => a.getTime() - b.getTime()); // Sort chronologically so replies always come after their targets - this.possibleMembers = this.members.filter(member => new Date(member.created_at) < new Date(model.published_at)); + this.possibleMembers = this.members.filter(member => dateToDatabaseString.parse(member.created_at) < publishedAt); } generate() { diff --git a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js index 8a84c352e98..cfc29dbfac3 100644 --- a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js @@ -20,8 +20,8 @@ class EmailBatchesImporter extends TableImporter { } generate() { - const emailSentDate = new Date(this.model.created_at); - const latestUpdatedDate = new Date(this.model.created_at); + const emailSentDate = dateToDatabaseString.parse(this.model.created_at); + const latestUpdatedDate = dateToDatabaseString.parse(this.model.created_at); latestUpdatedDate.setHours(latestUpdatedDate.getHours() + 1); return { @@ -30,7 +30,7 @@ class EmailBatchesImporter extends TableImporter { provider_id: `${new Date().toISOString().split('.')[0].replace(/[^0-9]/g, '')}.${faker.datatype.hexadecimal({length: 16, prefix: '', case: 'lower'})}@m.example.com`, status: 'submitted', // TODO: introduce failures created_at: this.model.created_at, - updated_at: dateToDatabaseString(faker.date.between(emailSentDate, latestUpdatedDate)) + updated_at: dateToDatabaseString(dateToDatabaseString.randomBetween(emailSentDate, latestUpdatedDate)) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js index 7f36a49c973..88020253b55 100644 --- a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js @@ -111,7 +111,7 @@ class EmailRecipientsImporter extends TableImporter { if (!(memberSubscribeEvent.created_at instanceof Date)) { // SQLite fix - memberSubscribeEvent.created_at = new Date(memberSubscribeEvent.created_at); + memberSubscribeEvent.created_at = dateToDatabaseString.parse(memberSubscribeEvent.created_at); } this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(memberSubscribeEvent.created_at.getTime()); } @@ -125,8 +125,8 @@ class EmailRecipientsImporter extends TableImporter { this.batchIndex = this.batch.index; // Shallow clone members list so we can shuffle and modify it - const earliestOpenTime = new Date(this.batch.updated_at); - const latestOpenTime = new Date(this.batch.updated_at); + const earliestOpenTime = dateToDatabaseString.parse(this.batch.updated_at); + const latestOpenTime = dateToDatabaseString.parse(this.batch.updated_at); latestOpenTime.setDate(latestOpenTime.getDate() + 14); // Get all members that were subscribed to this newsletter BEFORE the batch was sent @@ -164,7 +164,7 @@ class EmailRecipientsImporter extends TableImporter { } // The events are generated for a different time, so we need to move them to the batch time - timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + new Date(this.batch.updated_at).getTime()); + timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + dateToDatabaseString.parse(this.batch.updated_at).getTime()); if (timestamp > new Date()) { timestamp = new Date(); @@ -187,9 +187,9 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = this.batch.updated_at; + const startDate = dateToDatabaseString.parse(this.batch.updated_at); const endDate = timestamp; - deliveredTime = faker.date.between(startDate, endDate); + deliveredTime = dateToDatabaseString.randomBetween(startDate, endDate); } return { diff --git a/ghost/core/core/server/data/seeders/importers/emails-importer.js b/ghost/core/core/server/data/seeders/importers/emails-importer.js index e795ba3f78f..b07055cbbe4 100644 --- a/ghost/core/core/server/data/seeders/importers/emails-importer.js +++ b/ghost/core/core/server/data/seeders/importers/emails-importer.js @@ -42,19 +42,20 @@ class EmailsImporter extends TableImporter { : this.newsletters[1]; } + const publishedAt = dateToDatabaseString.parse(this.model.published_at); const timestamp = luck(60) - ? new Date(this.model.published_at) + ? publishedAt : generateEvents({ shape: 'ease-out', trend: 'negative', total: 1, - startTime: new Date(this.model.published_at), + startTime: publishedAt, endTime: new Date() })[0]; const recipientCount = this.membersSubscribeEvents .filter(entry => entry.newsletter_id === newsletter.id) - .filter(entry => new Date(entry.created_at) < timestamp).length; + .filter(entry => dateToDatabaseString.parse(entry.created_at) < timestamp).length; const deliveredCount = Math.ceil(recipientCount * faker.datatype.float({ max: 1, min: 0.9, diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index cc29c3d4fff..e96ff922538 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -50,7 +50,7 @@ class MembersClickEventsImporter extends TableImporter { } this.amount -= 1; - const openedAt = new Date(this.model.opened_at); + const openedAt = dateToDatabaseString.parse(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); const clickTime = faker.date.between(openedAt.getTime(), laterOn.getTime()); //added getTime here because it threw random errors diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index 5eb3b503c26..d6980e1e04a 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -47,7 +47,8 @@ class MembersCreatedEventsImporter extends TableImporter { }; if (source === 'member' && luck(30)) { - const post = this.posts.find(p => p.visibility === 'public' && new Date(p.published_at) < new Date(this.model.created_at)); + const memberCreatedAt = dateToDatabaseString.parse(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && dateToDatabaseString.parse(p.published_at) < memberCreatedAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index 2a75ab2e129..82f0f86f22c 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,5 +1,4 @@ const TableImporter = require('./table-importer'); -const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); const dateToDatabaseString = require('../utils/database-date'); @@ -25,10 +24,10 @@ class MembersFeedbackImporter extends TableImporter { return null; } - const openedAt = new Date(this.model.opened_at); - const laterOn = new Date(this.model.opened_at); + const openedAt = dateToDatabaseString.parse(this.model.opened_at); + const laterOn = dateToDatabaseString.parse(this.model.opened_at); laterOn.setMinutes(laterOn.getMinutes() + 60); - const feedbackTime = faker.date.between(openedAt, laterOn); + const feedbackTime = dateToDatabaseString.randomBetween(openedAt, laterOn); const postId = this.emails.find(email => email.id === this.model.email_id).post_id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js index 7882761465b..ea3f0d888b7 100644 --- a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js @@ -35,9 +35,10 @@ class MembersLoginEventsImporter extends TableImporter { setReferencedModel(model) { this.model = model; + const memberCreatedAt = dateToDatabaseString.parse(model.created_at); const endDate = new Date(); - const daysBetween = Math.ceil((endDate.valueOf() - new Date(model.created_at).valueOf()) / (1000 * 60 * 60 * 24)); + const daysBetween = Math.ceil((endDate.valueOf() - memberCreatedAt.valueOf()) / (1000 * 60 * 60 * 24)); // Assuming most people either subscribe and lose interest, or maintain steady readership const shape = luck(40) ? 'ease-out' : 'flat'; @@ -47,7 +48,7 @@ class MembersLoginEventsImporter extends TableImporter { // Steady readers login more, readers who lose interest read less overall. // ceil because members will all have logged in at least once total: Math.min(5, shape === 'flat' ? Math.ceil(daysBetween / 3) : Math.ceil(daysBetween / 7)), - startTime: new Date(model.created_at), + startTime: memberCreatedAt, endTime: endDate }); } diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index 4b0aa3c8506..58eeccee29e 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,4 @@ const TableImporter = require('./table-importer'); -const {faker} = require('@faker-js/faker'); const dateToDatabaseString = require('../utils/database-date'); class MembersStatusEventsImporter extends TableImporter { @@ -41,7 +40,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: dateToDatabaseString(faker.date.between(new Date(model.created_at), new Date())) + created_at: dateToDatabaseString(dateToDatabaseString.randomBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js index 66671083f17..21e048c1376 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js @@ -1,5 +1,6 @@ const {faker} = require('@faker-js/faker'); const TableImporter = require('./table-importer'); +const dateToDatabaseString = require('../utils/database-date'); class MembersStripeCustomersImporter extends TableImporter { static table = 'members_stripe_customers'; @@ -35,7 +36,7 @@ class MembersStripeCustomersImporter extends TableImporter { // Only 30% of free members should have a stripe customer = have had a subscription in the past or tried to subscribe // The number should increase the older the member is - const daysSinceMemberCreated = Math.floor((new Date() - new Date(this.model.created_at)) / (1000 * 60 * 60 * 24)); + const daysSinceMemberCreated = Math.floor((new Date() - dateToDatabaseString.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); const shouldHaveStripeCustomer = faker.datatype.number({min: 0, max: 100}) < Math.max(Math.min(daysSinceMemberCreated / 60, 15), 2); if (!shouldHaveStripeCustomer) { diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js index 78034d97b2c..eb4945adcce 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js @@ -30,6 +30,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { this.members = await this.transaction.select('id', 'status', 'created_at').from('members').whereIn('id', membersStripeCustomers.map(m => m.member_id)); if (this.members.length === 0) { + offset += limit; continue; } @@ -95,6 +96,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { (isMonthly ? price.interval === 'month' : price.interval === 'year'); }); const mrr = createValid ? (isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12)) : 0; + const memberCreatedAt = dateToDatabaseString.parse(member.created_at); const referenceEndDate = this.lastSubscriptionStart ?? new Date(); @@ -106,7 +108,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { } } - if (referenceEndDate < member.created_at) { + if (referenceEndDate < memberCreatedAt) { // Not possible to create an invalid subscription here return; } @@ -114,7 +116,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { const [startDate] = generateEvents({ total: 1, trend: 'negative', - startTime: new Date(member.created_at), + startTime: memberCreatedAt, endTime: referenceEndDate, shape: 'ease-out' }); diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index a6e8c4edbb0..5c8c3a93ea3 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,5 +1,4 @@ const TableImporter = require('./table-importer'); -const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); const dateToDatabaseString = require('../utils/database-date'); @@ -52,7 +51,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date())); + const createdAt = dateToDatabaseString(dateToDatabaseString.randomBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js index 2d3d8126de4..32e9cacfee8 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js @@ -1,6 +1,7 @@ const TableImporter = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); +const dateToDatabaseString = require('../utils/database-date'); class MembersSubscriptionCreatedEventsImporter extends TableImporter { static table = 'members_subscription_created_events'; @@ -48,7 +49,8 @@ class MembersSubscriptionCreatedEventsImporter extends TableImporter { }; if (luck(30)) { - const post = this.posts.find(p => p.visibility === 'public' && new Date(p.published_at) < new Date(this.model.created_at)); + const createdAt = dateToDatabaseString.parse(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && dateToDatabaseString.parse(p.published_at) < createdAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index 83dc30e955a..d7939e0b2f1 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -53,7 +53,7 @@ class OfferRedemptionsImporter extends TableImporter { this.subscriptionPool.push({ memberId, subscriptionId: subscription.id, - subscriptionCreatedAt: new Date(subscription.created_at), + subscriptionCreatedAt: dateToDatabaseString.parse(subscription.created_at), redemptionEndAt: this.getRedemptionEndDate(subscription.current_period_end), availableOffers: [...matchingOffers], lastRedeemedAt: null @@ -78,7 +78,7 @@ class OfferRedemptionsImporter extends TableImporter { getRedemptionEndDate(currentPeriodEnd) { const now = new Date(); - const endDate = currentPeriodEnd ? new Date(currentPeriodEnd) : now; + const endDate = currentPeriodEnd ? dateToDatabaseString.parse(currentPeriodEnd) : now; return endDate > now ? now : endDate; } @@ -86,7 +86,7 @@ class OfferRedemptionsImporter extends TableImporter { getCreatedAt(subscriptionState, offer) { const candidateEarliest = new Date(Math.max( subscriptionState.subscriptionCreatedAt.valueOf(), - new Date(offer.created_at).valueOf(), + dateToDatabaseString.parse(offer.created_at).valueOf(), subscriptionState.lastRedeemedAt ? subscriptionState.lastRedeemedAt.valueOf() + 1000 : 0 )); const earliest = new Date(Math.min( diff --git a/ghost/core/core/server/data/seeders/utils/database-date.js b/ghost/core/core/server/data/seeders/utils/database-date.js index 01da9ee611d..1a48d79aed5 100644 --- a/ghost/core/core/server/data/seeders/utils/database-date.js +++ b/ghost/core/core/server/data/seeders/utils/database-date.js @@ -1,7 +1,32 @@ -module.exports = function dateToDatabaseString(date) { +const {faker} = require('@faker-js/faker'); + +const databaseDatePattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/; + +function dateToDatabaseString(date) { if (typeof date === 'string') { // SQLite fix when reusing other dates from the db return date; } return date.toISOString().replace('Z','').replace('T', ' '); +} + +dateToDatabaseString.parse = function parseDatabaseDate(date) { + if (date instanceof Date) { + return new Date(date); + } + + if (typeof date === 'string' && databaseDatePattern.test(date)) { + return new Date(date.replace(' ', 'T') + 'Z'); + } + + return new Date(date); +}; + +dateToDatabaseString.randomBetween = function randomBetween(start, end) { + const earliest = dateToDatabaseString.parse(start); + const latest = dateToDatabaseString.parse(end); + + return latest > earliest ? faker.date.between(earliest, latest) : earliest; }; + +module.exports = dateToDatabaseString; diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 1dcc9696ae4..7c853863e9a 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -1,4 +1,5 @@ const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); const knex = require('knex').default; @@ -9,6 +10,7 @@ const StripeProductsImporter = importers.find(i => i.table === 'stripe_products' const StripePricesImporter = importers.find(i => i.table === 'stripe_prices'); const generateEvents = require('../../../../../core/server/data/seeders/utils/event-generator'); +const dateToDatabaseString = require('../../../../../core/server/data/seeders/utils/database-date'); const DataGenerator = require('../../../../../core/server/data/seeders/data-generator'); @@ -253,6 +255,30 @@ describe('Importer', function () { }); describe('Events Generator', function () { + it('Parses database timestamps as UTC in non-UTC timezones', function () { + const script = ` + const dateToDatabaseString = require(${JSON.stringify(require.resolve('../../../../../core/server/data/seeders/utils/database-date'))}); + process.stdout.write(dateToDatabaseString.parse('2026-03-26 11:50:00.000').toISOString()); + `; + const result = spawnSync(process.execPath, ['-e', script], { + env: { + ...process.env, + TZ: 'America/New_York' + }, + encoding: 'utf8' + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, '2026-03-26T11:50:00.000Z'); + }); + + it('Returns the start date when a range is inverted', function () { + const startDate = new Date('2026-03-26T11:50:00.000Z'); + const endDate = new Date('2026-03-26T10:00:00.000Z'); + + assert.equal(dateToDatabaseString.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); + }); + it('Generates a set of timestamps which meet the criteria', function () { const startTime = new Date(); startTime.setDate(startTime.getDate() - 30);