From 5e4941e8f340147532484eb0f13908fcf169063d Mon Sep 17 00:00:00 2001 From: ppatwal Date: Mon, 13 Jul 2026 11:08:40 +0530 Subject: [PATCH 1/3] feat(data-access): add allBySuggestionIds and allByTicketIds bulk accessors to TicketSuggestionCollection (SITES-44690) Co-Authored-By: Claude Sonnet 4.6 --- .../ticket-suggestion.collection.js | 49 ++++++ .../ticket-suggestion.collection.test.js | 153 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js index f90cd3c88..9adfb52c7 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { DataAccessError } from '../../errors/index.js'; import BaseCollection from '../base/base.collection.js'; /** @@ -24,6 +25,54 @@ import BaseCollection from '../base/base.collection.js'; */ class TicketSuggestionCollection extends BaseCollection { static COLLECTION_NAME = 'TicketSuggestionCollection'; + + /** + * Returns all bridge records whose suggestion_id is in the given array. + * Used for bulk pre-flight checks before ticket creation. + * + * @param {string[]} suggestionIds + * @returns {Promise} + */ + async allBySuggestionIds(suggestionIds) { + if (!Array.isArray(suggestionIds) || suggestionIds.length === 0) { + return []; + } + + const { data, error } = await this.postgrestService + .from(this.tableName) + .select() + .in('suggestion_id', suggestionIds); + + if (error) { + throw new DataAccessError('Failed to load ticket suggestions by suggestion IDs', { entityName: 'TicketSuggestion' }, error); + } + + return (data ?? []).map((row) => this.createInstanceFromRow(row)); + } + + /** + * Returns all bridge records whose ticket_id is in the given array. + * Used to bulk-load suggestion mappings when listing tickets. + * + * @param {string[]} ticketIds + * @returns {Promise} + */ + async allByTicketIds(ticketIds) { + if (!Array.isArray(ticketIds) || ticketIds.length === 0) { + return []; + } + + const { data, error } = await this.postgrestService + .from(this.tableName) + .select() + .in('ticket_id', ticketIds); + + if (error) { + throw new DataAccessError('Failed to load ticket suggestions by ticket IDs', { entityName: 'TicketSuggestion' }, error); + } + + return (data ?? []).map((row) => this.createInstanceFromRow(row)); + } } export default TicketSuggestionCollection; diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js new file mode 100644 index 000000000..d222f4254 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; + +import { createElectroMocks } from '../../util.js'; +import TicketSuggestion from '../../../../src/models/ticket-suggestion/ticket-suggestion.model.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const TICKET_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const SUGGESTION_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +const MOCK_RECORD = { + ticketSuggestionId: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + ticketId: TICKET_ID, + suggestionId: SUGGESTION_ID, + opportunityId: 'dddddddd-dddd-dddd-dddd-dddddddddddd', + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00.000Z', +}; + +const DB_ROW = { + id: MOCK_RECORD.ticketSuggestionId, + ticket_id: MOCK_RECORD.ticketId, + suggestion_id: MOCK_RECORD.suggestionId, + opportunity_id: MOCK_RECORD.opportunityId, + created_by: MOCK_RECORD.createdBy, + created_at: MOCK_RECORD.createdAt, +}; + +describe('TicketSuggestionCollection', () => { + let instance; + + beforeEach(() => { + ({ collection: instance } = createElectroMocks(TicketSuggestion, MOCK_RECORD)); + }); + + describe('allBySuggestionIds()', () => { + function setupInChain(result) { + const inStub = sinon.stub().resolves(result); + const selectStub = sinon.stub().returns({ in: inStub }); + instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); + return { selectStub, inStub }; + } + + it('returns empty array for empty input', async () => { + const result = await instance.allBySuggestionIds([]); + expect(result).to.deep.equal([]); + }); + + it('returns empty array for non-array input', async () => { + const result = await instance.allBySuggestionIds(null); + expect(result).to.deep.equal([]); + }); + + it('returns mapped instances when rows found', async () => { + setupInChain({ data: [DB_ROW], error: null }); + + const result = await instance.allBySuggestionIds([SUGGESTION_ID]); + + expect(result).to.have.length(1); + expect(result[0].getSuggestionId()).to.equal(SUGGESTION_ID); + }); + + it('returns empty array when data is null', async () => { + setupInChain({ data: null, error: null }); + + const result = await instance.allBySuggestionIds([SUGGESTION_ID]); + + expect(result).to.deep.equal([]); + }); + + it('queries with correct column and values', async () => { + const { inStub } = setupInChain({ data: [], error: null }); + + await instance.allBySuggestionIds([SUGGESTION_ID]); + + expect(inStub).to.have.been.calledWith('suggestion_id', [SUGGESTION_ID]); + }); + + it('throws DataAccessError when PostgREST returns an error', async () => { + setupInChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); + + await expect(instance.allBySuggestionIds([SUGGESTION_ID])) + .to.be.rejectedWith('Failed to load ticket suggestions by suggestion IDs'); + }); + }); + + describe('allByTicketIds()', () => { + function setupInChain(result) { + const inStub = sinon.stub().resolves(result); + const selectStub = sinon.stub().returns({ in: inStub }); + instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); + return { selectStub, inStub }; + } + + it('returns empty array for empty input', async () => { + const result = await instance.allByTicketIds([]); + expect(result).to.deep.equal([]); + }); + + it('returns empty array for non-array input', async () => { + const result = await instance.allByTicketIds(null); + expect(result).to.deep.equal([]); + }); + + it('returns mapped instances when rows found', async () => { + setupInChain({ data: [DB_ROW], error: null }); + + const result = await instance.allByTicketIds([TICKET_ID]); + + expect(result).to.have.length(1); + expect(result[0].getTicketId()).to.equal(TICKET_ID); + }); + + it('returns empty array when data is null', async () => { + setupInChain({ data: null, error: null }); + + const result = await instance.allByTicketIds([TICKET_ID]); + + expect(result).to.deep.equal([]); + }); + + it('queries with correct column and values', async () => { + const { inStub } = setupInChain({ data: [], error: null }); + + await instance.allByTicketIds([TICKET_ID]); + + expect(inStub).to.have.been.calledWith('ticket_id', [TICKET_ID]); + }); + + it('throws DataAccessError when PostgREST returns an error', async () => { + setupInChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); + + await expect(instance.allByTicketIds([TICKET_ID])) + .to.be.rejectedWith('Failed to load ticket suggestions by ticket IDs'); + }); + }); +}); From 7141a2846cb0e49a2b86ef39927409f9b6ae0084 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Mon, 13 Jul 2026 11:20:40 +0530 Subject: [PATCH 2/3] refactor(data-access): use this.all() with op.in for bulk TicketSuggestion accessors (SITES-44690) Co-Authored-By: Claude Sonnet 4.6 --- .../ticket-suggestion.collection.js | 38 ++--- .../ticket-suggestion.collection.test.js | 136 +++++++++--------- 2 files changed, 90 insertions(+), 84 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js index 9adfb52c7..1dd48f360 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js @@ -38,16 +38,17 @@ class TicketSuggestionCollection extends BaseCollection { return []; } - const { data, error } = await this.postgrestService - .from(this.tableName) - .select() - .in('suggestion_id', suggestionIds); - - if (error) { - throw new DataAccessError('Failed to load ticket suggestions by suggestion IDs', { entityName: 'TicketSuggestion' }, error); + try { + return await this.all( + {}, + { where: (attrs, op) => op.in(attrs.suggestionId, suggestionIds) }, + ); + } catch (error) { + if (error instanceof DataAccessError) { + throw error; + } + throw new DataAccessError('Failed to load ticket suggestions by suggestion IDs', this, error); } - - return (data ?? []).map((row) => this.createInstanceFromRow(row)); } /** @@ -62,16 +63,17 @@ class TicketSuggestionCollection extends BaseCollection { return []; } - const { data, error } = await this.postgrestService - .from(this.tableName) - .select() - .in('ticket_id', ticketIds); - - if (error) { - throw new DataAccessError('Failed to load ticket suggestions by ticket IDs', { entityName: 'TicketSuggestion' }, error); + try { + return await this.all( + {}, + { where: (attrs, op) => op.in(attrs.ticketId, ticketIds) }, + ); + } catch (error) { + if (error instanceof DataAccessError) { + throw error; + } + throw new DataAccessError('Failed to load ticket suggestions by ticket IDs', this, error); } - - return (data ?? []).map((row) => this.createInstanceFromRow(row)); } } diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js index d222f4254..95d806add 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js @@ -13,8 +13,9 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; -import sinon from 'sinon'; +import { stub } from 'sinon'; +import { DataAccessError } from '../../../../src/errors/index.js'; import { createElectroMocks } from '../../util.js'; import TicketSuggestion from '../../../../src/models/ticket-suggestion/ticket-suggestion.model.js'; @@ -33,15 +34,6 @@ const MOCK_RECORD = { createdAt: '2026-01-01T00:00:00.000Z', }; -const DB_ROW = { - id: MOCK_RECORD.ticketSuggestionId, - ticket_id: MOCK_RECORD.ticketId, - suggestion_id: MOCK_RECORD.suggestionId, - opportunity_id: MOCK_RECORD.opportunityId, - created_by: MOCK_RECORD.createdBy, - created_at: MOCK_RECORD.createdAt, -}; - describe('TicketSuggestionCollection', () => { let instance; @@ -50,104 +42,116 @@ describe('TicketSuggestionCollection', () => { }); describe('allBySuggestionIds()', () => { - function setupInChain(result) { - const inStub = sinon.stub().resolves(result); - const selectStub = sinon.stub().returns({ in: inStub }); - instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); - return { selectStub, inStub }; - } - - it('returns empty array for empty input', async () => { + it('returns empty array for empty input without querying', async () => { + instance.all = stub(); + const result = await instance.allBySuggestionIds([]); + expect(result).to.deep.equal([]); + expect(instance.all).to.not.have.been.called; }); - it('returns empty array for non-array input', async () => { + it('returns empty array for non-array input without querying', async () => { + instance.all = stub(); + const result = await instance.allBySuggestionIds(null); + expect(result).to.deep.equal([]); + expect(instance.all).to.not.have.been.called; }); - it('returns mapped instances when rows found', async () => { - setupInChain({ data: [DB_ROW], error: null }); + it('delegates to this.all() with correct where clause', async () => { + const mockResults = [{ getSuggestionId: () => SUGGESTION_ID }]; + instance.all = stub().resolves(mockResults); const result = await instance.allBySuggestionIds([SUGGESTION_ID]); - expect(result).to.have.length(1); - expect(result[0].getSuggestionId()).to.equal(SUGGESTION_ID); - }); - - it('returns empty array when data is null', async () => { - setupInChain({ data: null, error: null }); + expect(result).to.deep.equal(mockResults); + expect(instance.all).to.have.been.calledOnce; - const result = await instance.allBySuggestionIds([SUGGESTION_ID]); + const [sortKeys, options] = instance.all.firstCall.args; + expect(sortKeys).to.deep.equal({}); - expect(result).to.deep.equal([]); + const opStub = { in: stub().returns('IN_EXPR') }; + const expression = options.where({ suggestionId: 'suggestionId' }, opStub); + expect(opStub.in).to.have.been.calledOnceWith('suggestionId', [SUGGESTION_ID]); + expect(expression).to.equal('IN_EXPR'); }); - it('queries with correct column and values', async () => { - const { inStub } = setupInChain({ data: [], error: null }); - - await instance.allBySuggestionIds([SUGGESTION_ID]); + it('throws DataAccessError when this.all() rejects', async () => { + instance.all = stub().rejects(new Error('DB error')); - expect(inStub).to.have.been.calledWith('suggestion_id', [SUGGESTION_ID]); + await expect(instance.allBySuggestionIds([SUGGESTION_ID])) + .to.be.rejectedWith(DataAccessError, 'Failed to load ticket suggestions by suggestion IDs'); }); - it('throws DataAccessError when PostgREST returns an error', async () => { - setupInChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); + it('re-throws DataAccessError without wrapping', async () => { + const inner = new DataAccessError('inner failure'); + instance.all = stub().rejects(inner); - await expect(instance.allBySuggestionIds([SUGGESTION_ID])) - .to.be.rejectedWith('Failed to load ticket suggestions by suggestion IDs'); + try { + await instance.allBySuggestionIds([SUGGESTION_ID]); + expect.fail('should have thrown'); + } catch (err) { + expect(err).to.equal(inner); + } }); }); describe('allByTicketIds()', () => { - function setupInChain(result) { - const inStub = sinon.stub().resolves(result); - const selectStub = sinon.stub().returns({ in: inStub }); - instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); - return { selectStub, inStub }; - } - - it('returns empty array for empty input', async () => { + it('returns empty array for empty input without querying', async () => { + instance.all = stub(); + const result = await instance.allByTicketIds([]); + expect(result).to.deep.equal([]); + expect(instance.all).to.not.have.been.called; }); - it('returns empty array for non-array input', async () => { + it('returns empty array for non-array input without querying', async () => { + instance.all = stub(); + const result = await instance.allByTicketIds(null); + expect(result).to.deep.equal([]); + expect(instance.all).to.not.have.been.called; }); - it('returns mapped instances when rows found', async () => { - setupInChain({ data: [DB_ROW], error: null }); + it('delegates to this.all() with correct where clause', async () => { + const mockResults = [{ getTicketId: () => TICKET_ID }]; + instance.all = stub().resolves(mockResults); const result = await instance.allByTicketIds([TICKET_ID]); - expect(result).to.have.length(1); - expect(result[0].getTicketId()).to.equal(TICKET_ID); - }); - - it('returns empty array when data is null', async () => { - setupInChain({ data: null, error: null }); + expect(result).to.deep.equal(mockResults); + expect(instance.all).to.have.been.calledOnce; - const result = await instance.allByTicketIds([TICKET_ID]); + const [sortKeys, options] = instance.all.firstCall.args; + expect(sortKeys).to.deep.equal({}); - expect(result).to.deep.equal([]); + const opStub = { in: stub().returns('IN_EXPR') }; + const expression = options.where({ ticketId: 'ticketId' }, opStub); + expect(opStub.in).to.have.been.calledOnceWith('ticketId', [TICKET_ID]); + expect(expression).to.equal('IN_EXPR'); }); - it('queries with correct column and values', async () => { - const { inStub } = setupInChain({ data: [], error: null }); + it('throws DataAccessError when this.all() rejects', async () => { + instance.all = stub().rejects(new Error('DB error')); - await instance.allByTicketIds([TICKET_ID]); - - expect(inStub).to.have.been.calledWith('ticket_id', [TICKET_ID]); + await expect(instance.allByTicketIds([TICKET_ID])) + .to.be.rejectedWith(DataAccessError, 'Failed to load ticket suggestions by ticket IDs'); }); - it('throws DataAccessError when PostgREST returns an error', async () => { - setupInChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); + it('re-throws DataAccessError without wrapping', async () => { + const inner = new DataAccessError('inner failure'); + instance.all = stub().rejects(inner); - await expect(instance.allByTicketIds([TICKET_ID])) - .to.be.rejectedWith('Failed to load ticket suggestions by ticket IDs'); + try { + await instance.allByTicketIds([TICKET_ID]); + expect.fail('should have thrown'); + } catch (err) { + expect(err).to.equal(inner); + } }); }); }); From 5b30c85680a45e2a724bc63a33aec4a8ceb29525 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Mon, 13 Jul 2026 12:23:35 +0530 Subject: [PATCH 3/3] refactor(data-access): align TicketSuggestion bulk accessors with reference pattern (SITES-44690) - Add IN-filter chunking (size 50) with parallel Promise.all to prevent URL-length failures on large ID arrays - Replace ad-hoc Array.isArray guard with guardArray() for consistent ValidationError on invalid input - Deduplicate input IDs with [...new Set()] before chunking - Switch test stubs from bare sinon.stub() to sandbox pattern with afterEach restore - Add test cases for ValidationError, deduplication, and chunking behaviour Co-Authored-By: Claude Sonnet 4.6 --- .../ticket-suggestion.collection.js | 43 ++++++-- .../ticket-suggestion.collection.test.js | 99 +++++++++++++------ 2 files changed, 104 insertions(+), 38 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js index 1dd48f360..6cc669aa2 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js @@ -11,8 +11,11 @@ */ import { DataAccessError } from '../../errors/index.js'; +import { guardArray } from '../../util/guards.js'; import BaseCollection from '../base/base.collection.js'; +const IN_FILTER_CHUNK_SIZE = 50; + /** * TicketSuggestionCollection — manages TicketSuggestion bridge records. * @@ -34,15 +37,27 @@ class TicketSuggestionCollection extends BaseCollection { * @returns {Promise} */ async allBySuggestionIds(suggestionIds) { - if (!Array.isArray(suggestionIds) || suggestionIds.length === 0) { + guardArray('suggestionIds', suggestionIds, 'TicketSuggestionCollection', 'string'); + + if (suggestionIds.length === 0) { return []; } try { - return await this.all( - {}, - { where: (attrs, op) => op.in(attrs.suggestionId, suggestionIds) }, + const uniqueIds = [...new Set(suggestionIds)]; + const chunks = []; + for (let i = 0; i < uniqueIds.length; i += IN_FILTER_CHUNK_SIZE) { + chunks.push(uniqueIds.slice(i, i + IN_FILTER_CHUNK_SIZE)); + } + + const results = await Promise.all( + chunks.map((chunk) => this.all( + {}, + { where: (attrs, op) => op.in(attrs.suggestionId, chunk) }, + )), ); + + return results.flat(); } catch (error) { if (error instanceof DataAccessError) { throw error; @@ -59,15 +74,27 @@ class TicketSuggestionCollection extends BaseCollection { * @returns {Promise} */ async allByTicketIds(ticketIds) { - if (!Array.isArray(ticketIds) || ticketIds.length === 0) { + guardArray('ticketIds', ticketIds, 'TicketSuggestionCollection', 'string'); + + if (ticketIds.length === 0) { return []; } try { - return await this.all( - {}, - { where: (attrs, op) => op.in(attrs.ticketId, ticketIds) }, + const uniqueIds = [...new Set(ticketIds)]; + const chunks = []; + for (let i = 0; i < uniqueIds.length; i += IN_FILTER_CHUNK_SIZE) { + chunks.push(uniqueIds.slice(i, i + IN_FILTER_CHUNK_SIZE)); + } + + const results = await Promise.all( + chunks.map((chunk) => this.all( + {}, + { where: (attrs, op) => op.in(attrs.ticketId, chunk) }, + )), ); + + return results.flat(); } catch (error) { if (error instanceof DataAccessError) { throw error; diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js index 95d806add..06dd3f783 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.collection.test.js @@ -13,9 +13,9 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; -import { stub } from 'sinon'; +import sinon from 'sinon'; -import { DataAccessError } from '../../../../src/errors/index.js'; +import { DataAccessError, ValidationError } from '../../../../src/errors/index.js'; import { createElectroMocks } from '../../util.js'; import TicketSuggestion from '../../../../src/models/ticket-suggestion/ticket-suggestion.model.js'; @@ -36,25 +36,26 @@ const MOCK_RECORD = { describe('TicketSuggestionCollection', () => { let instance; + const sandbox = sinon.createSandbox(); beforeEach(() => { ({ collection: instance } = createElectroMocks(TicketSuggestion, MOCK_RECORD)); }); - describe('allBySuggestionIds()', () => { - it('returns empty array for empty input without querying', async () => { - instance.all = stub(); - - const result = await instance.allBySuggestionIds([]); + afterEach(() => { + sandbox.restore(); + }); - expect(result).to.deep.equal([]); - expect(instance.all).to.not.have.been.called; + describe('allBySuggestionIds()', () => { + it('throws ValidationError for non-array input', async () => { + await expect(instance.allBySuggestionIds(null)) + .to.be.rejectedWith(ValidationError); }); - it('returns empty array for non-array input without querying', async () => { - instance.all = stub(); + it('returns empty array for empty input without querying', async () => { + instance.all = sandbox.stub(); - const result = await instance.allBySuggestionIds(null); + const result = await instance.allBySuggestionIds([]); expect(result).to.deep.equal([]); expect(instance.all).to.not.have.been.called; @@ -62,7 +63,7 @@ describe('TicketSuggestionCollection', () => { it('delegates to this.all() with correct where clause', async () => { const mockResults = [{ getSuggestionId: () => SUGGESTION_ID }]; - instance.all = stub().resolves(mockResults); + instance.all = sandbox.stub().resolves(mockResults); const result = await instance.allBySuggestionIds([SUGGESTION_ID]); @@ -72,14 +73,35 @@ describe('TicketSuggestionCollection', () => { const [sortKeys, options] = instance.all.firstCall.args; expect(sortKeys).to.deep.equal({}); - const opStub = { in: stub().returns('IN_EXPR') }; + const opStub = { in: sandbox.stub().returns('IN_EXPR') }; const expression = options.where({ suggestionId: 'suggestionId' }, opStub); expect(opStub.in).to.have.been.calledOnceWith('suggestionId', [SUGGESTION_ID]); expect(expression).to.equal('IN_EXPR'); }); + it('deduplicates ids before querying', async () => { + instance.all = sandbox.stub().resolves([]); + + await instance.allBySuggestionIds([SUGGESTION_ID, SUGGESTION_ID]); + + expect(instance.all).to.have.been.calledOnce; + const [, options] = instance.all.firstCall.args; + const opStub = { in: sandbox.stub().returns('IN_EXPR') }; + options.where({ suggestionId: 'suggestionId' }, opStub); + expect(opStub.in.firstCall.args[1]).to.deep.equal([SUGGESTION_ID]); + }); + + it('chunks large id arrays into batches of 50', async () => { + const ids = Array.from({ length: 75 }, (_, i) => `id-${i}`); + instance.all = sandbox.stub().resolves([]); + + await instance.allBySuggestionIds(ids); + + expect(instance.all).to.have.been.calledTwice; + }); + it('throws DataAccessError when this.all() rejects', async () => { - instance.all = stub().rejects(new Error('DB error')); + instance.all = sandbox.stub().rejects(new Error('DB error')); await expect(instance.allBySuggestionIds([SUGGESTION_ID])) .to.be.rejectedWith(DataAccessError, 'Failed to load ticket suggestions by suggestion IDs'); @@ -87,7 +109,7 @@ describe('TicketSuggestionCollection', () => { it('re-throws DataAccessError without wrapping', async () => { const inner = new DataAccessError('inner failure'); - instance.all = stub().rejects(inner); + instance.all = sandbox.stub().rejects(inner); try { await instance.allBySuggestionIds([SUGGESTION_ID]); @@ -99,19 +121,15 @@ describe('TicketSuggestionCollection', () => { }); describe('allByTicketIds()', () => { - it('returns empty array for empty input without querying', async () => { - instance.all = stub(); - - const result = await instance.allByTicketIds([]); - - expect(result).to.deep.equal([]); - expect(instance.all).to.not.have.been.called; + it('throws ValidationError for non-array input', async () => { + await expect(instance.allByTicketIds(null)) + .to.be.rejectedWith(ValidationError); }); - it('returns empty array for non-array input without querying', async () => { - instance.all = stub(); + it('returns empty array for empty input without querying', async () => { + instance.all = sandbox.stub(); - const result = await instance.allByTicketIds(null); + const result = await instance.allByTicketIds([]); expect(result).to.deep.equal([]); expect(instance.all).to.not.have.been.called; @@ -119,7 +137,7 @@ describe('TicketSuggestionCollection', () => { it('delegates to this.all() with correct where clause', async () => { const mockResults = [{ getTicketId: () => TICKET_ID }]; - instance.all = stub().resolves(mockResults); + instance.all = sandbox.stub().resolves(mockResults); const result = await instance.allByTicketIds([TICKET_ID]); @@ -129,14 +147,35 @@ describe('TicketSuggestionCollection', () => { const [sortKeys, options] = instance.all.firstCall.args; expect(sortKeys).to.deep.equal({}); - const opStub = { in: stub().returns('IN_EXPR') }; + const opStub = { in: sandbox.stub().returns('IN_EXPR') }; const expression = options.where({ ticketId: 'ticketId' }, opStub); expect(opStub.in).to.have.been.calledOnceWith('ticketId', [TICKET_ID]); expect(expression).to.equal('IN_EXPR'); }); + it('deduplicates ids before querying', async () => { + instance.all = sandbox.stub().resolves([]); + + await instance.allByTicketIds([TICKET_ID, TICKET_ID]); + + expect(instance.all).to.have.been.calledOnce; + const [, options] = instance.all.firstCall.args; + const opStub = { in: sandbox.stub().returns('IN_EXPR') }; + options.where({ ticketId: 'ticketId' }, opStub); + expect(opStub.in.firstCall.args[1]).to.deep.equal([TICKET_ID]); + }); + + it('chunks large id arrays into batches of 50', async () => { + const ids = Array.from({ length: 75 }, (_, i) => `id-${i}`); + instance.all = sandbox.stub().resolves([]); + + await instance.allByTicketIds(ids); + + expect(instance.all).to.have.been.calledTwice; + }); + it('throws DataAccessError when this.all() rejects', async () => { - instance.all = stub().rejects(new Error('DB error')); + instance.all = sandbox.stub().rejects(new Error('DB error')); await expect(instance.allByTicketIds([TICKET_ID])) .to.be.rejectedWith(DataAccessError, 'Failed to load ticket suggestions by ticket IDs'); @@ -144,7 +183,7 @@ describe('TicketSuggestionCollection', () => { it('re-throws DataAccessError without wrapping', async () => { const inner = new DataAccessError('inner failure'); - instance.all = stub().rejects(inner); + instance.all = sandbox.stub().rejects(inner); try { await instance.allByTicketIds([TICKET_ID]);