diff --git a/src/utils/collection.js b/src/utils/collection.js index ef48dd3c..24d6a838 100644 --- a/src/utils/collection.js +++ b/src/utils/collection.js @@ -1,30 +1,103 @@ /** - * Create a Collection object from a response containing a list of resources. - * - * @param {Object} response_and_data - * @param {ApiClient} apiClient - * @param {Object} apiRequestData - * @returns {Object} Collection + * @fileoverview Enhanced Collection utility for paginated Asana API responses. + * + * This module provides a powerful abstraction over paginated API responses with: + * - ES6+ async iteration support (for-await-of) + * - Functional programming helpers (map, filter, reduce, find) + * - Memory-efficient streaming capabilities + * - Progress tracking for large datasets + * + * @module utils/Collection + * @version 3.1.5 + * @author Asana SDK Contributors + * @license Apache-2.0 + */ + +'use strict'; + +/** + * @typedef {Object} CollectionOptions + * @property {number} [pageSize] - Maximum items per page (1-100) + * @property {function(PageInfo): void} [onPage] - Callback invoked after each page fetch + */ + +/** + * @typedef {Object} PageInfo + * @property {number} pageNumber - Current page number (1-indexed) + * @property {number} itemsInPage - Number of items in current page + * @property {number} totalItemsFetched - Running total of all items fetched + * @property {boolean} hasNextPage - Whether more pages exist + */ + +/** + * Creates a Collection object from a response containing a list of resources. + * + * Collection provides an ergonomic interface for working with paginated Asana API + * responses, supporting both traditional promise-based iteration and modern + * async iteration patterns. + * + * @class Collection + * @param {Object} response_and_data - Raw API response containing data and metadata + * @param {ApiClient} apiClient - The API client instance for making subsequent requests + * @param {Object} apiRequestData - Original request parameters for pagination + * @throws {Error} If response does not contain a valid collection + * + * @example + * // Basic usage with nextPage() + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 50 }); + * let page = tasks; + * while (page.data) { + * console.log(page.data); + * page = await page.nextPage(); + * } + * + * @example + * // Modern async iteration + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 50 }); + * for await (const task of tasks) { + * console.log(task.name); + * } + * + * @example + * // Functional helpers + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * const completedTasks = await tasks.filter(task => task.completed); + * const taskNames = await tasks.map(task => task.name); */ function Collection(response_and_data, apiClient, apiRequestData) { if (!Collection.isCollectionResponse(response_and_data.data.data)) { - throw new Error( - 'Cannot create Collection from response that does not have resources'); + throw new Error( + 'Cannot create Collection from response that does not have resources' + ); } - this.data = response_and_data.data.data; // return the contents inside of the "data" key that Asana API returns + /** @type {Array} The current page of data items */ + this.data = response_and_data.data.data; + + /** @type {Object} The full API response including metadata */ this._response = response_and_data.data; + + /** @type {ApiClient} Reference to the API client for pagination */ this._apiClient = apiClient; + + /** @type {Object} Original request data for fetching subsequent pages */ this._apiRequestData = apiRequestData; + + /** @type {number} Running count of items fetched across all pages */ + this._totalFetched = this.data.length; + + /** @type {number} Current page number (1-indexed) */ + this._pageNumber = 1; } /** * Transforms a Promise of a raw response into a Promise for a Collection. - * - * @param {Promise} promise - * @param {ApiClient} apiClient - * @param {Object} apiRequestData - * @returns {Promise} + * + * @static + * @param {Promise} promise - Promise resolving to API response + * @param {ApiClient} apiClient - The API client instance + * @param {Object} apiRequestData - Original request parameters + * @returns {Promise} Promise resolving to a Collection instance */ Collection.fromApiClient = function(promise, apiClient, apiRequestData) { return promise.then(function(response_and_data) { @@ -33,48 +106,692 @@ Collection.fromApiClient = function(promise, apiClient, apiRequestData) { }; /** - * @param response {Object} Response that a request promise resolved to - * @returns {boolean} True iff the response is a collection (possibly empty) + * Determines if a response represents a collection (array of resources). + * + * @static + * @param {*} responseData - Response data to check + * @returns {boolean} True if the response is a valid collection */ Collection.isCollectionResponse = function(responseData) { - return typeof(responseData) === 'object' && - typeof(responseData) === 'object' && - typeof(responseData.length) === 'number'; + return typeof responseData === 'object' && + responseData !== null && + typeof responseData.length === 'number'; }; -module.exports = Collection; - /** - * Get the next page of results in a collection. - * - * @returns {Promise} Resolves to either a collection representing - * the next page of results, or null if no more pages. + * Fetches the next page of results in the collection. + * + * @method nextPage + * @memberof Collection + * @instance + * @returns {Promise} Resolves to a new Collection for + * the next page, or an object with `data: null` if no more pages exist + * + * @example + * let page = await tasksApi.getTasks({ limit: 10 }); + * while (page.data) { + * page.data.forEach(task => console.log(task.name)); + * page = await page.nextPage(); + * } */ Collection.prototype.nextPage = function() { - /* jshint camelcase:false */ var me = this; var next = me._response.next_page; var apiRequestData = me._apiRequestData; - if (typeof(next) === 'object' && next !== null && me.data && me.data.length > 0) { + + if (typeof next === 'object' && next !== null && me.data && me.data.length > 0) { apiRequestData.queryParams['offset'] = next.offset; - return Collection.fromApiClient( - me._apiClient.callApi( - apiRequestData.path, - apiRequestData.httpMethod, - apiRequestData.pathParams, - apiRequestData.queryParams, - apiRequestData.headerParams, - apiRequestData.formParams, - apiRequestData.bodyParam, - apiRequestData.authNames, - apiRequestData.contentTypes, - apiRequestData.accepts, - apiRequestData.returnType - ), - me._apiClient, - me._apiRequestData); + + return me._apiClient.callApi( + apiRequestData.path, + apiRequestData.httpMethod, + apiRequestData.pathParams, + apiRequestData.queryParams, + apiRequestData.headerParams, + apiRequestData.formParams, + apiRequestData.bodyParam, + apiRequestData.authNames, + apiRequestData.contentTypes, + apiRequestData.accepts, + apiRequestData.returnType + ).then(function(response_and_data) { + var collection = new Collection(response_and_data, me._apiClient, me._apiRequestData); + collection._totalFetched = me._totalFetched + collection.data.length; + collection._pageNumber = me._pageNumber + 1; + return collection; + }); } else { - // No more results. - return Promise.resolve({"data": null}); + return Promise.resolve({ data: null }); + } +}; + +/** + * Checks if there are more pages available to fetch. + * + * @method hasNextPage + * @memberof Collection + * @instance + * @returns {boolean} True if more pages exist + * + * @example + * const tasks = await tasksApi.getTasks({ limit: 10 }); + * if (tasks.hasNextPage()) { + * const moreTasks = await tasks.nextPage(); + * } + */ +Collection.prototype.hasNextPage = function() { + var next = this._response.next_page; + return typeof next === 'object' && + next !== null && + this.data && + this.data.length > 0; +}; + +/** + * Returns the current page number (1-indexed). + * + * @method getPageNumber + * @memberof Collection + * @instance + * @returns {number} Current page number + */ +Collection.prototype.getPageNumber = function() { + return this._pageNumber; +}; + +/** + * Returns the total number of items fetched so far across all pages. + * + * @method getTotalFetched + * @memberof Collection + * @instance + * @returns {number} Total items fetched + */ +Collection.prototype.getTotalFetched = function() { + return this._totalFetched; +}; + +/** + * Makes Collection iterable with for-await-of syntax. + * Iterates through all items across all pages automatically. + * + * This is the recommended way to process large datasets as it: + * - Automatically handles pagination + * - Processes items as they arrive (memory efficient) + * - Supports early termination with `break` + * + * @method [Symbol.asyncIterator] + * @memberof Collection + * @instance + * @yields {Object} Individual resource items from all pages + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * // Process all tasks across all pages + * for await (const task of tasks) { + * console.log(`Task: ${task.name}`); + * + * // Early termination is supported + * if (task.completed) break; + * } + * + * @example + * // Using with async generators + * async function* getIncompleteTasks(collection) { + * for await (const task of collection) { + * if (!task.completed) yield task; + * } + * } + */ +Collection.prototype[Symbol.asyncIterator] = function() { + var collection = this; + var currentIndex = 0; + var currentPage = collection; + + return { + async next() { + // If we've exhausted the current page's data + while (currentIndex >= currentPage.data.length) { + // Try to get the next page + if (!currentPage.hasNextPage()) { + return { done: true, value: undefined }; + } + + currentPage = await currentPage.nextPage(); + + // Handle case where nextPage returns { data: null } + if (!currentPage.data) { + return { done: true, value: undefined }; + } + + currentIndex = 0; + } + + // Return the next item + var value = currentPage.data[currentIndex]; + currentIndex++; + return { done: false, value: value }; + } + }; +}; + +/** + * Collects all items from all pages into a single array. + * + * ⚠️ Warning: Use with caution on large datasets as this loads + * all items into memory. For large datasets, prefer using + * async iteration or the streaming methods. + * + * @method toArray + * @memberof Collection + * @instance + * @param {CollectionOptions} [options] - Configuration options + * @param {function(PageInfo): void} [options.onPage] - Callback after each page + * @returns {Promise>} All items from all pages + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * const allTasks = await tasks.toArray(); + * console.log(`Total tasks: ${allTasks.length}`); + * + * @example + * // With progress tracking + * const allTasks = await tasks.toArray({ + * onPage: (info) => { + * console.log(`Page ${info.pageNumber}: ${info.totalItemsFetched} items so far`); + * } + * }); + */ +Collection.prototype.toArray = async function(options) { + options = options || {}; + var result = []; + var page = this; + var pageNumber = 0; + + while (page.data) { + pageNumber++; + result = result.concat(page.data); + + if (options.onPage) { + options.onPage({ + pageNumber: pageNumber, + itemsInPage: page.data.length, + totalItemsFetched: result.length, + hasNextPage: page.hasNextPage() + }); + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + return result; +}; + +/** + * Executes a callback for each item across all pages. + * More memory efficient than toArray() for side-effect operations. + * + * @method forEach + * @memberof Collection + * @instance + * @param {function(Object, number): void|Promise} callback - Function to execute + * for each item. Receives (item, index). If it returns a promise, iteration + * waits for it to resolve. + * @param {CollectionOptions} [options] - Configuration options + * @returns {Promise} Total number of items processed + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * const count = await tasks.forEach(async (task, index) => { + * console.log(`${index + 1}. ${task.name}`); + * await processTask(task); + * }); + * + * console.log(`Processed ${count} tasks`); + */ +Collection.prototype.forEach = async function(callback, options) { + options = options || {}; + var index = 0; + var page = this; + var pageNumber = 0; + + while (page.data) { + pageNumber++; + + for (var i = 0; i < page.data.length; i++) { + await callback(page.data[i], index); + index++; + } + + if (options.onPage) { + options.onPage({ + pageNumber: pageNumber, + itemsInPage: page.data.length, + totalItemsFetched: index, + hasNextPage: page.hasNextPage() + }); + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + return index; +}; + +/** + * Maps each item across all pages using a transform function. + * + * @method map + * @memberof Collection + * @instance + * @param {function(Object, number): *|Promise<*>} transform - Function to transform + * each item. Receives (item, index). Can be async. + * @param {CollectionOptions} [options] - Configuration options + * @returns {Promise>} Array of transformed items + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * const taskSummaries = await tasks.map(task => ({ + * id: task.gid, + * name: task.name, + * done: task.completed + * })); + * + * @example + * // Async transform + * const enrichedTasks = await tasks.map(async (task) => { + * const details = await fetchExternalDetails(task.gid); + * return { ...task, details }; + * }); + */ +Collection.prototype.map = async function(transform, options) { + options = options || {}; + var result = []; + var index = 0; + var page = this; + var pageNumber = 0; + + while (page.data) { + pageNumber++; + + for (var i = 0; i < page.data.length; i++) { + var transformed = await transform(page.data[i], index); + result.push(transformed); + index++; + } + + if (options.onPage) { + options.onPage({ + pageNumber: pageNumber, + itemsInPage: page.data.length, + totalItemsFetched: index, + hasNextPage: page.hasNextPage() + }); + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); } + + return result; }; + +/** + * Filters items across all pages using a predicate function. + * + * @method filter + * @memberof Collection + * @instance + * @param {function(Object, number): boolean|Promise} predicate - Function + * that returns true for items to include. Receives (item, index). Can be async. + * @param {CollectionOptions} [options] - Configuration options + * @returns {Promise>} Array of items that pass the predicate + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * // Get incomplete tasks + * const incompleteTasks = await tasks.filter(task => !task.completed); + * + * // Get high-priority tasks (async predicate) + * const highPriority = await tasks.filter(async (task) => { + * const priority = await getPriority(task.gid); + * return priority === 'high'; + * }); + */ +Collection.prototype.filter = async function(predicate, options) { + options = options || {}; + var result = []; + var index = 0; + var page = this; + var pageNumber = 0; + + while (page.data) { + pageNumber++; + + for (var i = 0; i < page.data.length; i++) { + var item = page.data[i]; + var matches = await predicate(item, index); + if (matches) { + result.push(item); + } + index++; + } + + if (options.onPage) { + options.onPage({ + pageNumber: pageNumber, + itemsInPage: page.data.length, + totalItemsFetched: index, + hasNextPage: page.hasNextPage() + }); + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + return result; +}; + +/** + * Finds the first item across all pages that satisfies a predicate. + * Stops iteration as soon as a match is found (early termination). + * + * @method find + * @memberof Collection + * @instance + * @param {function(Object, number): boolean|Promise} predicate - Function + * that returns true when item is found. Receives (item, index). Can be async. + * @returns {Promise} The first matching item, or undefined + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * // Find the first incomplete task + * const firstIncomplete = await tasks.find(task => !task.completed); + * + * if (firstIncomplete) { + * console.log(`First incomplete: ${firstIncomplete.name}`); + * } + */ +Collection.prototype.find = async function(predicate) { + var index = 0; + var page = this; + + while (page.data) { + for (var i = 0; i < page.data.length; i++) { + var item = page.data[i]; + var matches = await predicate(item, index); + if (matches) { + return item; + } + index++; + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + return undefined; +}; + +/** + * Reduces all items across all pages to a single value. + * + * @method reduce + * @memberof Collection + * @instance + * @param {function(*, Object, number): *|Promise<*>} reducer - Function that + * accumulates values. Receives (accumulator, item, index). Can be async. + * @param {*} initialValue - Initial value for the accumulator + * @param {CollectionOptions} [options] - Configuration options + * @returns {Promise<*>} Final accumulated value + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * // Count completed tasks + * const completedCount = await tasks.reduce( + * (count, task) => task.completed ? count + 1 : count, + * 0 + * ); + * + * @example + * // Group tasks by assignee + * const tasksByAssignee = await tasks.reduce((groups, task) => { + * const assignee = task.assignee?.gid || 'unassigned'; + * groups[assignee] = groups[assignee] || []; + * groups[assignee].push(task); + * return groups; + * }, {}); + */ +Collection.prototype.reduce = async function(reducer, initialValue, options) { + options = options || {}; + var accumulator = initialValue; + var index = 0; + var page = this; + var pageNumber = 0; + + while (page.data) { + pageNumber++; + + for (var i = 0; i < page.data.length; i++) { + accumulator = await reducer(accumulator, page.data[i], index); + index++; + } + + if (options.onPage) { + options.onPage({ + pageNumber: pageNumber, + itemsInPage: page.data.length, + totalItemsFetched: index, + hasNextPage: page.hasNextPage() + }); + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + return accumulator; +}; + +/** + * Checks if at least one item across all pages satisfies a predicate. + * Stops iteration as soon as a match is found (early termination). + * + * @method some + * @memberof Collection + * @instance + * @param {function(Object, number): boolean|Promise} predicate - Function + * to test each item. Receives (item, index). Can be async. + * @returns {Promise} True if at least one item matches + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * const hasOverdue = await tasks.some(task => { + * return task.due_on && new Date(task.due_on) < new Date(); + * }); + * + * if (hasOverdue) { + * console.log('Warning: Project has overdue tasks!'); + * } + */ +Collection.prototype.some = async function(predicate) { + var found = await this.find(predicate); + return found !== undefined; +}; + +/** + * Checks if all items across all pages satisfy a predicate. + * Stops iteration as soon as a non-match is found (early termination). + * + * @method every + * @memberof Collection + * @instance + * @param {function(Object, number): boolean|Promise} predicate - Function + * to test each item. Receives (item, index). Can be async. + * @returns {Promise} True if all items match + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * const allAssigned = await tasks.every(task => task.assignee !== null); + * + * if (allAssigned) { + * console.log('All tasks are assigned!'); + * } + */ +Collection.prototype.every = async function(predicate) { + var index = 0; + var page = this; + + while (page.data) { + for (var i = 0; i < page.data.length; i++) { + var item = page.data[i]; + var matches = await predicate(item, index); + if (!matches) { + return false; + } + index++; + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + return true; +}; + +/** + * Takes the first N items from the collection across pages. + * More efficient than toArray() when you only need a subset. + * + * @method take + * @memberof Collection + * @instance + * @param {number} count - Maximum number of items to take + * @returns {Promise>} Array of up to `count` items + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * // Get first 5 tasks (may span multiple pages if limit is small) + * const firstFive = await tasks.take(5); + */ +Collection.prototype.take = async function(count) { + if (count <= 0) return []; + + var result = []; + var page = this; + + while (page.data && result.length < count) { + var needed = count - result.length; + var available = page.data.slice(0, needed); + result = result.concat(available); + + if (result.length >= count || !page.hasNextPage()) break; + page = await page.nextPage(); + } + + return result; +}; + +/** + * Creates a stream-like interface for processing items in batches. + * Useful for processing large datasets with controlled concurrency. + * + * @method batch + * @memberof Collection + * @instance + * @param {number} batchSize - Number of items per batch + * @param {function(Array, number): void|Promise} processor - Function + * to process each batch. Receives (items, batchIndex). Can be async. + * @param {CollectionOptions} [options] - Configuration options + * @returns {Promise} Total number of batches processed + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 100 }); + * + * // Process tasks in batches of 10 + * const batchCount = await tasks.batch(10, async (batch, batchIndex) => { + * console.log(`Processing batch ${batchIndex + 1} with ${batch.length} tasks`); + * await Promise.all(batch.map(task => processTask(task))); + * }); + * + * console.log(`Processed ${batchCount} batches`); + */ +Collection.prototype.batch = async function(batchSize, processor, options) { + options = options || {}; + var currentBatch = []; + var batchIndex = 0; + var page = this; + var pageNumber = 0; + var totalItems = 0; + + while (page.data) { + pageNumber++; + + for (var i = 0; i < page.data.length; i++) { + currentBatch.push(page.data[i]); + totalItems++; + + if (currentBatch.length >= batchSize) { + await processor(currentBatch, batchIndex); + batchIndex++; + currentBatch = []; + } + } + + if (options.onPage) { + options.onPage({ + pageNumber: pageNumber, + itemsInPage: page.data.length, + totalItemsFetched: totalItems, + hasNextPage: page.hasNextPage() + }); + } + + if (!page.hasNextPage()) break; + page = await page.nextPage(); + } + + // Process any remaining items + if (currentBatch.length > 0) { + await processor(currentBatch, batchIndex); + batchIndex++; + } + + return batchIndex; +}; + +/** + * Returns pagination metadata for the current response. + * + * @method getPageInfo + * @memberof Collection + * @instance + * @returns {PageInfo} Current page information + * + * @example + * const tasks = await tasksApi.getTasks({ project: projectGid, limit: 50 }); + * const info = tasks.getPageInfo(); + * console.log(`Page ${info.pageNumber}: ${info.itemsInPage} items, hasMore: ${info.hasNextPage}`); + */ +Collection.prototype.getPageInfo = function() { + return { + pageNumber: this._pageNumber, + itemsInPage: this.data.length, + totalItemsFetched: this._totalFetched, + hasNextPage: this.hasNextPage() + }; +}; + +module.exports = Collection; diff --git a/test/utils/collection.spec.js b/test/utils/collection.spec.js new file mode 100644 index 00000000..82d70243 --- /dev/null +++ b/test/utils/collection.spec.js @@ -0,0 +1,851 @@ +/** + * @fileoverview Comprehensive test suite for the Collection utility. + * + * Tests cover: + * - Basic functionality (construction, nextPage) + * - Async iterator support (Symbol.asyncIterator) + * - Functional helpers (map, filter, reduce, find, forEach) + * - Edge cases and error handling + * - Progress tracking and pagination metadata + * + * @module test/utils/collection + */ + +'use strict'; + +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + define(['expect.js', 'sinon', '../../src/utils/collection'], factory); + } else if (typeof module === 'object' && module.exports) { + factory(require('expect.js'), require('sinon'), require('../../src/utils/collection')); + } else { + factory(root.expect, root.sinon, root.Collection); + } +}(this, function(expect, sinon, Collection) { + + /** + * Creates a mock API client for testing pagination + */ + function createMockApiClient(pages) { + var pageIndex = 0; + + return { + callApi: sinon.stub().callsFake(function() { + pageIndex++; + if (pageIndex >= pages.length) { + return Promise.resolve({ + data: { + data: pages[pages.length - 1].data, + next_page: null + } + }); + } + return Promise.resolve({ + data: pages[pageIndex] + }); + }) + }; + } + + /** + * Creates mock response data for testing + */ + function createMockResponse(items, hasNextPage) { + return { + data: { + data: items, + next_page: hasNextPage ? { offset: 'next-offset-token' } : null + } + }; + } + + /** + * Creates mock API request data + */ + function createMockRequestData() { + return { + path: '/test', + httpMethod: 'GET', + pathParams: {}, + queryParams: { limit: 10 }, + headerParams: {}, + formParams: {}, + bodyParam: null, + authNames: ['token'], + contentTypes: [], + accepts: ['application/json'], + returnType: 'Blob' + }; + } + + describe('Collection', function() { + + describe('Constructor', function() { + it('should create a Collection from valid response', function() { + var response = createMockResponse([{ gid: '1', name: 'Task 1' }], false); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + + expect(collection.data).to.be.an('array'); + expect(collection.data.length).to.be(1); + expect(collection.data[0].name).to.be('Task 1'); + }); + + it('should throw error for non-collection response', function() { + var invalidResponse = { + data: { + data: 'not an array' + } + }; + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + expect(function() { + new Collection(invalidResponse, client, requestData); + }).to.throwError(/Cannot create Collection/); + }); + + it('should accept empty array as valid collection', function() { + var response = createMockResponse([], false); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + + expect(collection.data).to.be.an('array'); + expect(collection.data.length).to.be(0); + }); + }); + + describe('isCollectionResponse', function() { + it('should return true for array', function() { + expect(Collection.isCollectionResponse([])).to.be(true); + expect(Collection.isCollectionResponse([1, 2, 3])).to.be(true); + }); + + it('should return false for non-array', function() { + expect(Collection.isCollectionResponse(null)).to.be(false); + expect(Collection.isCollectionResponse(undefined)).to.be(false); + expect(Collection.isCollectionResponse({})).to.be(false); + expect(Collection.isCollectionResponse('string')).to.be(false); + expect(Collection.isCollectionResponse(123)).to.be(false); + }); + }); + + describe('nextPage', function() { + it('should fetch next page when available', function(done) { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + collection.nextPage().then(function(nextCollection) { + expect(nextCollection.data).to.be.an('array'); + expect(nextCollection.data[0].gid).to.be('2'); + expect(client.callApi.calledOnce).to.be(true); + done(); + }).catch(done); + }); + + it('should return { data: null } when no more pages', function(done) { + var page1 = { data: [{ gid: '1' }], next_page: null }; + + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + collection.nextPage().then(function(result) { + expect(result.data).to.be(null); + done(); + }).catch(done); + }); + + it('should track page numbers correctly', function(done) { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + expect(collection.getPageNumber()).to.be(1); + + collection.nextPage().then(function(nextCollection) { + expect(nextCollection.getPageNumber()).to.be(2); + done(); + }).catch(done); + }); + + it('should track total fetched correctly', function(done) { + var page1 = { data: [{ gid: '1' }, { gid: '2' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '3' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + expect(collection.getTotalFetched()).to.be(2); + + collection.nextPage().then(function(nextCollection) { + expect(nextCollection.getTotalFetched()).to.be(3); + done(); + }).catch(done); + }); + }); + + describe('hasNextPage', function() { + it('should return true when next_page exists', function() { + var response = createMockResponse([{ gid: '1' }], true); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + + expect(collection.hasNextPage()).to.be(true); + }); + + it('should return false when next_page is null', function() { + var response = createMockResponse([{ gid: '1' }], false); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + + expect(collection.hasNextPage()).to.be(false); + }); + + it('should return false when data is empty', function() { + var response = { + data: { + data: [], + next_page: { offset: 'token' } // Even with next_page token + } + }; + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + + expect(collection.hasNextPage()).to.be(false); + }); + }); + + describe('Symbol.asyncIterator', function() { + it('should iterate through all items in single page', async function() { + var response = createMockResponse([ + { gid: '1', name: 'Task 1' }, + { gid: '2', name: 'Task 2' } + ], false); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + var items = []; + + for await (var item of collection) { + items.push(item); + } + + expect(items.length).to.be(2); + expect(items[0].gid).to.be('1'); + expect(items[1].gid).to.be('2'); + }); + + it('should iterate through all items across multiple pages', async function() { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }, { gid: '3' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var items = []; + for await (var item of collection) { + items.push(item); + } + + expect(items.length).to.be(3); + expect(items[0].gid).to.be('1'); + expect(items[1].gid).to.be('2'); + expect(items[2].gid).to.be('3'); + }); + + it('should support early termination with break', async function() { + var page1 = { data: [{ gid: '1' }, { gid: '2' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '3' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var items = []; + for await (var item of collection) { + items.push(item); + if (items.length >= 2) break; + } + + expect(items.length).to.be(2); + // Should not have fetched page 2 + expect(client.callApi.called).to.be(false); + }); + + it('should handle empty collection', async function() { + var response = createMockResponse([], false); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + var items = []; + + for await (var item of collection) { + items.push(item); + } + + expect(items.length).to.be(0); + }); + }); + + describe('toArray', function() { + it('should collect all items from all pages', async function() { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.toArray(); + + expect(result.length).to.be(2); + expect(result[0].gid).to.be('1'); + expect(result[1].gid).to.be('2'); + }); + + it('should call onPage callback for each page', async function() { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }, { gid: '3' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var pageInfos = []; + await collection.toArray({ + onPage: function(info) { + pageInfos.push(info); + } + }); + + expect(pageInfos.length).to.be(2); + expect(pageInfos[0].pageNumber).to.be(1); + expect(pageInfos[0].itemsInPage).to.be(1); + expect(pageInfos[0].totalItemsFetched).to.be(1); + expect(pageInfos[0].hasNextPage).to.be(true); + + expect(pageInfos[1].pageNumber).to.be(2); + expect(pageInfos[1].totalItemsFetched).to.be(3); + expect(pageInfos[1].hasNextPage).to.be(false); + }); + }); + + describe('forEach', function() { + it('should execute callback for each item', async function() { + var page1 = { data: [{ gid: '1' }, { gid: '2' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var items = []; + var indices = []; + + var count = await collection.forEach(function(item, index) { + items.push(item); + indices.push(index); + }); + + expect(count).to.be(2); + expect(items.length).to.be(2); + expect(indices).to.eql([0, 1]); + }); + + it('should support async callbacks', async function() { + var page1 = { data: [{ gid: '1' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var results = []; + + await collection.forEach(async function(item) { + await new Promise(function(resolve) { setTimeout(resolve, 10); }); + results.push(item.gid); + }); + + expect(results).to.eql(['1']); + }); + }); + + describe('map', function() { + it('should transform all items', async function() { + var page1 = { data: [{ gid: '1', name: 'A' }, { gid: '2', name: 'B' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.map(function(item) { + return item.name.toLowerCase(); + }); + + expect(result).to.eql(['a', 'b']); + }); + + it('should support async transform', async function() { + var page1 = { data: [{ gid: '1' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.map(async function(item) { + await new Promise(function(resolve) { setTimeout(resolve, 10); }); + return item.gid + '-transformed'; + }); + + expect(result).to.eql(['1-transformed']); + }); + + it('should provide index to transform function', async function() { + var page1 = { data: [{ gid: 'a' }, { gid: 'b' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.map(function(item, index) { + return index + ':' + item.gid; + }); + + expect(result).to.eql(['0:a', '1:b']); + }); + }); + + describe('filter', function() { + it('should filter items based on predicate', async function() { + var page1 = { + data: [ + { gid: '1', completed: true }, + { gid: '2', completed: false }, + { gid: '3', completed: true } + ], + next_page: null + }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.filter(function(item) { + return item.completed; + }); + + expect(result.length).to.be(2); + expect(result[0].gid).to.be('1'); + expect(result[1].gid).to.be('3'); + }); + + it('should filter across multiple pages', async function() { + var page1 = { data: [{ gid: '1', v: 10 }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2', v: 5 }, { gid: '3', v: 15 }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.filter(function(item) { + return item.v >= 10; + }); + + expect(result.length).to.be(2); + expect(result[0].gid).to.be('1'); + expect(result[1].gid).to.be('3'); + }); + + it('should support async predicate', async function() { + var page1 = { data: [{ gid: '1' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.filter(async function(item) { + await new Promise(function(resolve) { setTimeout(resolve, 10); }); + return true; + }); + + expect(result.length).to.be(1); + }); + }); + + describe('find', function() { + it('should return first matching item', async function() { + var page1 = { + data: [ + { gid: '1', name: 'A' }, + { gid: '2', name: 'B' }, + { gid: '3', name: 'B' } + ], + next_page: null + }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.find(function(item) { + return item.name === 'B'; + }); + + expect(result.gid).to.be('2'); + }); + + it('should return undefined when no match', async function() { + var page1 = { data: [{ gid: '1' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.find(function(item) { + return item.gid === 'nonexistent'; + }); + + expect(result).to.be(undefined); + }); + + it('should stop fetching pages once found (early termination)', async function() { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.find(function(item) { + return item.gid === '1'; + }); + + expect(result.gid).to.be('1'); + // Should not have fetched page 2 + expect(client.callApi.called).to.be(false); + }); + + it('should search across pages if needed', async function() { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.find(function(item) { + return item.gid === '2'; + }); + + expect(result.gid).to.be('2'); + expect(client.callApi.calledOnce).to.be(true); + }); + }); + + describe('reduce', function() { + it('should reduce items to single value', async function() { + var page1 = { data: [{ v: 1 }, { v: 2 }, { v: 3 }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.reduce(function(sum, item) { + return sum + item.v; + }, 0); + + expect(result).to.be(6); + }); + + it('should reduce across multiple pages', async function() { + var page1 = { data: [{ v: 1 }], next_page: { offset: 'token' } }; + var page2 = { data: [{ v: 2 }, { v: 3 }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.reduce(function(sum, item) { + return sum + item.v; + }, 10); + + expect(result).to.be(16); // 10 + 1 + 2 + 3 + }); + + it('should support object accumulator', async function() { + var page1 = { + data: [ + { gid: '1', type: 'a' }, + { gid: '2', type: 'b' }, + { gid: '3', type: 'a' } + ], + next_page: null + }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.reduce(function(groups, item) { + groups[item.type] = groups[item.type] || []; + groups[item.type].push(item.gid); + return groups; + }, {}); + + expect(result).to.eql({ a: ['1', '3'], b: ['2'] }); + }); + }); + + describe('some', function() { + it('should return true if any item matches', async function() { + var page1 = { data: [{ completed: false }, { completed: true }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.some(function(item) { + return item.completed; + }); + + expect(result).to.be(true); + }); + + it('should return false if no items match', async function() { + var page1 = { data: [{ completed: false }, { completed: false }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.some(function(item) { + return item.completed; + }); + + expect(result).to.be(false); + }); + + it('should use early termination', async function() { + var page1 = { data: [{ v: 1 }], next_page: { offset: 'token' } }; + var page2 = { data: [{ v: 2 }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + await collection.some(function(item) { + return item.v === 1; + }); + + // Should not fetch page 2 + expect(client.callApi.called).to.be(false); + }); + }); + + describe('every', function() { + it('should return true if all items match', async function() { + var page1 = { data: [{ v: 5 }, { v: 10 }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.every(function(item) { + return item.v >= 5; + }); + + expect(result).to.be(true); + }); + + it('should return false if any item does not match', async function() { + var page1 = { data: [{ v: 5 }, { v: 3 }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.every(function(item) { + return item.v >= 5; + }); + + expect(result).to.be(false); + }); + + it('should use early termination on first non-match', async function() { + var page1 = { data: [{ v: 1 }], next_page: { offset: 'token' } }; + var page2 = { data: [{ v: 2 }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + await collection.every(function(item) { + return item.v > 5; // First item fails + }); + + // Should not fetch page 2 + expect(client.callApi.called).to.be(false); + }); + + it('should return true for empty collection', async function() { + var page1 = { data: [], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.every(function() { + return false; + }); + + expect(result).to.be(true); + }); + }); + + describe('take', function() { + it('should return first N items', async function() { + var page1 = { data: [{ gid: '1' }, { gid: '2' }, { gid: '3' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.take(2); + + expect(result.length).to.be(2); + expect(result[0].gid).to.be('1'); + expect(result[1].gid).to.be('2'); + }); + + it('should span multiple pages if needed', async function() { + var page1 = { data: [{ gid: '1' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '2' }, { gid: '3' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.take(2); + + expect(result.length).to.be(2); + expect(result[0].gid).to.be('1'); + expect(result[1].gid).to.be('2'); + }); + + it('should return all items if count exceeds total', async function() { + var page1 = { data: [{ gid: '1' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var result = await collection.take(100); + + expect(result.length).to.be(1); + }); + + it('should return empty array for count <= 0', async function() { + var page1 = { data: [{ gid: '1' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + expect(await collection.take(0)).to.eql([]); + expect(await collection.take(-1)).to.eql([]); + }); + }); + + describe('batch', function() { + it('should process items in batches', async function() { + var page1 = { + data: [{ gid: '1' }, { gid: '2' }, { gid: '3' }, { gid: '4' }, { gid: '5' }], + next_page: null + }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var batches = []; + var batchCount = await collection.batch(2, function(batch, index) { + batches.push({ index: index, items: batch.map(function(i) { return i.gid; }) }); + }); + + expect(batchCount).to.be(3); + expect(batches.length).to.be(3); + expect(batches[0]).to.eql({ index: 0, items: ['1', '2'] }); + expect(batches[1]).to.eql({ index: 1, items: ['3', '4'] }); + expect(batches[2]).to.eql({ index: 2, items: ['5'] }); + }); + + it('should work across multiple pages', async function() { + var page1 = { data: [{ gid: '1' }, { gid: '2' }], next_page: { offset: 'token' } }; + var page2 = { data: [{ gid: '3' }], next_page: null }; + + var client = createMockApiClient([page1, page2]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var batches = []; + await collection.batch(2, function(batch) { + batches.push(batch.map(function(i) { return i.gid; })); + }); + + expect(batches).to.eql([['1', '2'], ['3']]); + }); + + it('should support async processor', async function() { + var page1 = { data: [{ gid: '1' }, { gid: '2' }], next_page: null }; + var client = createMockApiClient([page1]); + var requestData = createMockRequestData(); + var collection = new Collection({ data: page1 }, client, requestData); + + var processed = []; + await collection.batch(2, async function(batch) { + await new Promise(function(resolve) { setTimeout(resolve, 10); }); + processed.push(batch.length); + }); + + expect(processed).to.eql([2]); + }); + }); + + describe('getPageInfo', function() { + it('should return correct page information', function() { + var response = createMockResponse([{ gid: '1' }, { gid: '2' }], true); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = new Collection(response, client, requestData); + var info = collection.getPageInfo(); + + expect(info.pageNumber).to.be(1); + expect(info.itemsInPage).to.be(2); + expect(info.totalItemsFetched).to.be(2); + expect(info.hasNextPage).to.be(true); + }); + }); + + describe('fromApiClient', function() { + it('should create Collection from promise', async function() { + var responsePromise = Promise.resolve({ + data: { + data: [{ gid: '1' }], + next_page: null + } + }); + var client = createMockApiClient([]); + var requestData = createMockRequestData(); + + var collection = await Collection.fromApiClient(responsePromise, client, requestData); + + expect(collection).to.be.a(Collection); + expect(collection.data.length).to.be(1); + }); + }); + }); + +})); +