Flatten threads: fix for application/json+protobuf responses

When a new message is created in a thread, the thread itself is
reloaded, but via an application/json+protobuf request (array-like data)
instead of a regular text/plain request (object-like data). Since the
code didn't work well for these types of requests, the thread didn't
fully load.

This CL fixes this issue by correctly handling application/json+protobuf
responses in the response modifiers.

An issue with the read-only interceptors has also been fixed, and tests
have been added to ensure that the array-like to object-like and
viceversa transformation functions work properly.

Bug: twpowertools:153
Change-Id: If6cd5adc67d676bf36986f325e791124fa71da51
diff --git a/src/common/protojs.js b/src/common/protojs.js
index a2e2d06..c618cec 100644
--- a/src/common/protojs.js
+++ b/src/common/protojs.js
@@ -20,15 +20,22 @@
       input = Array.from(input);
       input.shift();
     }
+
+    let object = [];
     for (let i = 0; i < input.length; ++i) {
-      input[i] = inverseCorrectArrayKeys(input[i]);
+      object[i] = inverseCorrectArrayKeys(input[i]);
     }
-    return input;
+    return object;
   }
 
   if (typeof input !== 'object' || input === null) return input;
 
-  let array = [];
+  const keys = Object.keys(input);
+  if (keys.length === 0) return [];
+
+  const maxItem = Math.max(...keys);
+  let array = Array(maxItem).fill(undefined);
+
   Object.entries(input).forEach(entry => {
     array[entry[0] - 1] = inverseCorrectArrayKeys(entry[1]);
   });
