Repository: scimmyjs/scimmy
Pull request #67 (2025-01-15 03:02:54)

Fix comparison of multivalue attributes targeted by sortBy parameter in `SCIMMY.Messages.ListResponse` constructor
Currently, when the `sortBy` parameter of the `ListResponse` message constructor targets a multi-value attribute, the built-in sort functionality will attempt to compare nonexistent values, and no sorting will occur.

This change updates the comparison logic to correctly target either the `value` attribute of the entry marked as primary, the `value` attribute of the first entry, or the value of the first entry directly. Documentation has also been updated to make the ListResponse class generic, allowing resources to be typed in TypeScript, and to add additional remarks explaining internal usage locations. Test fixtures have also been updated to add missing coverage to the ListResponse class.


Patch:
diff --git a/src/lib/messages/listresponse.js b/src/lib/messages/listresponse.js
index be6d3ca..3797938 100644
--- a/src/lib/messages/listresponse.js
+++ b/src/lib/messages/listresponse.js
@@ -1,8 +1,17 @@
 /**
  * SCIM List Response Message
  * @alias SCIMMY.Messages.ListResponse
+ * @template {SCIMMY.Types.Schema} [T=*] - type of schema instance that will be passed to handlers
  * @summary
  * *   Formats supplied service provider resources as [ListResponse messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2), handling pagination and sort when required.
+ * @description
+ * The `ListResponse` class creates a SCIM [ListResponse message](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2) for a supplied set of resources, and automates pagination and sorting of the included set.
+ * It is used internally by the `read()` method in each of {@link SCIMMY.Resources|SCIMMY's Resource classes} when requesting a list of resources matching a given query,
+ * and in the {@link SCIMMY.Messages.SearchRequest#apply|`apply()`} instance method of the {@link SCIMMY.Messages.SearchRequest|SearchRequest} message class.
+ * 
+ * > **Tip:**  
+ * > When using the {@link SCIMMY.Resources.User|`User`} and {@link SCIMMY.Resources.Group|`Group`} resource classes, their instance `read()` methods will wrap the set of matching query results in a `ListResponse` instance.
+ * > This happens automatically when retrieving multiple resources, meaning their {@link SCIMMY.Types.Resource~EgressHandler|egress handlers} need only return the array of matching resources, not a `ListResponse` instance of their own.
  */
 export class ListResponse {
     /**
@@ -23,11 +32,11 @@ export class ListResponse {
     
     /**
      * Instantiate a new SCIM List Response Message with relevant details
-     * @param {Object|SCIMMY.Types.Schema[]} request - contents of the ListResponse message, or items to include in the list response
+     * @param {SCIMMY.Messages.ListResponse<T>|T[]} request - contents of the ListResponse message, or items to include in the list response
      * @param {SCIMMY.Messages.ListResponse~ListConstraints} [params] - parameters for the list response (i.e. sort details, start index, and items per page)
      * @param {Number} [params.count=20] - alias property for itemsPerPage, used only if itemsPerPage is unset
      * @param {Number} [params.itemsPerPage=20] - maximum number of items returned in this list response
-     * @property {Array<Object|SCIMMY.Types.Schema>} Resources - resources included in the list response
+     * @property {Array<T>} Resources - resources included in the list response
      * @property {Number} totalResults - the total number of resources matching a given request
      * @property {Number} startIndex - index within total results that included resources start from
      * @property {Number} itemsPerPage - maximum number of items returned in this list response
@@ -36,7 +45,7 @@ export class ListResponse {
         const outbound = Array.isArray(request);
         const resources = (outbound ? request : request?.Resources ?? []);
         const totalResults = (outbound ? resources.totalResults ?? resources.length : request.totalResults);
-        const {sortBy, sortOrder = "ascending"} = params ?? {};
+        const {sortBy, sortOrder = "ascending"} = (outbound ? params : {});
         const {startIndex = 1, count = 20, itemsPerPage = count} = (outbound ? params : request);
         
         // Verify the ListResponse contents are valid
@@ -70,8 +79,9 @@ export class ListResponse {
             // Do the sort!
             this.Resources = this.Resources.sort((a, b) => {
                 // Resolve target sort values for each side of the comparison (either the "primary" entry, or first entry, in a multi-valued attribute, or the target value)
-                const ta = paths.reduce((res = {}, path = "") => ((!Array.isArray(res[path]) ? res[path] : (res[path].find(v => !!v.primary) ?? res[0])?.value) ?? ""), a);
-                const tb = paths.reduce((res = {}, path = "") => ((!Array.isArray(res[path]) ? res[path] : (res[path].find(v => !!v.primary) ?? res[0])?.value) ?? ""), b);
+                const reducer = (res = {}, path = "") => ((!Array.isArray(res[path]) ? res[path] : (res[path].find(v => !!v.primary) ?? res[path][0])?.value ?? res[path][0]));
+                const ta = paths.reduce(reducer, a);
+                const tb = paths.reduce(reducer, b);
                 const list = [ta, tb];
                 
                 // If some or all of the targets are unspecified, sort specified value above unspecified value


Test patch:
diff --git a/test/lib/messages/listresponse.js b/test/lib/messages/listresponse.js
index 47e44d1..8137f34 100644
--- a/test/lib/messages/listresponse.js
+++ b/test/lib/messages/listresponse.js
@@ -54,8 +54,11 @@ describe("SCIMMY.Messages.ListResponse", () => {
         });
         
         it("should ignore 'sortOrder' parameter if 'sortBy' parameter is not defined", () => {
-            assert.doesNotThrow(() => new ListResponse([], {sortOrder: "a string"}), 
-                "ListResponse did not ignore invalid 'sortOrder' parameter when 'sortBy' parameter was not defined");
+            try {
+                new ListResponse([], {sortOrder: "a string"});
+            } catch (ex) {
+                assert.fail(`ListResponse did not ignore invalid 'sortOrder' parameter when 'sortBy' parameter was not defined\r\n[cause]: ${ex}`);
+            }
         });
         
         it("should expect 'sortOrder' parameter to be either 'ascending' or 'descending' if 'sortBy' parameter is defined", () => {
@@ -96,14 +99,42 @@ describe("SCIMMY.Messages.ListResponse", () => {
                 "Instance member 'Resources' was not an array");
         });
         
-        it("should not include more resources than 'itemsPerPage' parameter", async () => {
+        it("should equal 'Resources' value included in inbound messages", async () => {
+            const {inbound: suite} = await fixtures;
+            
+            for (let fixture of suite) {
+                assert.deepStrictEqual(new ListResponse(fixture, {sortBy: "id", sortOrder: "descending"}).Resources, fixture.Resources,
+                    `Instance member 'Resources' did not equal 'Resources' value included in inbound fixture #${suite.indexOf(fixture) + 1}`);
+            }
+        });
+        
+        it("should not include more resources than 'itemsPerPage' value included in inbound messages", async () => {
+            const {outbound: {source}} = await fixtures;
+            const fixture = {...template, Resources: source};
+            
+            for (let itemsPerPage of [2, 5, 10, 200, 1]) {
+                assert.ok(new ListResponse({...fixture, itemsPerPage}).Resources.length <= itemsPerPage,
+                    "Instance member 'Resources' included more resources than specified in 'itemsPerPage' value of inbound message");
+            }
+        });
+        
+        it("should not include more resources than 'itemsPerPage' parameter when preparing outbound messages", async () => {
             const {outbound: {source}} = await fixtures;
             
-            for (let length of [2, 5, 10, 200, 1]) {
-                assert.ok((new ListResponse(source, {itemsPerPage: length})).Resources.length <= length,
-                    "Instance member 'Resources' included more resources than specified in 'itemsPerPage' parameter");
+            for (let itemsPerPage of [2, 5, 10, 200, 1]) {
+                assert.ok(new ListResponse(source, {itemsPerPage}).Resources.length <= itemsPerPage,
+                    "Instance member 'Resources' included more resources than specified in 'itemsPerPage' parameter when preparing outbound messages");
             }
         });
+        
+        it("should correctly compare Date instances at 'sortBy' path when preparing outbound messages", async () => {
+            const {outbound: {source, targets: {sortBy: suite}}} = await fixtures;
+            const {expected, sortBy} = suite.find(({sortBy}) => sortBy === "date");
+            const actual = new ListResponse(source.map(({id, date}) => ({id, date: new Date(date)})), {sortBy});
+                
+            assert.deepStrictEqual(actual.Resources.map(({id}) => id), expected,
+                "ListResponse did not correctly compare Date instances at 'sortBy' path when preparing outbound message");
+        });
     });
     
     describe("#startIndex", () => {
@@ -121,36 +152,46 @@ describe("SCIMMY.Messages.ListResponse", () => {
                 "Instance member 'startIndex' was not a positive integer");
         });
         
-        context("when parsing inbound messages", () => {
-            it("should equal 'startIndex' value included in message", async () => {
-                const {inbound: suite} = await fixtures;
-                
-                for (let fixture of suite) {
-                    assert.strictEqual((new ListResponse(fixture, {startIndex: 20})).startIndex, fixture.startIndex,
-                        `Instance member 'startIndex' did not equal 'startIndex' value included in inbound fixture #${suite.indexOf(fixture) + 1}`);
-                }
-            });
+        it("should equal 'startIndex' value included in inbound message", async () => {
+            const {inbound: suite} = await fixtures;
+            
+            for (let fixture of suite) {
+                assert.strictEqual(new ListResponse(fixture, {startIndex: 20}).startIndex, fixture.startIndex,
+                    `Instance member 'startIndex' did not equal 'startIndex' value included in inbound fixture #${suite.indexOf(fixture) + 1}`);
+            }
         });
         
-        context("when preparing outbound messages", () => {
-            it("should be honoured if 'totalResults' is less than 'itemsPerPage'", async () => {
-                const {outbound: {source}} = await fixtures;
-                
-                assert.strictEqual(new ListResponse(source, {startIndex: source.length}).Resources.length, 1,
-                    "ListResponse did not honour 'startIndex' when 'totalResults' was less than 'itemsPerPage'");
-            });
+        it("should be honoured if 'totalResults' is less than 'itemsPerPage' when preparing outbound messages", async () => {
+            const {outbound: {source}} = await fixtures;
             
-            it("should be honoured if results are not already paginated", async () => {
-                const {outbound: {source, targets: {startIndex: suite}}} = await fixtures;
+            assert.strictEqual(new ListResponse(source, {startIndex: source.length}).Resources.length, 1,
+                "ListResponse did not honour 'startIndex' when 'totalResults' was less than 'itemsPerPage'");
+        });
+        
+        it("should be honoured if results are not already paginated when preparing outbound messages", async () => {
+            const {outbound: {source, targets: {startIndex: suite}}} = await fixtures;
+            
+            for (let fixture of suite) {
+                const {expected, length = source.length, sourceRange: [from = 1, to = length] = [], startIndex, itemsPerPage} = fixture;
+                const actual = new ListResponse(Object.assign(source.slice(from-1, to), {length}), {startIndex, itemsPerPage});
                 
-                for (let fixture of suite) {
-                    const {expected, length = source.length, sourceRange: [from = 1, to = length] = [], startIndex, itemsPerPage} = fixture;
-                    const actual = new ListResponse(Object.assign(source.slice(from-1, to), {length}), {startIndex, itemsPerPage});
-                    
-                    assert.deepStrictEqual(actual.Resources.map(({id}) => id), expected,
-                        `ListResponse startIndex outbound target #${suite.indexOf(fixture)+1} did not honour 'startIndex' value '${startIndex}'`);
-                }
-            });
+                assert.deepStrictEqual(actual.Resources.map(({id}) => id), expected,
+                    `ListResponse startIndex outbound target #${suite.indexOf(fixture)+1} did not honour 'startIndex' value '${startIndex}'`);
+            }
+        });
+        
+        it("should be constrained to a minimum value of one when parsing inbound messages", async () => {
+            const {inbound: suite} = await fixtures;
+            
+            for (let fixture of suite) {
+                assert.strictEqual(new ListResponse({...fixture, startIndex: -10}).startIndex, 1,
+                    "Instance member 'startIndex' was not constrained to minimum value one when parsing inbound message");
+            }
+        });
+        
+        it("should be constrained to a minimum value of one when preparing outbound messages", () => {
+            assert.strictEqual(new ListResponse([], {startIndex: -10}).startIndex, 1,
+                "Instance member 'startIndex' was not constrained to minimum value one when preparing outbound message");
         });
     });
     
@@ -160,23 +201,57 @@ describe("SCIMMY.Messages.ListResponse", () => {
                 "Instance member 'itemsPerPage' was not defined");
         });
         
-        it("should be a positive integer", () => {
+        it("should be a non-negative integer", () => {
             const list = new ListResponse();
             
             assert.ok(typeof list.itemsPerPage === "number" && !Number.isNaN(list.itemsPerPage),
                 "Instance member 'itemsPerPage' was not a number");
-            assert.ok(list.itemsPerPage > 0 && Number.isInteger(list.itemsPerPage),
-                "Instance member 'itemsPerPage' was not a positive integer");
+            assert.ok(list.itemsPerPage >= 0 && Number.isInteger(list.itemsPerPage),
+                "Instance member 'itemsPerPage' was not a non-negative integer");
         });
         
-        it("should equal 'itemsPerPage' value included in inbound requests", async () => {
+        it("should equal 'itemsPerPage' value included in inbound messages", async () => {
             const {inbound: suite} = await fixtures;
             
             for (let fixture of suite) {
-                assert.strictEqual((new ListResponse(fixture, {itemsPerPage: 200})).itemsPerPage, fixture.itemsPerPage,
+                assert.strictEqual(new ListResponse(fixture, {itemsPerPage: 200}).itemsPerPage, fixture.itemsPerPage,
                     `Instance member 'itemsPerPage' did not equal 'itemsPerPage' value included in inbound fixture #${suite.indexOf(fixture) + 1}`);
             }
         });
+        
+        it("should equal 'itemsPerPage' parameter specified at instantiation when preparing outbound messages", () => {
+            assert.strictEqual(new ListResponse([], {itemsPerPage: 1}).itemsPerPage, 1,
+                "Instance member 'itemsPerPage' did not equal 'itemsPerPage' parameter specified at instantiation when preparing outbound messages");
+        });
+        
+        it("should equal 'count' parameter when 'itemsPerPage' parameter is not specified at instantiation", () => {
+            assert.strictEqual(new ListResponse([], {count: 1}).itemsPerPage, 1,
+                "Instance member 'itemsPerPage' did not equal 'count' parameter when 'itemsPerPage' parameter was not specified");
+        });
+        
+        it("should prefer 'itemsPerPage' parameter over 'count' parameter specified at instantiation", () => {
+            assert.strictEqual(new ListResponse([], {count: 10, itemsPerPage: 1}).itemsPerPage, 1,
+                "Instance member 'itemsPerPage' did not prefer 'itemsPerPage' parameter over 'count' parameter specified at instantiation");
+        });
+        
+        it("should fall back to the default value when 'itemsPerPage' and 'count' parameters are not specified at instantiation", () => {
+            assert.strictEqual(new ListResponse([]).itemsPerPage, 20,
+                "Instance member 'itemsPerPage' did not fall back to default value when 'itemsPerPage' and 'count' parameters were not specified");
+        });
+        
+        it("should be constrained to a minimum value of zero when parsing inbound messages", async () => {
+            const {inbound: suite} = await fixtures;
+            
+            for (let fixture of suite) {
+                assert.strictEqual(new ListResponse({...fixture, itemsPerPage: -10}).itemsPerPage, 0,
+                    "Instance member 'itemsPerPage' was not constrained to minimum value zero when parsing inbound message");
+            }
+        });
+        
+        it("should be constrained to a minimum value of zero when preparing outbound messages", () => {
+            assert.strictEqual(new ListResponse([], {itemsPerPage: -1}).itemsPerPage, 0,
+                "Instance member 'itemsPerPage' was not constrained to minimum value zero when preparing outbound message");
+        });
     });
     
     describe("#totalResults", () => {
@@ -185,22 +260,37 @@ describe("SCIMMY.Messages.ListResponse", () => {
                 "Instance member 'totalResults' was not defined");
         });
         
-        it("should be a positive integer", () => {
+        it("should be a non-negative integer", () => {
             const list = new ListResponse();
             
             assert.ok(typeof list.totalResults === "number" && !Number.isNaN(list.totalResults),
                 "Instance member 'totalResults' was not a number");
             assert.ok(list.totalResults >= 0 && Number.isInteger(list.totalResults),
-                "Instance member 'totalResults' was not a positive integer");
+                "Instance member 'totalResults' was not a non-negative integer");
         });
         
-        it("should equal 'totalResults' value included in inbound requests", async () => {
+        it("should equal 'totalResults' value included in inbound messages", async () => {
             const {inbound: suite} = await fixtures;
             
             for (let fixture of suite) {
-                assert.strictEqual((new ListResponse(fixture, {totalResults: 200})).totalResults, fixture.totalResults,
+                assert.strictEqual(new ListResponse(fixture, {totalResults: 200}).totalResults, fixture.totalResults,
                     `Instance member 'totalResults' did not equal 'totalResults' value included in inbound fixture #${suite.indexOf(fixture) + 1}`);
             }
         });
+        
+        it("should equal 'totalResults' property of resources array when preparing outbound messages", () => {
+            assert.strictEqual(new ListResponse(Object.assign([], {totalResults: 100})).totalResults, 100,
+                "Instance member 'totalResults' did not equal 'totalResults' property of resources array when preparing outbound messages");
+        });
+        
+        it("should equal 'length' property of resources array when 'totalResults' property is not specified", () => {
+            assert.strictEqual(new ListResponse(Object.assign([], {length: 100})).totalResults, 100,
+                "Instance member 'totalResults' did not equal 'length' property of resources array when 'totalResults' property was not specified");
+        });
+        
+        it("should prefer 'totalResults' property over 'length' property of resources array when preparing outbound messages", () => {
+            assert.strictEqual(new ListResponse(Object.assign([], {length: 1000, totalResults: 100})).totalResults, 100,
+                "Instance member 'totalResults' did not prefer 'totalResults' property over 'length' property of resources array when preparing outbound messages");
+        });
     });
 });
\ No newline at end of file
diff --git a/test/lib/messages/listresponse.json b/test/lib/messages/listresponse.json
index 12f6d37..12b75de 100644
--- a/test/lib/messages/listresponse.json
+++ b/test/lib/messages/listresponse.json
@@ -15,22 +15,22 @@
   ],
   "outbound": {
     "source": [
-      {"id": 1, "userName": "AdeleV", "name": {"formatted": "Adele Vance"}, "date": "2021-07-25T12:37:58.132Z", "number": 4},
-      {"id": 2, "userName": "GradyA", "name": {"formatted": "Grady Archie"}, "date": "2021-09-22T02:32:12.026Z", "number": 6},
-      {"id": 3, "userName": "LynneR", "name": {"formatted": "Lynne Robbins"}, "date": "2021-08-05T10:11:57.910Z", "number": 14},
-      {"id": 4, "userName": "MeganB", "name": {"formatted": "Megan Bowen"}, "date": "2021-09-08T23:02:28.986Z", "number": 9},
-      {"id": 5, "userName": "DiegoS", "name": {"formatted": "Diego Siciliani"}, "date": "2021-07-13T11:03:28.202Z", "number": 12},
-      {"id": 6, "userName": "PradeepG", "name": {"formatted": "Pradeep Gupta"}, "date": "2021-09-11T15:57:40.114Z", "number": 1},
-      {"id": 7, "userName": "HenriettaM", "name": {"formatted": "Henrietta Mueller"}, "date": "2021-08-25T14:00:23.925Z", "number": 10},
-      {"id": 8, "userName": "AlexW", "name": {"formatted": "Alex Wilber"}, "date": "2021-10-25T18:45:22.984Z", "number": 2},
-      {"id": 9, "userName": "NestorW", "name": {"formatted": "Nestor Wilke"}, "date": "2021-10-19T04:00:08.652Z", "number": 3},
-      {"id": 10, "userName": "PattiF", "name": {"formatted": "Patti Fernandez"}, "date": "2021-07-19T00:15:28.313Z", "number": 11},
-      {"id": 11, "userName": "MiriamG", "name": {"formatted": "Miriam Graham"}, "date": "2021-10-15T21:24:51.391Z", "number": 13},
-      {"id": 12, "userName": "LeeG", "name": {"formatted": "Lee Gu"}, "date": "2021-10-12T23:30:26.847Z", "number": 5},
-      {"id": 13, "userName": "IsaiahL", "name": {"formatted": "Isaiah Langer"}, "date": "2021-09-30T16:03:02.186Z", "number": 7},
-      {"id": 14, "userName": "JoniS", "name": {"formatted": "Joni Sherman"}, "date": "2021-10-18T04:31:09.963Z", "number": 8},
-      {"id": 15, "userName": "LidiaH", "name": {"formatted": "Lidia Holloway"}, "date": "2021-10-15T21:37:36.111Z", "number": 15},
-      {"id": 16, "userName": "JohannaL", "name": {"formatted": "Johanna Lorenz"}, "date": "2021-07-19T19:39:26.251Z", "number": 16}
+      {"id": 1, "userName": "AdeleV", "name": {"formatted": "Adele Vance"}, "date": "2021-07-25T12:37:58.132Z", "number": 4, "list": ["a", "b"], "emails": [{"value": "asdf@dsaf.org"}, {"value": "AdeleV@example.com", "primary": true}]},
+      {"id": 2, "userName": "GradyA", "name": {"formatted": "Grady Archie"}, "date": "2021-09-22T02:32:12.026Z", "number": 6, "emails": [{"value": "GradyA@example.com"}, {"value": "asdf@dsaf.com"}]},
+      {"id": 3, "userName": "LynneR", "name": {"formatted": "Lynne Robbins"}, "date": "2021-08-05T10:11:57.910Z", "number": 14, "emails": [{"value": "LynneR@example.com"}]},
+      {"id": 4, "userName": "MeganB", "name": {"formatted": "Megan Bowen"}, "date": "2021-09-08T23:02:28.986Z", "number": 9, "emails": [{"value": "MeganB@example.com"}]},
+      {"id": 5, "userName": "DiegoS", "name": {"formatted": "Diego Siciliani"}, "date": "2021-07-13T11:03:28.202Z", "number": 12, "emails": [{"value": "DiegoS@example.com", "primary": true}]},
+      {"id": 6, "userName": "PradeepG", "name": {"formatted": "Pradeep Gupta"}, "date": "2021-09-11T15:57:40.114Z", "number": 1, "emails": [{"value": "PradeepG@example.com"}]},
+      {"id": 7, "userName": "HenriettaM", "name": {"formatted": "Henrietta Mueller"}, "date": "2021-08-25T14:00:23.925Z", "number": 10, "emails": [{"value": "HenriettaM@example.com"}]},
+      {"id": 8, "userName": "AlexW", "name": {"formatted": "Alex Wilber"}, "date": "2021-10-25T18:45:22.984Z", "number": 2, "emails": [{"value": "AlexW@example.com"}]},
+      {"id": 9, "userName": "NestorW", "name": {"formatted": "Nestor Wilke"}, "date": "2021-10-19T04:00:08.652Z", "number": 3, "emails": [{"value": "NestorW@example.com"}]},
+      {"id": 10, "userName": "PattiF", "name": {"formatted": "Patti Fernandez"}, "date": "2021-07-19T00:15:28.313Z", "number": 11, "emails": [{"value": "PattiF@example.com"}]},
+      {"id": 11, "userName": "MiriamG", "name": {"formatted": "Miriam Graham"}, "date": "2021-10-15T21:24:51.391Z", "number": 13, "emails": [{"value": "MiriamG@example.com"}]},
+      {"id": 12, "userName": "LeeG", "name": {"formatted": "Lee Gu"}, "date": "2021-10-12T23:30:26.847Z", "number": 5, "emails": [{"value": "LeeG@example.com"}]},
+      {"id": 13, "userName": "IsaiahL", "name": {"formatted": "Isaiah Langer"}, "date": "2021-09-30T16:03:02.186Z", "number": 7, "emails": null},
+      {"id": 14, "userName": "JoniS", "name": {"formatted": "Joni Sherman"}, "date": "2021-10-18T04:31:09.963Z", "number": 8, "emails": null},
+      {"id": 15, "userName": "LidiaH", "name": {"formatted": "Lidia Holloway"}, "date": "2021-10-15T21:37:36.111Z", "number": 15, "list": ["d", "e"]},
+      {"id": 16, "userName": "JohannaL", "name": {"formatted": "Johanna Lorenz"}, "date": "2021-07-19T19:39:26.251Z", "number": 16, "list": ["c"]}
     ],
     "targets": {
       "sortBy": [
@@ -55,6 +55,14 @@
           "sortBy": "number",
           "sortOrder": "descending",
           "expected": [16, 15, 3, 11, 5, 10, 7, 4, 14, 13, 2, 12, 1, 9, 8, 6]
+        },
+        {
+          "sortBy": "emails",
+          "expected": [1, 8, 5, 2, 7, 12, 3, 4, 11, 9, 10, 6, 13, 14, 15, 16]
+        },
+        {
+          "sortBy": "list",
+          "expected": [1, 16, 15, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
         }
       ],
       "startIndex": [