This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-6764-5dccac5026148f38b017b6092faf9c0d78b72261
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 768f6f609705547e26108364f79f5d994fd411f4
Author: Meng Wang <[email protected]>
AuthorDate: Tue Jul 21 22:44:06 2026 -0700

    test(frontend): cover UserService avatar/state and AuthService 
registration/auto-logout (#6764)
    
    ### What changes were proposed in this PR?
    
    Extends the two auth-layer service specs
    (`frontend/src/app/common/service/user/user.service.ts`,
    `frontend/src/app/common/service/user/auth.service.ts`) to cover the
    state,
    avatar and registration paths they had left untested. No production code
    was
    changed.
    
    **`UserService`** (+6 tests)
    
    - `changeUser` / `getCurrentUser` — sets the current user (assigning an
    `hsl(…)`
      color) and emits it on `userChanged`;
    - `isAdmin` — true only for the `ADMIN` role;
    - `getAvatar` — returns `undefined` for an empty id; returns the cached
    object URL
    while fresh; and the network path (`fetchBlob`) — wraps the fetched blob
    in an
      object URL and caches it, or returns `undefined` when the fetch fails.
      `getAvatar` / `fetchBlob` use the native `fetch`/`URL` globals (not
    `HttpClient`), so those globals are stubbed deterministically and the
    originals
      are restored in `afterEach`.
    
    **`AuthService`** (+4 tests)
    
    - `registerAutoLogout` — with `vi.useFakeTimers()`, schedules a logout
    that fires
    once the token expiry elapses, and is a no-op when the token is already
    expired
      (real timers restored in `afterEach`);
    - the invite-only inactive-user branch when registration **is** required
      (`checkRegistrationRequired` → `true`) opens the registration modal
    (`openRegistrationModal`), and `submitRegistration` PUTs the
    affiliation/reason
    when the modal is confirmed. (The registration-**not**-required branch
    is already
      covered.)
    
    ### Any related issues, documentation, discussions?
    
    Closes #6753
    
    ### How was this PR tested?
    
    Extended unit tests, run locally in `frontend/` (all green; the failure
    path was
    verified by breaking the timer and network assertions to confirm the
    suite goes
    red):
    
    ```
    ng test --watch=false --include 
src/app/common/service/user/user.service.spec.ts   # 16 passed
    ng test --watch=false --include 
src/app/common/service/user/auth.service.spec.ts   # 17 passed
    prettier --write <specs>   # clean
    eslint  <specs>            # clean
    ```
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../app/common/service/user/auth.service.spec.ts   | 97 ++++++++++++++++++++++
 .../app/common/service/user/user.service.spec.ts   | 81 ++++++++++++++++++
 2 files changed, 178 insertions(+)

diff --git a/frontend/src/app/common/service/user/auth.service.spec.ts 
b/frontend/src/app/common/service/user/auth.service.spec.ts
index 25a0dbf645..922894c0f9 100644
--- a/frontend/src/app/common/service/user/auth.service.spec.ts
+++ b/frontend/src/app/common/service/user/auth.service.spec.ts
@@ -206,4 +206,101 @@ describe("AuthService", () => {
       expect(AuthService.getAccessToken()).toBeNull();
     });
   });
