Introduce OwnersApiCache

When Gerrit UI loads files owners status it treats each file as
an independent UI component therefore as a result plugin's REST API
endpoint is called for each one of them. The `Owned Files` tab adds an
extra call on the top of that. The same applies for getting the
currently logged in user account details.

Reduce it to a single call per REST API endpoint by OwnersApiCache
introduction (the idea is again transplanted from `code-owners` plugin).

It is a safe bet as in the event of change modification (for instance
when one votes) Gerrit doesn't modify the existing change object. It
creates new change object instead and existing
`OwnersService.getOwnersService` function (that is used to obtain the
service) ensures that new service instance gets created (together with
new instance of `OwnersApiCache`).

Caching abilities are also verified with unit tests.

Bug: Issue 377723880
Change-Id: I4b7312b644a0d2a608e4f19e23eda4d5a87f0b51
diff --git a/owners/web/owners-service.ts b/owners/web/owners-service.ts
index 7a295da..2e80458 100644
--- a/owners/web/owners-service.ts
+++ b/owners/web/owners-service.ts
@@ -118,16 +118,36 @@
   }
 }
 
-let service: OwnersService | undefined;
+/**
+ * Calls to REST API can be safely cached cuz:
+ * 1. Gerrit doesn't update the existing change object but obtains and passes new instead
+ * 2. `OwnersService.getOwnersService` guarantees that whenever new change object is retrieved the new service instance is created (and cache is created with it)
+ */
+class OwnersApiCache {
+  private loggedInUser?: Promise<User>;
 
-export class OwnersService {
-  private api: OwnersApi;
+  private filesOwners?: Promise<FilesOwners | undefined>;
 
-  constructor(readonly restApi: RestPluginApi, readonly change: ChangeInfo) {
-    this.api = new OwnersApi(restApi);
+  constructor(
+    private readonly api: OwnersApi,
+    private readonly change: ChangeInfo
+  ) {}
+
+  getLoggedInUser(): Promise<User> {
+    if (this.loggedInUser === undefined) {
+      this.loggedInUser = this.getLoggedInUserImpl();
+    }
+    return this.loggedInUser;
   }
 
-  async getLoggedInUser(): Promise<User> {
+  getFilesOwners(): Promise<FilesOwners | undefined> {
+    if (this.filesOwners === undefined) {
+      this.filesOwners = this.getFilesOwnersImpl();
+    }
+    return this.filesOwners;
+  }
+
+  private async getLoggedInUserImpl(): Promise<User> {
     const account = await this.api.getAccount();
     if (!account) {
       return {role: UserRole.ANONYMOUS} as unknown as User;
@@ -139,6 +159,28 @@
     return {account, role} as unknown as User;
   }
 
+  private async getFilesOwnersImpl(): Promise<FilesOwners | undefined> {
+    return this.api.getFilesOwners(
+      this.change.project,
+      this.change._number,
+      this.change.current_revision ?? 'current'
+    );
+  }
+}
+
+let service: OwnersService | undefined;
+
+export class OwnersService {
+  private apiCache: OwnersApiCache;
+
+  constructor(readonly restApi: RestPluginApi, readonly change: ChangeInfo) {
+    this.apiCache = new OwnersApiCache(new OwnersApi(restApi), change);
+  }
+
+  getLoggedInUser(): Promise<User> {
+    return this.apiCache.getLoggedInUser();
+  }
+
   async getAllFilesApproved(): Promise<boolean | undefined> {
     if (!(await this.isLoggedIn())) {
       return Promise.resolve(undefined);
@@ -163,12 +205,8 @@
     );
   }
 
-  async getFilesOwners(): Promise<FilesOwners | undefined> {
-    return this.api.getFilesOwners(
-      this.change.project,
-      this.change._number,
-      this.change.current_revision ?? 'current'
-    );
+  getFilesOwners(): Promise<FilesOwners | undefined> {
+    return this.apiCache.getFilesOwners();
   }
 
   private async isLoggedIn(): Promise<boolean> {
diff --git a/owners/web/owners-service_test.ts b/owners/web/owners-service_test.ts
index 1c01c07..f98fddd 100644
--- a/owners/web/owners-service_test.ts
+++ b/owners/web/owners-service_test.ts
@@ -106,6 +106,25 @@
       assert.equal(user.role, UserRole.CHANGE_OWNER);
       assert.equal(user.account, owner);
     });
+
+    test('getLoggedInUser - should fetch response from plugin only once', async () => {
+      let calls = 0;
+      const notLoggedInApi = {
+        getLoggedIn() {
+          calls++;
+          return Promise.resolve(false);
+        },
+      } as unknown as RestPluginApi;
+
+      const service = OwnersService.getOwnersService(
+        notLoggedInApi,
+        fakeChange
+      );
+
+      await service.getLoggedInUser();
+      await service.getLoggedInUser();
+      assert.equal(calls, 1);
+    });
   });
 
   suite('files owners tests', () => {
@@ -274,6 +293,18 @@
         true
       );
     });
+
+    test('should fetch response from plugin only once', async () => {
+      setupGetAllFilesApproved_true();
+
+      await service.getFilesOwners();
+      await flush();
+
+      await service.getFilesOwners();
+      await flush();
+
+      assert.equal(getApiStub.callCount, 1);
+    });
   });
 });