From 3c9e76b83749d4e1af1ed1281c5da6ec9be190b4 Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Tue, 2 Jun 2026 11:01:31 -0400 Subject: [PATCH 1/4] Deduplicated revisions when editing automations --- .../fake-database-automations-repository.ts | 101 ++++++- ghost/core/package.json | 1 + ...ke-database-automations-repository.test.ts | 265 ++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts diff --git a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts index d4b2e7dc996..3f5b2d1ca89 100644 --- a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts @@ -1,6 +1,7 @@ import errors from '@tryghost/errors'; import tpl from '@tryghost/tpl'; import ObjectId from 'bson-objectid'; +import {dequal} from 'dequal'; import type {DatabaseSync} from 'node:sqlite'; import type { Automation, @@ -39,11 +40,31 @@ interface ActionRow { email_design_setting_id: string | null; } +type ActionRevisionRow = { + action_id: string; + created_at: string; + wait_hours: number | null; + email_subject: string | null; + email_lexical: string | null; + email_sender_name: string | null; + email_sender_email: string | null; + email_sender_reply_to: string | null; + email_design_setting_id: string | null; +}; + interface EdgeRow { source_action_id: string; target_action_id: string; } +type WaitActionData = Extract['data']; +type SendEmailActionData = Extract['data']; +type RevisionDataFor = { + [FieldT in keyof ActionDataT]: ActionRevisionRow[FieldT & keyof ActionRevisionRow]; +}; +type WaitRevisionData = RevisionDataFor; +type SendEmailRevisionData = RevisionDataFor; + export function createFakeDatabaseAutomationsRepository({ getDatabase }: { @@ -185,8 +206,10 @@ function replaceAutomationGraph(database: DatabaseSync, automationId: string, ac }); } - // TODO (NY-1283): Deduplicate revisions before inserting them. - insertActionRevision(database, action.id, action, now); + const latestRevision = loadLatestActionRevision(database, action.id); + if (shouldInsertActionRevision(action, latestRevision)) { + insertActionRevision(database, action.id, action, now, latestRevision); + } } for (const existingAction of existingActions) { @@ -235,6 +258,64 @@ function insertAction(database: DatabaseSync, action: { `).run(action); } +function shouldInsertActionRevision(action: AutomationAction, latestRevision: ActionRevisionRow | null): boolean { + if (!latestRevision) { + return true; + } + + return !dequal(buildRevisionActionData(action, latestRevision), action.data); +} + +function buildRevisionActionData(action: AutomationAction, revision: ActionRevisionRow): WaitRevisionData | SendEmailRevisionData { + switch (action.type) { + case 'wait': { + return { + wait_hours: revision.wait_hours + }; + } + case 'send_email': { + return { + email_subject: revision.email_subject, + email_lexical: revision.email_lexical, + email_sender_name: revision.email_sender_name, + email_sender_email: revision.email_sender_email, + email_sender_reply_to: revision.email_sender_reply_to, + email_design_setting_id: revision.email_design_setting_id + }; + } + default: { + const _exhaustive: never = action; + throw new errors.InternalServerError({ + message: `Unhandled action type: ${_exhaustive}` + }); + } + } +} + +function loadLatestActionRevision(database: DatabaseSync, actionId: string): ActionRevisionRow | null { + const row = database.prepare(` + SELECT + action_id, + created_at, + wait_hours, + email_subject, + email_lexical, + email_sender_name, + email_sender_email, + email_sender_reply_to, + email_design_setting_id + FROM automation_action_revisions + WHERE action_id = ? + AND created_at = ( + SELECT MAX(created_at) + FROM automation_action_revisions + WHERE action_id = ? + ) + `).get(actionId, actionId) as ActionRevisionRow | undefined; + + return row ?? null; +} + function softDeleteAction(database: DatabaseSync, actionId: string, deletedAt: string) { database.prepare(` UPDATE automation_actions @@ -248,8 +329,8 @@ function softDeleteAction(database: DatabaseSync, actionId: string, deletedAt: s }); } -function insertActionRevision(database: DatabaseSync, actionId: string, action: AutomationAction, createdAt: string) { - const revision = buildActionRevision(actionId, action, getNextRevisionCreatedAt(database, actionId, createdAt)); +function insertActionRevision(database: DatabaseSync, actionId: string, action: AutomationAction, createdAt: string, latestRevision: ActionRevisionRow | null) { + const revision = buildActionRevision(actionId, action, getNextRevisionCreatedAt(latestRevision?.created_at ?? null, createdAt)); database.prepare(` INSERT INTO automation_action_revisions @@ -279,19 +360,13 @@ function insertActionRevision(database: DatabaseSync, actionId: string, action: `).run(revision); } -function getNextRevisionCreatedAt(database: DatabaseSync, actionId: string, requestedCreatedAt: string) { - const row = database.prepare(` - SELECT MAX(created_at) AS created_at - FROM automation_action_revisions - WHERE action_id = ? - `).get(actionId) as {created_at: string | null} | undefined; - - if (!row?.created_at) { +function getNextRevisionCreatedAt(latestCreatedAt: string | null, requestedCreatedAt: string) { + if (!latestCreatedAt) { return requestedCreatedAt; } const requestedTime = new Date(requestedCreatedAt).getTime(); - const latestTime = new Date(row.created_at).getTime(); + const latestTime = new Date(latestCreatedAt).getTime(); if (requestedTime > latestTime) { return requestedCreatedAt; diff --git a/ghost/core/package.json b/ghost/core/package.json index 610dcb193a2..c175a675ada 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -160,6 +160,7 @@ "csso": "5.0.5", "csv-writer": "1.6.0", "date-fns": "2.30.0", + "dequal": "catalog:", "dompurify": "catalog:", "downsize": "0.0.8", "entities": "4.5.0", diff --git a/ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts new file mode 100644 index 00000000000..434464459e9 --- /dev/null +++ b/ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import {DatabaseSync} from 'node:sqlite'; +import ObjectId from 'bson-objectid'; +import {createFakeDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/fake-database-automations-repository'; +import type {Automation, AutomationAction} from '../../../../../core/server/services/automations/automations-repository'; + +const now = '2026-01-01T00:00:00.000Z'; +const emailLexical = JSON.stringify({ + root: { + children: [], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } +}); + +function id() { + return ObjectId().toString(); +} + +function insertRevision(database: DatabaseSync, revision: { + action_id: string; + wait_hours: number | null; + email_subject: string | null; + email_lexical: string | null; + email_sender_name: string | null; + email_sender_email: string | null; + email_sender_reply_to: string | null; + email_design_setting_id: string | null; +}) { + database.prepare(` + INSERT INTO automation_action_revisions + ( + id, + created_at, + action_id, + wait_hours, + email_subject, + email_lexical, + email_sender_name, + email_sender_email, + email_sender_reply_to, + email_design_setting_id + ) VALUES ( + :id, + :created_at, + :action_id, + :wait_hours, + :email_subject, + :email_lexical, + :email_sender_name, + :email_sender_email, + :email_sender_reply_to, + :email_design_setting_id + ) + `).run({ + id: id(), + created_at: now, + ...revision + }); +} + +function createDatabase() { + const database = new DatabaseSync(':memory:'); + const automationId = id(); + const waitActionId = id(); + const emailActionId = id(); + + database.exec(` +CREATE TABLE automations ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL +) STRICT; + +CREATE TABLE automation_actions ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + automation_id TEXT NOT NULL REFERENCES automations(id), + type TEXT NOT NULL +) STRICT; + +CREATE TABLE automation_action_revisions ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + action_id TEXT NOT NULL REFERENCES automation_actions(id), + wait_hours INTEGER, + email_subject TEXT, + email_lexical TEXT, + email_sender_name TEXT, + email_sender_email TEXT, + email_sender_reply_to TEXT, + email_design_setting_id TEXT, + UNIQUE (created_at, action_id) +) STRICT; + +CREATE TABLE automation_action_edges ( + source_action_id TEXT NOT NULL REFERENCES automation_actions(id), + target_action_id TEXT NOT NULL REFERENCES automation_actions(id), + PRIMARY KEY (source_action_id, target_action_id) +) STRICT; +`); + + database.prepare(` + INSERT INTO automations + (id, created_at, updated_at, slug, name, status) VALUES + (:id, :created_at, :updated_at, :slug, :name, :status) + `).run({ + id: automationId, + created_at: now, + updated_at: now, + slug: 'test-automation', + name: 'Test Automation', + status: 'active' + }); + + database.prepare(` + INSERT INTO automation_actions + (id, created_at, updated_at, automation_id, type) VALUES + (:id, :created_at, :updated_at, :automation_id, :type) + `).run({ + id: waitActionId, + created_at: now, + updated_at: now, + automation_id: automationId, + type: 'wait' + }); + + database.prepare(` + INSERT INTO automation_actions + (id, created_at, updated_at, automation_id, type) VALUES + (:id, :created_at, :updated_at, :automation_id, :type) + `).run({ + id: emailActionId, + created_at: now, + updated_at: now, + automation_id: automationId, + type: 'send_email' + }); + + insertRevision(database, { + action_id: waitActionId, + wait_hours: 24, + email_subject: null, + email_lexical: null, + email_sender_name: null, + email_sender_email: null, + email_sender_reply_to: null, + email_design_setting_id: null + }); + + insertRevision(database, { + action_id: emailActionId, + wait_hours: null, + email_subject: 'Welcome', + email_lexical: emailLexical, + email_sender_name: 'Sender', + email_sender_email: null, + email_sender_reply_to: 'reply@example.com', + email_design_setting_id: id() + }); + + database.prepare(` + INSERT INTO automation_action_edges + (source_action_id, target_action_id) VALUES + (:source_action_id, :target_action_id) + `).run({ + source_action_id: waitActionId, + target_action_id: emailActionId + }); + + return {database, automationId, waitActionId, emailActionId}; +} + +function countRevisions(database: DatabaseSync, actionId?: string) { + const row = actionId + ? database.prepare('SELECT COUNT(*) AS count FROM automation_action_revisions WHERE action_id = ?').get(actionId) + : database.prepare('SELECT COUNT(*) AS count FROM automation_action_revisions').get(); + + return Number((row as {count: number}).count); +} + +function requireAutomation(automation: Automation | null): Automation { + assert.ok(automation); + return automation; +} + +function changeWaitHours(action: AutomationAction, waitHours: number): AutomationAction { + assert.equal(action.type, 'wait'); + return { + ...action, + data: { + wait_hours: waitHours + } + }; +} + +describe('FakeDatabaseAutomationsRepository', function () { + it('only inserts action revisions when action data changes', async function () { + const {database, automationId, waitActionId, emailActionId} = createDatabase(); + const repository = createFakeDatabaseAutomationsRepository({ + getDatabase: () => database + }); + + const initialAutomation = requireAutomation(await repository.getById(automationId)); + assert.equal(countRevisions(database), 2); + + await repository.edit(automationId, { + status: 'inactive', + actions: initialAutomation.actions, + edges: initialAutomation.edges + }); + + assert.equal(countRevisions(database), 2); + assert.equal(countRevisions(database, waitActionId), 1); + assert.equal(countRevisions(database, emailActionId), 1); + + const unchangedEmailAction = initialAutomation.actions.find(action => action.id === emailActionId); + const changedWaitAction = changeWaitHours(initialAutomation.actions.find(action => action.id === waitActionId) as AutomationAction, 48); + assert.ok(unchangedEmailAction); + + await repository.edit(automationId, { + status: 'inactive', + actions: [changedWaitAction, unchangedEmailAction], + edges: initialAutomation.edges + }); + + assert.equal(countRevisions(database), 3); + assert.equal(countRevisions(database, waitActionId), 2); + assert.equal(countRevisions(database, emailActionId), 1); + + const addedActionId = id(); + const addedAction: AutomationAction = { + id: addedActionId, + type: 'wait', + data: { + wait_hours: 72 + } + }; + + await repository.edit(automationId, { + status: 'inactive', + actions: [changedWaitAction, unchangedEmailAction, addedAction], + edges: [ + ...initialAutomation.edges, + { + source_action_id: emailActionId, + target_action_id: addedActionId + } + ] + }); + + assert.equal(countRevisions(database), 4); + assert.equal(countRevisions(database, waitActionId), 2); + assert.equal(countRevisions(database, emailActionId), 1); + assert.equal(countRevisions(database, addedActionId), 1); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0679c3f2cc..6ad5ced0af6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2510,6 +2510,9 @@ importers: date-fns: specifier: 2.30.0 version: 2.30.0 + dequal: + specifier: 'catalog:' + version: 2.0.3 dompurify: specifier: 'catalog:' version: 3.4.5 From 9e83bc0a0cb41b1ccfe25354fa11d7b0fb64d068 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 3 Jun 2026 09:05:56 -0500 Subject: [PATCH 2/4] Minor: avoid creating unnecessary scopes --- .../automations/fake-database-automations-repository.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts index 3f5b2d1ca89..11429a96f06 100644 --- a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts @@ -268,12 +268,11 @@ function shouldInsertActionRevision(action: AutomationAction, latestRevision: Ac function buildRevisionActionData(action: AutomationAction, revision: ActionRevisionRow): WaitRevisionData | SendEmailRevisionData { switch (action.type) { - case 'wait': { + case 'wait': return { wait_hours: revision.wait_hours }; - } - case 'send_email': { + case 'send_email': return { email_subject: revision.email_subject, email_lexical: revision.email_lexical, @@ -282,7 +281,6 @@ function buildRevisionActionData(action: AutomationAction, revision: ActionRevis email_sender_reply_to: revision.email_sender_reply_to, email_design_setting_id: revision.email_design_setting_id }; - } default: { const _exhaustive: never = action; throw new errors.InternalServerError({ From e974b160c65695245d3812240584128a7667dbd6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 3 Jun 2026 09:14:51 -0500 Subject: [PATCH 3/4] Don't let me return both wait data and send_email data --- .../automations/fake-database-automations-repository.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts index 11429a96f06..70e78134cb8 100644 --- a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts @@ -12,6 +12,7 @@ import type { EditAutomationData, Page } from './automations-repository'; +import type {ExclusifyUnion} from 'type-fest'; const messages = { invalidAutomationActionRevision: 'Automation action "{actionId}" of type "{actionType}" is missing required revision field "{field}".', @@ -266,7 +267,7 @@ function shouldInsertActionRevision(action: AutomationAction, latestRevision: Ac return !dequal(buildRevisionActionData(action, latestRevision), action.data); } -function buildRevisionActionData(action: AutomationAction, revision: ActionRevisionRow): WaitRevisionData | SendEmailRevisionData { +function buildRevisionActionData(action: AutomationAction, revision: ActionRevisionRow): ExclusifyUnion { switch (action.type) { case 'wait': return { From 093a19efe700ad6d861c59b1beacdd3e08bb3643 Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Wed, 3 Jun 2026 14:15:35 -0400 Subject: [PATCH 4/4] mv test --- .../automations-repository.test.ts | 89 +++++- ...ke-database-automations-repository.test.ts | 265 ------------------ 2 files changed, 88 insertions(+), 266 deletions(-) delete mode 100644 ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index 1c2e5312398..1501224f371 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; -import {AutomationsRepository} from '../../../../../core/server/services/automations/automations-repository'; +import ObjectId from 'bson-objectid'; import {createTemporaryFakeAutomationsDatabase} from '../../../../../core/server/services/automations/temporary-fake-database'; import {createFakeDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/fake-database-automations-repository'; +import type {AutomationAction, AutomationsRepository} from '../../../../../core/server/services/automations/automations-repository'; import type {DatabaseSync, SQLInputValue} from 'node:sqlite'; const addHours = (dateCol: unknown, hours: number): Date => { @@ -61,6 +62,24 @@ describe('automations repository', function () { return result?.count; }; + const getRevisionCount = (actionId?: string) => { + const row = actionId + ? database.prepare('SELECT COUNT(*) AS count FROM automation_action_revisions WHERE action_id = ?').get(actionId) + : database.prepare('SELECT COUNT(*) AS count FROM automation_action_revisions').get(); + + return Number((row as {count: number}).count); + }; + + const changeWaitHours = (action: AutomationAction, waitHours: number): AutomationAction => { + assert.equal(action.type, 'wait'); + return { + ...action, + data: { + wait_hours: waitHours + } + }; + }; + beforeEach(function () { database = createTemporaryFakeAutomationsDatabase(); repo = createFakeDatabaseAutomationsRepository({ @@ -202,4 +221,72 @@ describe('automations repository', function () { assert.equal(getRunCountByAutomationId(freeAutomation.id), 0); }); }); + + describe('edit', function () { + it('only inserts action revisions when action data changes', async function () { + const initialAutomation = await getAutomationBySlug('member-welcome-email-free'); + const initialRevisionCount = getRevisionCount(); + const waitAction = initialAutomation.actions.find(action => action.type === 'wait'); + const unchangedEmailAction = initialAutomation.actions.find(action => action.type === 'send_email'); + + assert(waitAction); + assert(unchangedEmailAction); + assert.equal(getRevisionCount(waitAction.id), 1); + assert.equal(getRevisionCount(unchangedEmailAction.id), 1); + + await repo.edit(initialAutomation.id, { + status: 'inactive', + actions: initialAutomation.actions, + edges: initialAutomation.edges + }); + + assert.equal(getRevisionCount(), initialRevisionCount); + assert.equal(getRevisionCount(waitAction.id), 1); + assert.equal(getRevisionCount(unchangedEmailAction.id), 1); + + const changedWaitAction = changeWaitHours(waitAction, waitAction.data.wait_hours + 24); + + await repo.edit(initialAutomation.id, { + status: 'inactive', + actions: [changedWaitAction, unchangedEmailAction], + edges: [{ + source_action_id: changedWaitAction.id, + target_action_id: unchangedEmailAction.id + }] + }); + + assert.equal(getRevisionCount(), initialRevisionCount + 1); + assert.equal(getRevisionCount(waitAction.id), 2); + assert.equal(getRevisionCount(unchangedEmailAction.id), 1); + + const addedActionId = ObjectId().toString(); + const addedAction: AutomationAction = { + id: addedActionId, + type: 'wait', + data: { + wait_hours: 72 + } + }; + + await repo.edit(initialAutomation.id, { + status: 'inactive', + actions: [changedWaitAction, unchangedEmailAction, addedAction], + edges: [ + { + source_action_id: changedWaitAction.id, + target_action_id: unchangedEmailAction.id + }, + { + source_action_id: unchangedEmailAction.id, + target_action_id: addedActionId + } + ] + }); + + assert.equal(getRevisionCount(), initialRevisionCount + 2); + assert.equal(getRevisionCount(waitAction.id), 2); + assert.equal(getRevisionCount(unchangedEmailAction.id), 1); + assert.equal(getRevisionCount(addedActionId), 1); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts deleted file mode 100644 index 434464459e9..00000000000 --- a/ghost/core/test/unit/server/services/automations/fake-database-automations-repository.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import assert from 'node:assert/strict'; -import {DatabaseSync} from 'node:sqlite'; -import ObjectId from 'bson-objectid'; -import {createFakeDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/fake-database-automations-repository'; -import type {Automation, AutomationAction} from '../../../../../core/server/services/automations/automations-repository'; - -const now = '2026-01-01T00:00:00.000Z'; -const emailLexical = JSON.stringify({ - root: { - children: [], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } -}); - -function id() { - return ObjectId().toString(); -} - -function insertRevision(database: DatabaseSync, revision: { - action_id: string; - wait_hours: number | null; - email_subject: string | null; - email_lexical: string | null; - email_sender_name: string | null; - email_sender_email: string | null; - email_sender_reply_to: string | null; - email_design_setting_id: string | null; -}) { - database.prepare(` - INSERT INTO automation_action_revisions - ( - id, - created_at, - action_id, - wait_hours, - email_subject, - email_lexical, - email_sender_name, - email_sender_email, - email_sender_reply_to, - email_design_setting_id - ) VALUES ( - :id, - :created_at, - :action_id, - :wait_hours, - :email_subject, - :email_lexical, - :email_sender_name, - :email_sender_email, - :email_sender_reply_to, - :email_design_setting_id - ) - `).run({ - id: id(), - created_at: now, - ...revision - }); -} - -function createDatabase() { - const database = new DatabaseSync(':memory:'); - const automationId = id(); - const waitActionId = id(); - const emailActionId = id(); - - database.exec(` -CREATE TABLE automations ( - id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - slug TEXT NOT NULL, - name TEXT NOT NULL, - status TEXT NOT NULL -) STRICT; - -CREATE TABLE automation_actions ( - id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - automation_id TEXT NOT NULL REFERENCES automations(id), - type TEXT NOT NULL -) STRICT; - -CREATE TABLE automation_action_revisions ( - id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - action_id TEXT NOT NULL REFERENCES automation_actions(id), - wait_hours INTEGER, - email_subject TEXT, - email_lexical TEXT, - email_sender_name TEXT, - email_sender_email TEXT, - email_sender_reply_to TEXT, - email_design_setting_id TEXT, - UNIQUE (created_at, action_id) -) STRICT; - -CREATE TABLE automation_action_edges ( - source_action_id TEXT NOT NULL REFERENCES automation_actions(id), - target_action_id TEXT NOT NULL REFERENCES automation_actions(id), - PRIMARY KEY (source_action_id, target_action_id) -) STRICT; -`); - - database.prepare(` - INSERT INTO automations - (id, created_at, updated_at, slug, name, status) VALUES - (:id, :created_at, :updated_at, :slug, :name, :status) - `).run({ - id: automationId, - created_at: now, - updated_at: now, - slug: 'test-automation', - name: 'Test Automation', - status: 'active' - }); - - database.prepare(` - INSERT INTO automation_actions - (id, created_at, updated_at, automation_id, type) VALUES - (:id, :created_at, :updated_at, :automation_id, :type) - `).run({ - id: waitActionId, - created_at: now, - updated_at: now, - automation_id: automationId, - type: 'wait' - }); - - database.prepare(` - INSERT INTO automation_actions - (id, created_at, updated_at, automation_id, type) VALUES - (:id, :created_at, :updated_at, :automation_id, :type) - `).run({ - id: emailActionId, - created_at: now, - updated_at: now, - automation_id: automationId, - type: 'send_email' - }); - - insertRevision(database, { - action_id: waitActionId, - wait_hours: 24, - email_subject: null, - email_lexical: null, - email_sender_name: null, - email_sender_email: null, - email_sender_reply_to: null, - email_design_setting_id: null - }); - - insertRevision(database, { - action_id: emailActionId, - wait_hours: null, - email_subject: 'Welcome', - email_lexical: emailLexical, - email_sender_name: 'Sender', - email_sender_email: null, - email_sender_reply_to: 'reply@example.com', - email_design_setting_id: id() - }); - - database.prepare(` - INSERT INTO automation_action_edges - (source_action_id, target_action_id) VALUES - (:source_action_id, :target_action_id) - `).run({ - source_action_id: waitActionId, - target_action_id: emailActionId - }); - - return {database, automationId, waitActionId, emailActionId}; -} - -function countRevisions(database: DatabaseSync, actionId?: string) { - const row = actionId - ? database.prepare('SELECT COUNT(*) AS count FROM automation_action_revisions WHERE action_id = ?').get(actionId) - : database.prepare('SELECT COUNT(*) AS count FROM automation_action_revisions').get(); - - return Number((row as {count: number}).count); -} - -function requireAutomation(automation: Automation | null): Automation { - assert.ok(automation); - return automation; -} - -function changeWaitHours(action: AutomationAction, waitHours: number): AutomationAction { - assert.equal(action.type, 'wait'); - return { - ...action, - data: { - wait_hours: waitHours - } - }; -} - -describe('FakeDatabaseAutomationsRepository', function () { - it('only inserts action revisions when action data changes', async function () { - const {database, automationId, waitActionId, emailActionId} = createDatabase(); - const repository = createFakeDatabaseAutomationsRepository({ - getDatabase: () => database - }); - - const initialAutomation = requireAutomation(await repository.getById(automationId)); - assert.equal(countRevisions(database), 2); - - await repository.edit(automationId, { - status: 'inactive', - actions: initialAutomation.actions, - edges: initialAutomation.edges - }); - - assert.equal(countRevisions(database), 2); - assert.equal(countRevisions(database, waitActionId), 1); - assert.equal(countRevisions(database, emailActionId), 1); - - const unchangedEmailAction = initialAutomation.actions.find(action => action.id === emailActionId); - const changedWaitAction = changeWaitHours(initialAutomation.actions.find(action => action.id === waitActionId) as AutomationAction, 48); - assert.ok(unchangedEmailAction); - - await repository.edit(automationId, { - status: 'inactive', - actions: [changedWaitAction, unchangedEmailAction], - edges: initialAutomation.edges - }); - - assert.equal(countRevisions(database), 3); - assert.equal(countRevisions(database, waitActionId), 2); - assert.equal(countRevisions(database, emailActionId), 1); - - const addedActionId = id(); - const addedAction: AutomationAction = { - id: addedActionId, - type: 'wait', - data: { - wait_hours: 72 - } - }; - - await repository.edit(automationId, { - status: 'inactive', - actions: [changedWaitAction, unchangedEmailAction, addedAction], - edges: [ - ...initialAutomation.edges, - { - source_action_id: emailActionId, - target_action_id: addedActionId - } - ] - }); - - assert.equal(countRevisions(database), 4); - assert.equal(countRevisions(database, waitActionId), 2); - assert.equal(countRevisions(database, emailActionId), 1); - assert.equal(countRevisions(database, addedActionId), 1); - }); -});