From eb7dac2737924ad852515a027cbe3b008dffa644 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Mon, 4 May 2026 13:56:59 +0300 Subject: [PATCH 01/10] feat(data-access): add scopeType and scopeId to Opportunity model Exposes the existing scope_type / scope_id DB columns via the JS model layer so offsite audits can tag opportunities with a brand scope. Co-Authored-By: Claude Sonnet 4.6 --- .../models/opportunity/opportunity.model.js | 5 +++ .../models/opportunity/opportunity.schema.js | 6 +++ .../opportunity/opportunity.model.test.js | 39 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js index 1fc1af718..cf46f0460 100755 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js @@ -37,6 +37,11 @@ class Opportunity extends BaseModel { RESOLVED: 'RESOLVED', }; + static SCOPE_TYPES = { + SITE: 'site', + BRAND: 'brand', + }; + /** * Adds the given suggestions to this Opportunity. Sets this opportunity as the parent * of each suggestion, as such the opportunity ID does not need to be provided. diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js index 04241ff19..83990d2be 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js @@ -64,6 +64,12 @@ const schema = new SchemaBuilder(Opportunity, OpportunityCollection) .addAttribute('lastAuditedAt', { type: 'string', validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('scopeType', { + type: Object.values(Opportunity.SCOPE_TYPES), + }) + .addAttribute('scopeId', { + type: 'string', }); export default schema.build(); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js index 2db66a24a..a557531a0 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js @@ -384,4 +384,43 @@ describe('OpportunityModel', () => { expect(instance.record.data).to.deep.equal({ newInfo: 'updatedInfo' }); }); }); + + describe('getScopeType and setScopeType', () => { + it('returns undefined when scopeType is not set', () => { + expect(instance.getScopeType()).to.be.undefined; + }); + + it('returns the scopeType value', () => { + mockRecord.scopeType = 'brand'; + expect(instance.getScopeType()).to.equal('brand'); + }); + + it('sets the scopeType value', () => { + instance.setScopeType('brand'); + expect(instance.record.scopeType).to.equal('brand'); + }); + }); + + describe('getScopeId and setScopeId', () => { + it('returns undefined when scopeId is not set', () => { + expect(instance.getScopeId()).to.be.undefined; + }); + + it('returns the scopeId value', () => { + mockRecord.scopeId = 'brand-uuid-123'; + expect(instance.getScopeId()).to.equal('brand-uuid-123'); + }); + + it('sets the scopeId value', () => { + instance.setScopeId('brand-uuid-456'); + expect(instance.record.scopeId).to.equal('brand-uuid-456'); + }); + }); + + describe('SCOPE_TYPES', () => { + it('defines expected scope type constants', () => { + expect(Opportunity.SCOPE_TYPES.SITE).to.equal('site'); + expect(Opportunity.SCOPE_TYPES.BRAND).to.equal('brand'); + }); + }); }); From fa9dcea22fbadd0a118012eb80c557786c5efae3 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Mon, 4 May 2026 14:40:54 +0300 Subject: [PATCH 02/10] chore(data-access): bump mysticat-data-service IT image tag to v5.1.1 Co-Authored-By: Claude Sonnet 4.6 --- .../test/it/postgrest/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml index cafd7db71..777bf436b 100644 --- a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml +++ b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml @@ -17,7 +17,7 @@ services: data-service: platform: ${MYSTICAT_DATA_SERVICE_PLATFORM:-linux/amd64} - image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v1.67.8} + image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1} depends_on: db: condition: service_healthy From f9ddca7dcf6564b3cadc1ff3f1710c7f86ebcbbd Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Mon, 4 May 2026 15:28:05 +0300 Subject: [PATCH 03/10] feat(data-access): add allByScopeId to OpportunityCollection for brand-scoped queries Adds `allByScopeId(scopeType, scopeId)` to OpportunityCollection so brand-scoped offsite LLMO opportunities can be queried directly by scope rather than iterating all site opportunities and filtering in-app. Also updates TypeScript declarations for the new method and the scope getter/setter methods added to the Opportunity model. Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/opportunity/index.d.ts | 5 +++ .../opportunity/opportunity.collection.js | 21 +++++++++++- .../opportunity.collection.test.js | 32 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts b/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts index ad49445a0..03c6a2ec5 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts @@ -35,6 +35,10 @@ export interface Opportunity extends BaseModel { getTags(): string[]; getTitle(): string; getType(): string; + getScopeId(): string | null; + getScopeType(): string | null; + setScopeId(scopeId: string): Opportunity; + setScopeType(scopeType: string): Opportunity; setAuditId(auditId: string): Opportunity; setData(data: object): Opportunity; setDescription(description: string): Opportunity; @@ -49,6 +53,7 @@ export interface Opportunity extends BaseModel { } export interface OpportunityCollection extends BaseCollection { + allByScopeId(scopeType: string, scopeId: string): Promise; allByAuditId(auditId: string): Promise; allByAuditIdAndUpdatedAt(auditId: string, updatedAt: string): Promise; allBySiteId(siteId: string): Promise; diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js index 185c2aae1..49d130a64 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import { hasText } from '@adobe/spacecat-shared-utils'; + import BaseCollection from '../base/base.collection.js'; /** @@ -22,7 +24,24 @@ import BaseCollection from '../base/base.collection.js'; class OpportunityCollection extends BaseCollection { static COLLECTION_NAME = 'OpportunityCollection'; - // add custom methods here + /** + * Returns all opportunities with a given scope type and scope ID. + * Used to fetch brand-scoped opportunities directly by brandId + * without going through site association. + * + * @param {string} scopeType - The scope type (e.g. 'brand', 'site'). + * @param {string} scopeId - The scope entity UUID (e.g. brand UUID). + * @returns {Promise} The matching opportunities. + */ + async allByScopeId(scopeType, scopeId) { + if (!hasText(scopeType)) { + throw new Error('scopeType is required'); + } + if (!hasText(scopeId)) { + throw new Error('scopeId is required'); + } + return this.allByIndexKeys({ scopeType, scopeId }); + } } export default OpportunityCollection; diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index 7db3f1e65..5a0204c41 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -12,6 +12,7 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import { stub } from 'sinon'; import sinonChai from 'sinon-chai'; import Opportunity from '../../../../src/models/opportunity/opportunity.model.js'; @@ -70,4 +71,35 @@ describe('OpportunityCollection', () => { expect(model).to.be.an('object'); }); }); + + describe('allByScopeId', () => { + it('throws an error if scopeType is not provided', async () => { + await expect(instance.allByScopeId()).to.be.rejectedWith('scopeType is required'); + }); + + it('throws an error if scopeId is not provided', async () => { + await expect(instance.allByScopeId('brand')).to.be.rejectedWith('scopeId is required'); + }); + + it('delegates to allByIndexKeys with the correct arguments', async () => { + const mockOpportunity = { getOpportunityId: () => 'op-111' }; + instance.allByIndexKeys = stub().resolves([mockOpportunity]); + + const result = await instance.allByScopeId('brand', 'brand-uuid-123'); + + expect(instance.allByIndexKeys).to.have.been.calledOnceWith({ + scopeType: 'brand', + scopeId: 'brand-uuid-123', + }); + expect(result).to.deep.equal([mockOpportunity]); + }); + + it('returns an empty array when no opportunities match the scope', async () => { + instance.allByIndexKeys = stub().resolves([]); + + const result = await instance.allByScopeId('brand', 'brand-uuid-no-results'); + + expect(result).to.be.an('array').with.lengthOf(0); + }); + }); }); From 7d48e2a74cedb30f86632abc602fe580e61e930c Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Tue, 5 May 2026 11:10:56 +0300 Subject: [PATCH 04/10] fix(data-access): address PR review feedback on Opportunity scope fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename allByScopeId → allByScope (more generic naming) - Remove SCOPE_TYPES.SITE (only BRAND is used for now) - Fix scopeType attribute: use validate() instead of type enum to handle NULL rows - Add UUID validation to scopeId attribute - Add composite index declaration for scopeType+scopeId queries - Fix TypeScript return types: string | null → string | undefined - Narrow setScopeType parameter type to 'brand' - Alphabetize getters/setters in index.d.ts - Update error messages to include method name prefix - Update tests to match renamed method and removed SITE constant Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/opportunity/index.d.ts | 16 ++++++++-------- .../models/opportunity/opportunity.collection.js | 14 ++++++-------- .../src/models/opportunity/opportunity.model.js | 1 - .../src/models/opportunity/opportunity.schema.js | 11 ++++++++--- .../opportunity/opportunity.collection.test.js | 10 +++++----- .../models/opportunity/opportunity.model.test.js | 2 +- 6 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts b/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts index 03c6a2ec5..d2fbe132d 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts @@ -21,31 +21,31 @@ export interface Opportunity extends BaseModel { getAuditId(): string; getData(): object; getDescription(): string; + getFixEntities(): Promise; getGuidance(): string; + getLastAuditedAt(): string; getOrigin(): string; getRunbook(): string; + getScopeId(): string | undefined; + getScopeType(): string | undefined; getSite(): Promise; getSiteId(): string; getStatus(): string; - getFixEntities(): Promise; getSuggestions(): Promise; getSuggestionsByStatus(status: string): Promise; getSuggestionsByStatusAndRank(status: string, rank: string): Promise; - getLastAuditedAt(): string; getTags(): string[]; getTitle(): string; getType(): string; - getScopeId(): string | null; - getScopeType(): string | null; - setScopeId(scopeId: string): Opportunity; - setScopeType(scopeType: string): Opportunity; setAuditId(auditId: string): Opportunity; setData(data: object): Opportunity; setDescription(description: string): Opportunity; - setLastAuditedAt(lastAuditedAt: string): Opportunity; setGuidance(guidance: string): Opportunity; + setLastAuditedAt(lastAuditedAt: string): Opportunity; setOrigin(origin: string): Opportunity; setRunbook(runbook: string): Opportunity; + setScopeId(scopeId: string): Opportunity; + setScopeType(scopeType: 'brand'): Opportunity; setSiteId(siteId: string): Opportunity; setStatus(status: string): Opportunity; setTags(tags: string[]): Opportunity; @@ -53,7 +53,7 @@ export interface Opportunity extends BaseModel { } export interface OpportunityCollection extends BaseCollection { - allByScopeId(scopeType: string, scopeId: string): Promise; + allByScope(scopeType: string, scopeId: string): Promise; allByAuditId(auditId: string): Promise; allByAuditIdAndUpdatedAt(auditId: string, updatedAt: string): Promise; allBySiteId(siteId: string): Promise; diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js index 49d130a64..d2f2b1326 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js @@ -25,20 +25,18 @@ class OpportunityCollection extends BaseCollection { static COLLECTION_NAME = 'OpportunityCollection'; /** - * Returns all opportunities with a given scope type and scope ID. - * Used to fetch brand-scoped opportunities directly by brandId - * without going through site association. + * Returns all opportunities matching a given scope type and scope ID. * - * @param {string} scopeType - The scope type (e.g. 'brand', 'site'). - * @param {string} scopeId - The scope entity UUID (e.g. brand UUID). + * @param {string} scopeType - The scope type (e.g. 'brand'). + * @param {string} scopeId - The scope entity UUID. * @returns {Promise} The matching opportunities. */ - async allByScopeId(scopeType, scopeId) { + async allByScope(scopeType, scopeId) { if (!hasText(scopeType)) { - throw new Error('scopeType is required'); + throw new Error('allByScope: scopeType is required'); } if (!hasText(scopeId)) { - throw new Error('scopeId is required'); + throw new Error('allByScope: scopeId is required'); } return this.allByIndexKeys({ scopeType, scopeId }); } diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js index cf46f0460..40981be31 100755 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js @@ -38,7 +38,6 @@ class Opportunity extends BaseModel { }; static SCOPE_TYPES = { - SITE: 'site', BRAND: 'brand', }; diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js index 83990d2be..ca200c348 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js @@ -12,7 +12,9 @@ /* c8 ignore start */ -import { isIsoDate, isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils'; +import { + isIsoDate, isNonEmptyObject, isValidUrl, isValidUUID, +} from '@adobe/spacecat-shared-utils'; import SchemaBuilder from '../base/schema.builder.js'; import Opportunity from './opportunity.model.js'; @@ -66,10 +68,13 @@ const schema = new SchemaBuilder(Opportunity, OpportunityCollection) validate: (value) => !value || isIsoDate(value), }) .addAttribute('scopeType', { - type: Object.values(Opportunity.SCOPE_TYPES), + type: 'string', + validate: (value) => !value || Object.values(Opportunity.SCOPE_TYPES).includes(value), }) .addAttribute('scopeId', { type: 'string', - }); + validate: (value) => !value || isValidUUID(value), + }) + .addIndex({ composite: ['scopeType'] }, { composite: ['scopeId'] }); export default schema.build(); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index 5a0204c41..408924617 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -72,20 +72,20 @@ describe('OpportunityCollection', () => { }); }); - describe('allByScopeId', () => { + describe('allByScope', () => { it('throws an error if scopeType is not provided', async () => { - await expect(instance.allByScopeId()).to.be.rejectedWith('scopeType is required'); + await expect(instance.allByScope()).to.be.rejectedWith('allByScope: scopeType is required'); }); it('throws an error if scopeId is not provided', async () => { - await expect(instance.allByScopeId('brand')).to.be.rejectedWith('scopeId is required'); + await expect(instance.allByScope('brand')).to.be.rejectedWith('allByScope: scopeId is required'); }); it('delegates to allByIndexKeys with the correct arguments', async () => { const mockOpportunity = { getOpportunityId: () => 'op-111' }; instance.allByIndexKeys = stub().resolves([mockOpportunity]); - const result = await instance.allByScopeId('brand', 'brand-uuid-123'); + const result = await instance.allByScope('brand', 'brand-uuid-123'); expect(instance.allByIndexKeys).to.have.been.calledOnceWith({ scopeType: 'brand', @@ -97,7 +97,7 @@ describe('OpportunityCollection', () => { it('returns an empty array when no opportunities match the scope', async () => { instance.allByIndexKeys = stub().resolves([]); - const result = await instance.allByScopeId('brand', 'brand-uuid-no-results'); + const result = await instance.allByScope('brand', 'brand-uuid-no-results'); expect(result).to.be.an('array').with.lengthOf(0); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js index a557531a0..f8c7392ca 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js @@ -419,8 +419,8 @@ describe('OpportunityModel', () => { describe('SCOPE_TYPES', () => { it('defines expected scope type constants', () => { - expect(Opportunity.SCOPE_TYPES.SITE).to.equal('site'); expect(Opportunity.SCOPE_TYPES.BRAND).to.equal('brand'); + expect(Opportunity.SCOPE_TYPES.SITE).to.be.undefined; }); }); }); From 6136d91e60a84c2f26a1263c7cc66ac2553f22b3 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Wed, 6 May 2026 17:52:33 +0300 Subject: [PATCH 05/10] fix(opportunity): add co-presence validation and invert index order - Add create() override in OpportunityCollection to enforce that scopeType and scopeId must both be set or both be absent; throws ValidationError for half-scoped records - Invert addIndex order to scopeId (higher cardinality) as partition key and scopeType as sort key for better index selectivity - Tests: cover co-presence validation branches including null item path Co-Authored-By: Claude Sonnet 4.6 --- .../opportunity/opportunity.collection.js | 17 ++++++++++ .../models/opportunity/opportunity.schema.js | 2 +- .../opportunity.collection.test.js | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js index d2f2b1326..72ca60ae9 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js @@ -12,6 +12,7 @@ import { hasText } from '@adobe/spacecat-shared-utils'; +import { ValidationError } from '../../errors/index.js'; import BaseCollection from '../base/base.collection.js'; /** @@ -24,6 +25,22 @@ import BaseCollection from '../base/base.collection.js'; class OpportunityCollection extends BaseCollection { static COLLECTION_NAME = 'OpportunityCollection'; + /** + * Validates and creates a new Opportunity. Enforces that scopeType and scopeId + * must both be present or both be absent — a half-scoped record is invalid. + * + * @param {object} item - The opportunity data. + * @param {object} [options] - Optional create options (e.g. { upsert: true }). + * @returns {Promise} The created opportunity instance. + */ + async create(item, options) { + const { scopeType, scopeId } = item || {}; + if (Boolean(scopeType) !== Boolean(scopeId)) { + throw new ValidationError('scopeType and scopeId must both be set or both be absent'); + } + return super.create(item, options); + } + /** * Returns all opportunities matching a given scope type and scope ID. * diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js index ca200c348..3f456f836 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js @@ -75,6 +75,6 @@ const schema = new SchemaBuilder(Opportunity, OpportunityCollection) type: 'string', validate: (value) => !value || isValidUUID(value), }) - .addIndex({ composite: ['scopeType'] }, { composite: ['scopeId'] }); + .addIndex({ composite: ['scopeId'] }, { composite: ['scopeType'] }); export default schema.build(); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index 408924617..b5de6b5ff 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -72,6 +72,39 @@ describe('OpportunityCollection', () => { }); }); + describe('create', () => { + it('throws ValidationError when scopeType is set but scopeId is absent', async () => { + await expect( + instance.create({ type: 'content', scopeType: 'brand' }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('throws ValidationError when scopeId is set but scopeType is absent', async () => { + await expect( + instance.create({ type: 'content', scopeId: '11111111-1111-1111-1111-111111111111' }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('passes co-presence check when both scopeType and scopeId are set', async () => { + // Co-presence check passes — mock create succeeds without complaining. + await expect( + instance.create({ scopeType: 'brand', scopeId: '11111111-1111-1111-1111-111111111111' }), + ).to.be.fulfilled; + }); + + it('passes co-presence check when neither scopeType nor scopeId is set', async () => { + // Neither present — co-presence check passes and mock create succeeds. + await expect( + instance.create({ type: 'content' }), + ).to.be.fulfilled; + }); + + it('handles null item gracefully by delegating to super.create', async () => { + // null item: co-presence check skips (both undefined → equal), super.create handles it. + await expect(instance.create(null)).to.be.rejectedWith(/Failed to create/); + }); + }); + describe('allByScope', () => { it('throws an error if scopeType is not provided', async () => { await expect(instance.allByScope()).to.be.rejectedWith('allByScope: scopeType is required'); From aba6037593b3b5f812a1da86cdca660a2d0cd29f Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Wed, 6 May 2026 18:00:56 +0300 Subject: [PATCH 06/10] test(opportunity): add integration tests for allByScope and co-presence validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IT tests: create brand-A and brand-B scoped opportunities on the same site, assert allByScope('brand', brandA) returns only brandA's opp — proving the scope_type/scope_id columns and index routing work end-to-end in PostgREST (not stubbed like unit tests) - IT test: empty result for an unknown scopeId - IT tests: co-presence violations (scopeType-only, scopeId-only) rejected at create time via the new collection-level guard - Unit tests: replace non-UUID test strings with valid v4 UUIDs to match the schema's isValidUUID validation Co-Authored-By: Claude Sonnet 4.6 --- .../test/it/opportunity/opportunity.test.js | 61 +++++++++++++++++++ .../opportunity.collection.test.js | 7 ++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js index 24b6d0d34..07b5f90ae 100644 --- a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js +++ b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js @@ -382,6 +382,67 @@ describe('Opportunity IT', async () => { expect(record2).to.eql(data[1]); }); + describe('allByScope', () => { + const BRAND_A_ID = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; + const BRAND_B_ID = 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb'; + + const scopedOpportunityBase = { + siteId, + title: 'Scoped Opportunity', + description: 'Test for allByScope', + type: 'prerender', + origin: 'AI', + status: 'NEW', + updatedBy: 'system', + }; + + it('returns only opportunities matching the given brandId', async () => { + // Create one opp for brand-a and one for brand-b on the same site + const oppA = await Opportunity.create({ + ...scopedOpportunityBase, + title: 'Brand A Opportunity', + scopeType: 'brand', + scopeId: BRAND_A_ID, + }); + const oppB = await Opportunity.create({ + ...scopedOpportunityBase, + title: 'Brand B Opportunity', + scopeType: 'brand', + scopeId: BRAND_B_ID, + }); + + const resultA = await Opportunity.allByScope('brand', BRAND_A_ID); + const resultB = await Opportunity.allByScope('brand', BRAND_B_ID); + + const idSetA = resultA.map((o) => o.getId()); + const idSetB = resultB.map((o) => o.getId()); + + expect(idSetA).to.include(oppA.getId()); + expect(idSetA).to.not.include(oppB.getId()); + + expect(idSetB).to.include(oppB.getId()); + expect(idSetB).to.not.include(oppA.getId()); + }); + + it('returns an empty array when no opportunities match the scopeId', async () => { + const UNKNOWN_BRAND_ID = 'cccccccc-cccc-4ccc-cccc-cccccccccccc'; + const result = await Opportunity.allByScope('brand', UNKNOWN_BRAND_ID); + expect(result).to.be.an('array').with.length(0); + }); + + it('rejects co-presence violation: scopeType set without scopeId', async () => { + await expect( + Opportunity.create({ ...scopedOpportunityBase, scopeType: 'brand' }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('rejects co-presence violation: scopeId set without scopeType', async () => { + await expect( + Opportunity.create({ ...scopedOpportunityBase, scopeId: BRAND_A_ID }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + }); + describe('addFixEntities', () => { it('creates fix entities with valid suggestions', async () => { const opportunity = await Opportunity.findById(sampleData.opportunities[2].getId()); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index b5de6b5ff..6fda7290a 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -118,11 +118,12 @@ describe('OpportunityCollection', () => { const mockOpportunity = { getOpportunityId: () => 'op-111' }; instance.allByIndexKeys = stub().resolves([mockOpportunity]); - const result = await instance.allByScope('brand', 'brand-uuid-123'); + const BRAND_UUID = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; + const result = await instance.allByScope('brand', BRAND_UUID); expect(instance.allByIndexKeys).to.have.been.calledOnceWith({ scopeType: 'brand', - scopeId: 'brand-uuid-123', + scopeId: BRAND_UUID, }); expect(result).to.deep.equal([mockOpportunity]); }); @@ -130,7 +131,7 @@ describe('OpportunityCollection', () => { it('returns an empty array when no opportunities match the scope', async () => { instance.allByIndexKeys = stub().resolves([]); - const result = await instance.allByScope('brand', 'brand-uuid-no-results'); + const result = await instance.allByScope('brand', 'cccccccc-cccc-4ccc-cccc-cccccccccccc'); expect(result).to.be.an('array').with.lengthOf(0); }); From 7eeb0838e437bf306d17ec52ece93bf22ba17587 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Thu, 7 May 2026 16:58:33 +0300 Subject: [PATCH 07/10] fix(opportunity): enforce co-presence invariant on save path and address review feedback - Add save() override in Opportunity model to enforce that scopeType and scopeId must both be set or both be absent on every save, not only at create() time - Switch Boolean() to hasText() in collection co-presence check for correct empty-string handling - Pass `this` as second arg to ValidationError in collection, matching the standard call convention used elsewhere in the codebase - Add null/undefined overloads to setScopeId/setScopeType TypeScript declarations so applyScopeToOpportunity types correctly - Add after() cleanup hook in IT allByScope tests to remove oppA/oppB; fix test description from "brandId" to "scope" - Add docker-compose comment explaining the mutable image tag and how to override it - Add unit tests for save() co-presence (all four branches) and direct schema validate guard tests for invalid scopeType/scopeId values Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/opportunity/index.d.ts | 4 +-- .../opportunity/opportunity.collection.js | 4 +-- .../models/opportunity/opportunity.model.js | 20 +++++++++++++ .../test/it/opportunity/opportunity.test.js | 13 ++++++-- .../test/it/postgrest/docker-compose.yml | 2 ++ .../opportunity.collection.test.js | 16 ++++++++++ .../opportunity/opportunity.model.test.js | 30 +++++++++++++++++++ 7 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts b/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts index d2fbe132d..83af384c6 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/opportunity/index.d.ts @@ -44,8 +44,8 @@ export interface Opportunity extends BaseModel { setLastAuditedAt(lastAuditedAt: string): Opportunity; setOrigin(origin: string): Opportunity; setRunbook(runbook: string): Opportunity; - setScopeId(scopeId: string): Opportunity; - setScopeType(scopeType: 'brand'): Opportunity; + setScopeId(scopeId: string | null | undefined): Opportunity; + setScopeType(scopeType: 'brand' | null | undefined): Opportunity; setSiteId(siteId: string): Opportunity; setStatus(status: string): Opportunity; setTags(tags: string[]): Opportunity; diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js index 72ca60ae9..2c77c0488 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js @@ -35,8 +35,8 @@ class OpportunityCollection extends BaseCollection { */ async create(item, options) { const { scopeType, scopeId } = item || {}; - if (Boolean(scopeType) !== Boolean(scopeId)) { - throw new ValidationError('scopeType and scopeId must both be set or both be absent'); + if (hasText(scopeType) !== hasText(scopeId)) { + throw new ValidationError('scopeType and scopeId must both be set or both be absent', this); } return super.create(item, options); } diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js index 40981be31..b4d585aef 100755 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.model.js @@ -10,6 +10,9 @@ * governing permissions and limitations under the License. */ +import { hasText } from '@adobe/spacecat-shared-utils'; + +import { ValidationError } from '../../errors/index.js'; import BaseModel from '../base/base.model.js'; /** @@ -41,6 +44,23 @@ class Opportunity extends BaseModel { BRAND: 'brand', }; + /** + * Overrides BaseModel.save() to enforce the co-presence invariant: scopeType and scopeId + * must both be set or both be absent. Guards the setter+save path in addition to create(). + * + * @returns {Promise} The saved opportunity. + * @throws {ValidationError} When only one of scopeType / scopeId is set. + */ + async save() { + if (hasText(this.getScopeType()) !== hasText(this.getScopeId())) { + throw new ValidationError( + 'scopeType and scopeId must both be set or both be absent', + this, + ); + } + return super.save(); + } + /** * Adds the given suggestions to this Opportunity. Sets this opportunity as the parent * of each suggestion, as such the opportunity ID does not need to be provided. diff --git a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js index 07b5f90ae..f2e71e01f 100644 --- a/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js +++ b/packages/spacecat-shared-data-access/test/it/opportunity/opportunity.test.js @@ -396,15 +396,22 @@ describe('Opportunity IT', async () => { updatedBy: 'system', }; - it('returns only opportunities matching the given brandId', async () => { + let oppA; + let oppB; + + after(async () => { + await Promise.all([oppA?.remove(), oppB?.remove()].filter(Boolean)); + }); + + it('returns only opportunities matching the given scope', async () => { // Create one opp for brand-a and one for brand-b on the same site - const oppA = await Opportunity.create({ + oppA = await Opportunity.create({ ...scopedOpportunityBase, title: 'Brand A Opportunity', scopeType: 'brand', scopeId: BRAND_A_ID, }); - const oppB = await Opportunity.create({ + oppB = await Opportunity.create({ ...scopedOpportunityBase, title: 'Brand B Opportunity', scopeType: 'brand', diff --git a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml index 777bf436b..d5d028fd7 100644 --- a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml +++ b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml @@ -17,6 +17,8 @@ services: data-service: platform: ${MYSTICAT_DATA_SERVICE_PLATFORM:-linux/amd64} + # Default tag is bumped here whenever a new schema migration requires a matching data-service + # image. Override at runtime: export MYSTICAT_DATA_SERVICE_TAG=v before npm run test:it. image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1} depends_on: db: diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index 6fda7290a..d900b5c12 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -105,6 +105,22 @@ describe('OpportunityCollection', () => { }); }); + describe('schema validate guards', () => { + it('rejects scopeType values not in SCOPE_TYPES', () => { + const { scopeType } = schema.getAttributes(); + expect(scopeType.validate('page')).to.be.false; + expect(scopeType.validate('brand')).to.be.true; + expect(scopeType.validate(null)).to.be.true; + }); + + it('rejects scopeId values that are not valid UUIDs', () => { + const { scopeId } = schema.getAttributes(); + expect(scopeId.validate('not-a-uuid')).to.be.false; + expect(scopeId.validate('11111111-1111-1111-1111-111111111111')).to.be.true; + expect(scopeId.validate(null)).to.be.true; + }); + }); + describe('allByScope', () => { it('throws an error if scopeType is not provided', async () => { await expect(instance.allByScope()).to.be.rejectedWith('allByScope: scopeType is required'); diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js index f8c7392ca..aa48e80bb 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js @@ -423,4 +423,34 @@ describe('OpportunityModel', () => { expect(Opportunity.SCOPE_TYPES.SITE).to.be.undefined; }); }); + + describe('save (scope co-presence)', () => { + it('throws ValidationError when scopeType is set but scopeId is absent', async () => { + instance.setScopeType('brand'); + await expect(instance.save()).to.be.rejectedWith( + 'scopeType and scopeId must both be set or both be absent', + ); + }); + + it('throws ValidationError when scopeId is set but scopeType is absent', async () => { + instance.setScopeId('11111111-1111-1111-1111-111111111111'); + await expect(instance.save()).to.be.rejectedWith( + 'scopeType and scopeId must both be set or both be absent', + ); + }); + + it('delegates to super.save() when both scopeType and scopeId are set', async () => { + instance.setScopeType('brand'); + instance.setScopeId('11111111-1111-1111-1111-111111111111'); + const patcherSaveStub = stub(instance.patcher, 'save').resolves(); + await expect(instance.save()).to.be.fulfilled; + expect(patcherSaveStub.calledOnce).to.be.true; + }); + + it('delegates to super.save() when neither scopeType nor scopeId is set', async () => { + const patcherSaveStub = stub(instance.patcher, 'save').resolves(); + await expect(instance.save()).to.be.fulfilled; + expect(patcherSaveStub.calledOnce).to.be.true; + }); + }); }); From df0394b68b7efca2b4b9c573db534fcb1fad4029 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Fri, 8 May 2026 09:24:53 +0300 Subject: [PATCH 08/10] fix(opportunity): guard updateByKeys against half-scoped writes Add an updateByKeys override on OpportunityCollection that enforces the scopeType/scopeId co-presence invariant. The base class patches DynamoDB directly, bypassing the create() and save() guards already in place. The check fires when either field appears in the update payload: - both must be present together in the same call - their values must satisfy co-presence (both truthy or both null/empty) Clearing only one field (e.g. { scopeType: null } without scopeId) now throws ValidationError, preventing ghost-scope state in re-run scenarios. Co-Authored-By: Claude Sonnet 4.6 --- .../opportunity/opportunity.collection.js | 26 ++++++++++ .../opportunity.collection.test.js | 52 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js index 2c77c0488..b091369df 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js @@ -41,6 +41,32 @@ class OpportunityCollection extends BaseCollection { return super.create(item, options); } + /** + * Validates and updates an Opportunity by its keys. Enforces the scopeType/scopeId + * co-presence invariant: if either field appears in the update payload, both must + * be set or both must be absent — a half-scoped update is invalid. + * + * @param {object} keys - The key attributes identifying the record. + * @param {object} updates - The fields to update. + * @returns {Promise} + */ + async updateByKeys(keys, updates) { + const hasScopeType = updates != null && 'scopeType' in updates; + const hasScopeId = updates != null && 'scopeId' in updates; + // If either field is included in the update, both must be included together, + // and their combined values must satisfy co-presence (both set or both absent). + if (hasScopeType !== hasScopeId) { + throw new ValidationError('scopeType and scopeId must both be set or both be absent', this); + } + if (hasScopeType && hasScopeId) { + const { scopeType, scopeId } = updates; + if (hasText(scopeType) !== hasText(scopeId)) { + throw new ValidationError('scopeType and scopeId must both be set or both be absent', this); + } + } + return super.updateByKeys(keys, updates); + } + /** * Returns all opportunities matching a given scope type and scope ID. * diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index d900b5c12..d971e84e7 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -105,6 +105,58 @@ describe('OpportunityCollection', () => { }); }); + describe('updateByKeys', () => { + const KEYS = { opportunityId: 'op12345', siteId: 'site67890' }; + let superUpdateByKeysStub; + + beforeEach(() => { + // Stub the inherited updateByKeys so the ElectroDB patch chain is not invoked. + // The co-presence guard runs before super.updateByKeys, so stubbing the super + // lets us isolate the validation logic without needing a full ElectroDB mock. + superUpdateByKeysStub = stub(Object.getPrototypeOf(Object.getPrototypeOf(instance)), 'updateByKeys').resolves(); + }); + + afterEach(() => { + superUpdateByKeysStub.restore(); + }); + + it('throws ValidationError when updating scopeType without scopeId', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeType: 'brand' }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('throws ValidationError when updating scopeId without scopeType', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeId: '11111111-1111-1111-1111-111111111111' }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('throws ValidationError when clearing only one scope field', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeType: null }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('passes co-presence check when setting both scopeType and scopeId', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeType: 'brand', scopeId: '11111111-1111-1111-1111-111111111111' }), + ).to.be.fulfilled; + }); + + it('passes co-presence check when clearing both scopeType and scopeId', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeType: null, scopeId: null }), + ).to.be.fulfilled; + }); + + it('passes co-presence check when update does not touch scope fields', async () => { + await expect( + instance.updateByKeys(KEYS, { title: 'Updated title' }), + ).to.be.fulfilled; + }); + }); + describe('schema validate guards', () => { it('rejects scopeType values not in SCOPE_TYPES', () => { const { scopeType } = schema.getAttributes(); From 6b0e10af9441f4a1ccfb849b184a6f181a39aa04 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Fri, 8 May 2026 10:31:07 +0300 Subject: [PATCH 09/10] test(opportunity): cover value-mismatch branch in updateByKeys co-presence guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two tests for the case where both scopeType and scopeId keys are present in the update object but one value is non-empty and the other is null — the second throw in the updateByKeys guard (lines 64-65). These were the only uncovered lines causing the 100% coverage threshold to fail. Co-Authored-By: Claude Sonnet 4.6 --- .../opportunity/opportunity.collection.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index d971e84e7..9f24f6f0a 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -138,6 +138,18 @@ describe('OpportunityCollection', () => { ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); }); + it('throws ValidationError when both keys are present but scopeId value is null', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeType: 'brand', scopeId: null }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + + it('throws ValidationError when both keys are present but scopeType value is null', async () => { + await expect( + instance.updateByKeys(KEYS, { scopeType: null, scopeId: '11111111-1111-1111-1111-111111111111' }), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + }); + it('passes co-presence check when setting both scopeType and scopeId', async () => { await expect( instance.updateByKeys(KEYS, { scopeType: 'brand', scopeId: '11111111-1111-1111-1111-111111111111' }), From 577b6d3e6f7475a1783afb37e19da89b680b88b4 Mon Sep 17 00:00:00 2001 From: Alexandru Tudoran Date: Mon, 11 May 2026 14:28:39 +0300 Subject: [PATCH 10/10] fix(opportunity): close remaining write-path gaps and pin IT image digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the round-5 carry-forward findings from PR #1576: - Add a createMany override that runs the same co-presence guard as create() and updateByKeys(). BaseCollection.createMany previously bypassed validation, so a consumer using batch ingestion could persist half-scoped opportunities without any signal — broken GSI semantics, no audit trail. Half-scoped items now throw ValidationError before reaching the data service. - Document updateByKeys's scope-reattribution capability via JSDoc. The guard enforces co-presence but allows scope_id to change post-creation. Callers must verify authorization before mutating scope_id (it's the tenant boundary). - Support pinning the data-service image to an immutable digest via a new MYSTICAT_DATA_SERVICE_DIGEST env var. Documents the AWS ECR command to obtain it. The tag remains v5.1.1 (human-readable) and the digest, when set, prevents a re-pushed tag from silently changing the CI binary. - Add tests: createMany rejection (4 cases) and clear-then-save regression (2 cases — the most common realistic mutation pattern, missing from the suite). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../opportunity/opportunity.collection.js | 27 ++++++++++ .../test/it/postgrest/docker-compose.yml | 15 +++++- .../opportunity.collection.test.js | 50 +++++++++++++++++++ .../opportunity/opportunity.model.test.js | 21 ++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js index b091369df..21d19ba9c 100644 --- a/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js +++ b/packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js @@ -41,11 +41,38 @@ class OpportunityCollection extends BaseCollection { return super.create(item, options); } + /** + * Validates and bulk-creates Opportunities. Enforces the scopeType/scopeId + * co-presence invariant on every item — half-scoped records are invalid. + * + * Overrides BaseCollection.createMany() which would otherwise bypass the + * single-item create() guard and persist invalid scope tuples directly. + * + * @param {object[]} items - The opportunity items to create. + * @param {...*} rest - Additional arguments forwarded to BaseCollection.createMany. + * @returns {Promise<*>} The result from BaseCollection.createMany. + */ + async createMany(items, ...rest) { + if (Array.isArray(items)) { + for (const item of items) { + const { scopeType, scopeId } = item || {}; + if (hasText(scopeType) !== hasText(scopeId)) { + throw new ValidationError('scopeType and scopeId must both be set or both be absent', this); + } + } + } + return super.createMany(items, ...rest); + } + /** * Validates and updates an Opportunity by its keys. Enforces the scopeType/scopeId * co-presence invariant: if either field appears in the update payload, both must * be set or both must be absent — a half-scoped update is invalid. * + * Note: this guard enforces co-presence but allows scope re-attribution + * (moving an opportunity from one scopeId to another within the same scopeType). + * scopeId is the tenant boundary; callers must verify authorization before mutating it. + * * @param {object} keys - The key attributes identifying the record. * @param {object} updates - The fields to update. * @returns {Promise} diff --git a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml index d5d028fd7..a03341f9e 100644 --- a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml +++ b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml @@ -18,8 +18,19 @@ services: data-service: platform: ${MYSTICAT_DATA_SERVICE_PLATFORM:-linux/amd64} # Default tag is bumped here whenever a new schema migration requires a matching data-service - # image. Override at runtime: export MYSTICAT_DATA_SERVICE_TAG=v before npm run test:it. - image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1} + # image. The tag is supplemented by an immutable digest so CI cannot silently consume a + # re-pushed tag — if ECR re-publishes v5.1.1 with a different binary, the digest mismatch + # causes a hard pull failure instead of silent drift. + # + # To obtain or refresh the digest: + # aws ecr describe-images \ + # --profile --region us-east-1 \ + # --repository-name mysticat-data-service \ + # --image-ids imageTag=v5.1.1 \ + # --query 'imageDetails[0].imageDigest' --output text + # Then export MYSTICAT_DATA_SERVICE_DIGEST=sha256: before npm run test:it, + # or set it in the .env file consumed by docker compose. Leave unset for local dev. + image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1}${MYSTICAT_DATA_SERVICE_DIGEST:+@${MYSTICAT_DATA_SERVICE_DIGEST}} depends_on: db: condition: service_healthy diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js index 9f24f6f0a..afa2e731f 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js @@ -105,6 +105,56 @@ describe('OpportunityCollection', () => { }); }); + describe('createMany (scope co-presence)', () => { + let superCreateManyStub; + + beforeEach(() => { + // Stub the inherited createMany so the ElectroDB batch chain is not invoked. + // The per-item co-presence guard runs before super.createMany, so stubbing + // the super lets us isolate the validation logic. + superCreateManyStub = stub(Object.getPrototypeOf(Object.getPrototypeOf(instance)), 'createMany').resolves({ createdItems: [] }); + }); + + afterEach(() => { + superCreateManyStub.restore(); + }); + + it('rejects when any item has scopeType set without scopeId', async () => { + await expect( + instance.createMany([ + { type: 'content', scopeType: 'brand', scopeId: '11111111-1111-1111-1111-111111111111' }, + { type: 'content', scopeType: 'brand' }, + ]), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + expect(superCreateManyStub).to.not.have.been.called; + }); + + it('rejects when any item has scopeId set without scopeType', async () => { + await expect( + instance.createMany([ + { type: 'content', scopeId: '11111111-1111-1111-1111-111111111111' }, + ]), + ).to.be.rejectedWith('scopeType and scopeId must both be set or both be absent'); + expect(superCreateManyStub).to.not.have.been.called; + }); + + it('delegates to super.createMany when all items pass co-presence', async () => { + const items = [ + { type: 'content' }, + { type: 'content', scopeType: 'brand', scopeId: '11111111-1111-1111-1111-111111111111' }, + ]; + await expect(instance.createMany(items)).to.be.fulfilled; + expect(superCreateManyStub).to.have.been.calledOnce; + expect(superCreateManyStub.firstCall.args[0]).to.equal(items); + }); + + it('delegates to super.createMany when items is not an array', async () => { + // Non-array input bypasses per-item validation and is handled by the super class. + await expect(instance.createMany(null)).to.be.fulfilled; + expect(superCreateManyStub).to.have.been.calledOnceWith(null); + }); + }); + describe('updateByKeys', () => { const KEYS = { opportunityId: 'op12345', siteId: 'site67890' }; let superUpdateByKeysStub; diff --git a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js index aa48e80bb..eb9322f6d 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.model.test.js @@ -452,5 +452,26 @@ describe('OpportunityModel', () => { await expect(instance.save()).to.be.fulfilled; expect(patcherSaveStub.calledOnce).to.be.true; }); + + it('throws ValidationError when a fully-scoped record has scopeId cleared', async () => { + // Realistic regression scenario: a record loaded from DB has both scope fields set, + // and a caller clears only scopeId via the setter. The save() guard must catch the + // half-scoped state before the patch reaches the DB. + instance.record.scopeType = 'brand'; + instance.record.scopeId = '11111111-1111-1111-1111-111111111111'; + instance.setScopeId(null); + await expect(instance.save()).to.be.rejectedWith( + 'scopeType and scopeId must both be set or both be absent', + ); + }); + + it('throws ValidationError when a fully-scoped record has scopeType cleared', async () => { + instance.record.scopeType = 'brand'; + instance.record.scopeId = '11111111-1111-1111-1111-111111111111'; + instance.setScopeType(null); + await expect(instance.save()).to.be.rejectedWith( + 'scopeType and scopeId must both be set or both be absent', + ); + }); }); });