diff --git a/src/common/protojs.test.mjs b/src/common/protojs.test.mjs
new file mode 100644
index 0000000..b5ee44c
--- /dev/null
+++ b/src/common/protojs.test.mjs
@@ -0,0 +1,148 @@
+/* eslint-disable no-sparse-arrays */
+import * as protojs from './protojs.js';
+
+const objectLike1 = {
+  '2': [1, 2, 3],
+};
+
+const arrayLike1 = [
+  undefined,
+  [1, 2, 3],
+];
+
+const rObjectLike1 = [
+  ,
+  undefined,
+  [, 1, 2, 3],
+];
+
+const objectLike2 = {
+  '1': {
+    '1': 12,
+    '2': [
+      {
+        '6': [false, false, false],
+        '7': {
+          '1': false,
+          '2': 'Hola',
+        },
+      },
+      {
+        '6': [true, true, false],
+        '7': {
+          '1': false,
+          '2': 'Test',
+        },
+      },
+      {
+        '6': [],
+        '7': {
+          '1': true,
+          '2': 'Bye',
+        },
+      },
+    ],
+    '3': 1663,
+  },
+};
+
+const arrayLike2 = [[
+  12,
+  [
+    [
+      undefined, undefined, undefined, undefined, undefined,
+      [false, false, false],
+      [
+        false,
+        'Hola',
+      ]
+    ],
+    [
+      undefined, undefined, undefined, undefined, undefined,
+      [true, true, false],
+      [
+        false,
+        'Test',
+      ]
+    ],
+    [
+      undefined, undefined, undefined, undefined, undefined, [],
+      [
+        true,
+        'Bye',
+      ]
+    ],
+  ],
+  1663,
+]];
+
+const rObjectLike2 = [, [
+  ,
+  12,
+  [
+    ,
+    [
+      , undefined, undefined, undefined, undefined, undefined,
+      [, false, false, false],
+      [
+        ,
+        false,
+        'Hola',
+      ]
+    ],
+    [
+      , undefined, undefined, undefined, undefined, undefined,
+      [, true, true, false],
+      [
+        ,
+        false,
+        'Test',
+      ]
+    ],
+    [
+      , undefined, undefined, undefined, undefined, undefined, [],
+      [
+        ,
+        true,
+        'Bye',
+      ]
+    ],
+  ],
+  1663,
+]];
+
+test('can convert object-like to array-like', () => {
+  // [ object-like input, array-like verified output ]
+  const entries = [
+    [objectLike1, arrayLike1],
+    [rObjectLike1, arrayLike1],
+    [objectLike2, arrayLike2],
+    [rObjectLike2, arrayLike2],
+  ];
+
+  entries.forEach(([input, verifiedOutput]) => {
+    const converted = protojs.inverseCorrectArrayKeys(input);
+    expect(converted).toStrictEqual(verifiedOutput);
+  });
+});
+
+test('can convert array-like to object-like', () => {
+  // [ array-like input, object-like verified output ]
+  const entries = [
+    [arrayLike1, rObjectLike1],
+    [arrayLike2, rObjectLike2],
+  ];
+
+  entries.forEach(([input, verifiedOutput]) => {
+    const converted = protojs.correctArrayKeys(input);
+    expect(converted).toStrictEqual(verifiedOutput);
+  });
+});
+
+test('empty object can be converted to array-like form', () => {
+  const object = {
+    1: {},
+  };
+  const converted = protojs.inverseCorrectArrayKeys(object);
+  expect(converted).toStrictEqual([[]]);
+});
diff --git a/src/models/Message.js b/src/models/Message.js
index f5e5b37..baebd14 100644
--- a/src/models/Message.js
+++ b/src/models/Message.js
@@ -63,7 +63,7 @@
   }
 
   static mapToMessageOrGapModels(rawArray) {
-    return rawArray.map(mog => {
+    return rawArray.filter(mog => mog !== undefined).map(mog => {
       if (mog[1]) return new MessageModel(mog[1]);
       if (mog[2]) return new GapModel(mog[2]);
     });
diff --git a/src/xhrInterceptor/XHRProxy.js b/src/xhrInterceptor/XHRProxy.js
index 6254fbf..288142a 100644
--- a/src/xhrInterceptor/XHRProxy.js
+++ b/src/xhrInterceptor/XHRProxy.js
@@ -42,7 +42,7 @@
         this.xhr.addEventListener(eventName, function() {
           let p;
           if (eventName === 'load') {
-            p = classThis.responseModifier.intercept(proxyThis, this.response).then(() => {
+            p = classThis.responseModifier.intercept(proxyThis).then(() => {
               proxyThis.$responseIntercepted = true;
             });
           } else {
@@ -82,7 +82,12 @@
                 utils.matchInterceptors('response', this.$TWPTRequestURL);
             if (interceptors.length > 0) {
               this.xhr.addEventListener('load', function() {
-                var body = utils.getResponseJSON(proxyThis);
+                var body = utils.getResponseJSON({
+                  responseType: proxyThis.xhr.responseType,
+                  response: proxyThis.xhr.response,
+                  $TWPTRequestURL: proxyThis.$TWPTRequestURL,
+                  $isArrayProto: proxyThis.$isArrayProto,
+                });
                 if (body !== undefined)
                   interceptors.forEach(i => {
                     utils.triggerEvent(i.eventName, body, proxyThis.$TWPTID);
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
index 6828772..23db6eb 100644
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -71,13 +71,13 @@
     }
 
     let prevId;
-    if (parentId === prevReplyParentId && prevReplyParentId)
+    if (parentId == prevReplyParentId && prevReplyParentId)
       prevId = prevReplyId;
     else
       prevId = parentId;
 
     const prevMessage =
-        prevId ? mogs.find(m => m.getId() === prevId) : null;
+        prevId ? mogs.find(m => m.getId() == prevId) : null;
 
     return {
       isComment: true,
@@ -86,8 +86,8 @@
       parentId,
       prevMessage: {
         id: prevId,
-        payload: prevMessage.getPayload(),
-        author: prevMessage.getAuthor(),
+        payload: prevMessage?.getPayload(),
+        author: prevMessage?.getAuthor(),
       },
     };
   }
diff --git a/src/xhrInterceptor/responseModifiers/index.js b/src/xhrInterceptor/responseModifiers/index.js
index 2d0b7ce..43ffe6c 100644
--- a/src/xhrInterceptor/responseModifiers/index.js
+++ b/src/xhrInterceptor/responseModifiers/index.js
@@ -54,11 +54,11 @@
     });
   }
 
-  async intercept(request, response) {
+  async intercept(request) {
     const matchingModifiers = await this.#getMatchingModifiers(request);
 
     // If we didn't find any matching modifiers, return the response right away.
-    if (matchingModifiers.length === 0) return response;
+    if (matchingModifiers.length === 0) return request.xhr.response;
 
     // Otherwise, apply the modifiers sequentially and set the new response.
     let json = getResponseJSON({
@@ -70,7 +70,7 @@
     for (const modifier of matchingModifiers) {
       json = await modifier.interceptor(request, json);
     }
-    response = convertJSONToResponse(request, json);
+    const response = convertJSONToResponse(request, json);
     request.$newResponse = response;
     request.$responseModified = true;
   }
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
index f8da127..abea505 100644
--- a/src/xhrInterceptor/responseModifiers/loadMoreThread.js
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
@@ -38,8 +38,9 @@
     }
 
     const messageOrGapPromises = [];
-    messageOrGapPromises.push(Promise.resolve(mogs));
-    for (const mog of mogs) {
+    messageOrGapPromises.push(
+        Promise.resolve(mogs.filter(mog => mog !== undefined)));
+    mogs.forEach(mog => {
       if (mog instanceof GapModel) {
         messageOrGapPromises.push(this.loadGap(forumId, threadId, mog));
       }
@@ -50,7 +51,7 @@
           }
         });
       }
-    }
+    });
 
     return Promise.all(messageOrGapPromises).then(res => {
       // #!if !production
@@ -60,6 +61,7 @@
       // #!if !production
       console.timeEnd('mergeMessages');
       // #!endif
+
       if (mogs.some(mog => {
             return mog instanceof GapModel ||
                 mog.getCommentsAndGaps().some(cog => cog instanceof GapModel);