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(); + }); + }); });