+
+  describe("registerAutoLogout", () => {
+    afterEach(() => {
+      // Restore real timers before the outer afterEach runs logout()/verify().
+      vi.useRealTimers();
+    });
+
+    it("schedules a logout that fires once the token expiry elapses", () => {
+      vi.useFakeTimers();
+      AuthService.setAccessToken("tok");
+      jwt.isTokenExpired.mockReturnValue(false);
+      // Expiry one second into the (frozen) fake clock.
+      jwt.getTokenExpirationDate.mockReturnValue(new Date(Date.now() + 1000));
+      const logoutSpy = vi.spyOn(service, "logout");
+
+      (service as any).registerAutoLogout();
+      expect(logoutSpy).not.toHaveBeenCalled();
+
+      vi.advanceTimersByTime(1000);
+      expect(logoutSpy).toHaveBeenCalledTimes(1);
+    });
+
+    it("does not schedule a logout when the token is already expired", () => {
+      vi.useFakeTimers();
+      AuthService.setAccessToken("tok");
+      jwt.isTokenExpired.mockReturnValue(true);
+      const logoutSpy = vi.spyOn(service, "logout");
+
+      (service as any).registerAutoLogout();
+      vi.advanceTimersByTime(1_000_000);
+
+      expect(logoutSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  describe("invite-only registration gating", () => {
+    // Drives loginWithExistingToken down the inactive/invite-only branch and
+    // answers the registration-required probe with `true`, which is what makes
+    // openRegistrationModal run.
+    const openModalViaInactiveLogin = (): void => {
+      AuthService.setAccessToken("tok");
+      config.env.inviteOnly = true;
+      jwt.decodeToken.mockReturnValue({ ...claims, role: Role.INACTIVE });
+
+      expect(service.loginWithExistingToken()).toBeUndefined();
+
+      const req = httpMock.expectOne(r => r.url === 
`${api}/user/joining-reason/required`);
+      expect(req.request.method).toEqual("GET");
+      expect(req.request.params.get("uid")).toEqual("5");
+      req.flush(true);
+    };
+
+    it("opens the registration modal when registration is required", () => {
+      modal.create.mockReturnValue({
+        getContentComponent: () => ({
+          modalTitle: "Request Access",
+          getValues: () => ({ affiliation: "", reason: "" }),
+        }),
+        updateConfig: vi.fn(),
+      });
+
+      openModalViaInactiveLogin();
+
+      expect(modal.create).toHaveBeenCalledTimes(1);
+      expect(modal.create.mock.calls[0][0]).toEqual(
+        expect.objectContaining({
+          nzData: { uid: 5, email: "[email protected]", name: "Ursula" },
+          nzOkText: "Send request to Admin",
+        })
+      );
+    });
+
+    it("submitRegistration PUTs the affiliation/reason when the modal is 
confirmed", async () => {
+      const gmail = TestBed.inject(GmailService) as unknown as { 
notifyUnauthorizedLogin: ReturnType<typeof vi.fn> };
+      gmail.notifyUnauthorizedLogin = vi.fn();
+      modal.create.mockReturnValue({
+        getContentComponent: () => ({
+          modalTitle: "Request Access",
+          getValues: () => ({ affiliation: "Texera", reason: "research" }),
+        }),
+        updateConfig: vi.fn(),
+      });
+
+      openModalViaInactiveLogin();
+
+      const okHandler = modal.create.mock.calls[0][0].nzOnOk as () => 
Promise<boolean>;
+      const okPromise = okHandler();
+
+      const req = httpMock.expectOne(`${api}/user/joining-reason`);
+      expect(req.request.method).toEqual("PUT");
+      expect(req.request.body).toEqual({ uid: 5, affiliation: "Texera", 
joiningReason: "research" });
+      req.flush(null);
+
+      await expect(okPromise).resolves.toBe(true);
+      expect(gmail.notifyUnauthorizedLogin).toHaveBeenCalledWith("[email protected]", 
"Texera", "research");
+    });
+  });
 });
diff --git a/frontend/src/app/common/service/user/user.service.spec.ts 
b/frontend/src/app/common/service/user/user.service.spec.ts
index 199a9ff44b..b1e2afe35f 100644
--- a/frontend/src/app/common/service/user/user.service.spec.ts
+++ b/frontend/src/app/common/service/user/user.service.spec.ts
@@ -28,6 +28,7 @@ import { firstValueFrom, Subject, throwError } from "rxjs";
 import { commonTestProviders } from "../../testing/test-utils";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { GuiConfigService } from "../gui-config.service";
+import { Role, User } from "../../type/user";
 
 describe("UserService", () => {
   let service: UserService;
@@ -170,4 +171,84 @@ describe("UserService", () => {
     await firstValueFrom(service.login("test", "password"));
     expect(service.isLogin()).toBe(true);
   });
+
+  // ─── current-user state ───────────────────────────────────────────────────
+
+  const baseUser: User = {
+    uid: 1,
+    name: "alice",
+    email: "[email protected]",
+    role: Role.REGULAR,
+    comment: "",
+    joiningReason: "",
+  };
+
+  it("changeUser sets the current user (assigning a color) and emits it on 
userChanged", async () => {
+    // The constructor already replayed an initial `undefined`; skip it and 
wait
+    // for the emission our changeUser call produces.
+    const nextEmission = firstValueFrom(service.userChanged().pipe(skip(1)));
+
+    (service as any).changeUser(baseUser);
+
+    expect(service.getCurrentUser()).toMatchObject({ uid: 1, name: "alice" });
+    expect(service.getCurrentUser()?.color).toMatch(/^hsl\(/);
+    expect(await nextEmission).toMatchObject({ uid: 1, name: "alice" });
+  });
+
+  it("isAdmin reflects only the ADMIN role of the current user", () => {
+    expect(service.isAdmin()).toBe(false); // no user
+
+    (service as any).changeUser({ ...baseUser, role: Role.REGULAR });
+    expect(service.isAdmin()).toBe(false);
+
+    (service as any).changeUser({ ...baseUser, role: Role.ADMIN });
+    expect(service.isAdmin()).toBe(true);
+  });
+
+  // ─── avatar fetching ──────────────────────────────────────────────────────
+
+  it("getAvatar returns undefined for an empty avatar id", async () => {
+    expect(await firstValueFrom(service.getAvatar(""))).toBeUndefined();
+  });
+
+  it("getAvatar returns the cached object URL while the entry is still fresh", 
async () => {
+    (service as any).cache.set("cached-id", { url: "blob:cached", expiry: 
Date.now() + 60_000 });
+    expect(await 
firstValueFrom(service.getAvatar("cached-id"))).toBe("blob:cached");
+  });
+
+  describe("getAvatar network path", () => {
+    // fetchBlob() goes through the native `fetch`/`URL` globals (not 
HttpClient),
+    // so stub them deterministically and restore the originals afterwards.
+    let originalFetch: typeof globalThis.fetch;
+    let originalCreateObjectURL: typeof URL.createObjectURL;
+
+    beforeEach(() => {
+      originalFetch = globalThis.fetch;
+      originalCreateObjectURL = URL.createObjectURL;
+    });
+
+    afterEach(() => {
+      globalThis.fetch = originalFetch;
+      URL.createObjectURL = originalCreateObjectURL;
+    });
+
+    it("fetches the avatar, wraps the blob in an object URL, and caches it", 
async () => {
+      const blob = new Blob(["img"]);
+      globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, blob: () => 
Promise.resolve(blob) }) as any;
+      URL.createObjectURL = vi.fn().mockReturnValue("blob:fetched");
+
+      const result = await firstValueFrom(service.getAvatar("remote-id"));
+
+      expect(result).toBe("blob:fetched");
+      
expect(globalThis.fetch).toHaveBeenCalledWith("https://lh3.googleusercontent.com/a/remote-id";,
 {
+        referrerPolicy: "no-referrer",
+      });
+      expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
+    });
+
+    it("returns undefined when the avatar fetch fails", async () => {
+      globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) 
as any;
+      expect(await 
firstValueFrom(service.getAvatar("bad-id"))).toBeUndefined();
+    });
+  });
 });

Reply via email to