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

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


The following commit(s) were added to refs/heads/main by this push:
     new 30681cd384 test(frontend): close the user, hub and resource-registry 
service gaps (#8336)
30681cd384 is described below

commit 30681cd38406ca273f94714da2d7b118d8100f9f
Author: Xinyuan Lin <[email protected]>
AuthorDate: Wed Sep 2 11:01:06 2026 +0000

    test(frontend): close the user, hub and resource-registry service gaps 
(#8336)
    
    ### What changes were proposed in this PR?
    
    Four existing frontend specs extended. **+10 fully-covered lines and +7
    branch arms.**
    
    | File | Codecov | Branch arms |
    |---|---|---|
    | `user.service.ts` | 62/66 → **66/66** | 19/21 → **21/21** |
    | `browse-section.component.ts` | 28/32 → 31/32 | 22/25 → 24/25 |
    | `resource-registry.service.ts` | 18/20 → **20/20** | 16/17 → **17/17**
    |
    | `email-request-modal.component.html` | 14/15 → **15/15** | 4/6 →
    **6/6** |
    
    Three of the four reach 100%. The plain line-hit metric moves only
    **+4** against Codecov's +10, because six of the gained lines were
    already executing and flip solely by completing a branch arm — the two
    numbers are not interchangeable and both are given.
    
    Not-fully-covered across the bundle goes 13 → 3, and all three of those
    are deliberately declined (below).
    
    ### The reviewer revised my own claim down
    
    The build reported +11 lines and +8 arms. Independent re-measurement
    puts it at **+10 and +7**: `browse-section.component.ts` gains 3 lines
    and 2 arms, not 4 and 3. Line 96 remains partial. The lower figure is
    the one in the table.
    
    ### Two files were in scope and contribute nothing
    
    - **`user-dataset-version-creator.component.ts` was dropped entirely.**
    Its only missed line is `get formControlNames()`, and a repo-wide grep
    across `.ts` and `.html` returns exactly one hit — its own declaration.
    **Zero call sites, zero template bindings.** A test there would be a
    pure count-raiser, so the getter is flagged as a dead-code removal
    candidate instead.
    - `browse-section.component.html` (24/24) and
    `email-request-modal.component.ts` (8/9) were measured both ways and are
    unchanged.
    
    ### Deliberately not included
    
    `browse-section.component.ts:96` stays partial, and
    `email-request-modal.component.ts:67` is declined.
    
    One survivor is reported rather than chased: mutating
    `user.service.ts:108` from `handleAccessToken(accessToken ?? "")` to a
    conditional survives the suite. That mutant is character-for-character
    the shape `register()` already uses for the same `string | null` payload
    — it is arguably the *fix*, not a regression, so no test was written to
    pin the current form.
    
    `user.service.ts:184` (`this.cache.delete(avatarUrl)`) is covered but
    **not independently pinned**: the code immediately falls through to a
    `map` that overwrites the entry either way, so no mutation isolates it.
    It rides along with lines 180 and 183 in one test, and that is stated
    rather than presented as a kill.
    
    ### Verification
    
    Measured with the **full 209-file suite in one command** — no name
    filter and no `--include` at all — so there is no filter-attribution
    risk, with `coverage/` deleted before each run. The baseline was rebuilt
    by writing the `HEAD` versions of the four specs into place from a
    scratch extraction (never `git checkout`), running, then restoring from
    a hash-verified snapshot. Figures parsed straight from
    `coverage/gui/lcov.info`.
    
    Seven reviewer findings, all repaired — including one test that was
    **deleted** rather than kept, because it duplicated an existing
    assertion.
    
    `browse-section.component.ts:114` needed a white-box assertion or the
    test would have been vacuous: `getCoverImage()`'s `||
    this.defaultBackground` makes the guarded and unguarded paths observably
    identical through the public API, so the test asserts on the private
    `coverImageUrls` map. That is unusual and is called out here rather than
    left for a reader to find.
    
    `yarn format:ci` passes. `frontend/junit.xml` is regenerated by every
    run, is not gitignored, and is not committed. No production file is
    touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8334
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/user.service.spec.ts" 
--include="**/browse-section.component.spec.ts" 
--include="**/resource-registry.service.spec.ts" 
--include="**/email-request-modal.component.spec.ts"
    ```
    
    ```
     Test Files  4 passed (4)
    ```
    
    Re-run after rebasing onto current `main`.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    ---------
    
    Signed-off-by: Xinyuan Lin <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../email-request-modal.component.spec.ts          | 29 ++++++++
 .../app/common/service/user/user.service.spec.ts   | 83 +++++++++++++++++++++-
 .../resource-registry.service.spec.ts              | 38 ++++++++++
 .../browse-section.component.spec.ts               | 61 +++++++++++++++-
 4 files changed, 208 insertions(+), 3 deletions(-)

diff --git 
a/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
 
b/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
index a00bd7a60f..93e25de2ed 100644
--- 
a/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
+++ 
b/frontend/src/app/common/service/user/email-request-modal/email-request-modal.component.spec.ts
@@ -140,6 +140,35 @@ describe("EmailRequestModalComponent", () => {
     expect(code.getAttribute("autocomplete")).toBe("one-time-code");
   });
 
+  /**
+   * The spec above reads the code box's rendered attributes; nothing had ever 
typed into it, so its
+   * [(ngModel)] write-back had never fired. That is the direction that 
matters: the dialog's caller
+   * reads the code out of getValues(), and a one-way binding here would hand 
it a blank code with
+   * no visible symptom on screen.
+   */
+  it("writes the typed verification code back through ngModel", async () => {
+    const fixture = await createFixture({ name: "Sofia" });
+    fixture.detectChanges();
+    fixture.componentInstance.email = "[email protected]";
+    fixture.componentInstance.step = "code";
+    fixture.detectChanges();
+
+    const [, code] = inputs(fixture);
+    code.value = "246810";
+    code.dispatchEvent(new Event("input"));
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.code).toBe("246810");
+    // The two boxes are not crossed: the frozen address is still the one it 
was mailed to.
+    expect(fixture.componentInstance.getValues()).toEqual({ email: 
"[email protected]", code: "246810" });
+    // ...and not only in the fields. This is the one test that renders the 
code step with a
+    // non-empty code, so it is the only place the label naming where the code 
went can be told
+    // apart from the code itself; through getValues() the two are 
indistinguishable on screen.
+    const label = (fixture.nativeElement as 
HTMLElement).querySelector(".email-modal-code-label");
+    expect(label?.textContent).toContain("[email protected]");
+    expect(label?.textContent).not.toContain("246810");
+  });
+
   function inputs(fixture: ComponentFixture<EmailRequestModalComponent>): 
HTMLInputElement[] {
     return Array.from(fixture.nativeElement.querySelectorAll("input"));
   }
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 415335138c..6a38246626 100644
--- a/frontend/src/app/common/service/user/user.service.spec.ts
+++ b/frontend/src/app/common/service/user/user.service.spec.ts
@@ -106,6 +106,34 @@ describe("UserService", () => {
     expect(service.isLogin()).toBe(true);
   });
 
+  // The verify endpoint answers with `accessToken: string | null`, so a 
backend that accepted the
+  // code but declined to mint a session is representable. registerVerify 
substitutes "" for the
+  // absent token. What is pinned here is narrow and deliberate: no *usable* 
token is left behind,
+  // so any substituted placeholder fails.
+  //
+  // What is deliberately NOT pinned is whether the absent-token path runs 
`handleAccessToken` at
+  // all. It does today, and that is the defect: it writes "" over whatever 
token was there and
+  // fires the post-login @RolesAllowed config fetch for a session that was 
never granted. With a
+  // token already in localStorage that path reaches 
`AuthService.loginWithExistingToken`, whose
+  // `token == null` guard does not catch "", so an unrelated failed 
verification pops "Access
+  // token is expired!" and signs the current user out. `register()` above 
handles the same
+  // `string | null` shape by short-circuiting instead. Asserting either 
symptom — the "" write, or
+  // the config fetch firing — would cement that, so this test stays silent 
about it and accepts
+  // that a short-circuiting rewrite of line 108 is not distinguishable from 
here.
+  it("leaves no usable session when registerVerify answers without a token", 
async () => {
+    const auth = TestBed.inject(AuthService) as unknown as StubAuthService;
+    vi.spyOn(auth, "registerVerify").mockReturnValue(of({ accessToken: null 
}));
+
+    await firstValueFrom(service.registerVerify("pending", 
"[email protected]", "password", "123456"));
+
+    // Tolerant of both "" (today) and null (a short-circuiting fix), 
intolerant of any substituted
+    // placeholder. `isLogin()` is deliberately not asserted next to it: the 
stub's
+    // loginWithExistingToken answers with a user only for MOCK_TOKEN, which 
no mutation of this
+    // path can synthesise out of `{ accessToken: null }`, so that assertion 
could never have
+    // failed and read as assurance it did not provide.
+    expect(AuthService.getAccessToken() ?? "").toBe("");
+  });
+
   it("should not login after login failed", () => {
     expect((service as any).currentUser).toBeFalsy();
     service
@@ -260,15 +288,18 @@ describe("UserService", () => {
     // so stub them deterministically and restore the originals afterwards.
     let originalFetch: typeof globalThis.fetch;
     let originalCreateObjectURL: typeof URL.createObjectURL;
+    let originalRevokeObjectURL: typeof URL.revokeObjectURL;
 
     beforeEach(() => {
       originalFetch = globalThis.fetch;
       originalCreateObjectURL = URL.createObjectURL;
+      originalRevokeObjectURL = URL.revokeObjectURL;
     });
 
     afterEach(() => {
       globalThis.fetch = originalFetch;
       URL.createObjectURL = originalCreateObjectURL;
+      URL.revokeObjectURL = originalRevokeObjectURL;
     });
 
     it("fetches the avatar, wraps the blob in an object URL, and caches it", 
async () => {
@@ -276,14 +307,23 @@ describe("UserService", () => {
       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(AVATAR_URL));
+      // Held rather than subscribed inline, and subscribed twice: the 
returned observable replays
+      // its one value to every later subscriber. Under a refCounted share the 
first subscriber's
+      // unsubscribe would tear the buffer down, so the second would refetch 
and mint a second
+      // object URL over the cache entry — orphaning the first blob with 
nothing left holding the
+      // reference needed to revoke it. Same leak class as the expired-entry 
revoke below.
+      const avatar$ = service.getAvatar(AVATAR_URL);
+
+      expect(await firstValueFrom(avatar$)).toBe("blob:fetched");
+      expect(await firstValueFrom(avatar$)).toBe("blob:fetched");
 
-      expect(result).toBe("blob:fetched");
       // fetched verbatim — no CDN prefix is reconstructed here any more
       expect(globalThis.fetch).toHaveBeenCalledWith(AVATAR_URL, {
         referrerPolicy: "no-referrer",
       });
       expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
+      expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+      expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
     });
 
     it("fetches an avatar hosted anywhere the backend allowed, not just 
Google's CDN", async () => {
@@ -296,6 +336,45 @@ describe("UserService", () => {
       expect(globalThis.fetch).toHaveBeenCalledWith(otherHost, { 
referrerPolicy: "no-referrer" });
     });
 
+    // The freshness check has an expiry side to it: an object URL that 
outlives its entry has to be
+    // handed back to the browser, or every avatar refresh leaks the blob it 
was holding. The
+    // "returns the cached object URL while the entry is still fresh" spec 
above only ever exercised
+    // the other side of the same comparison.
+    it("revokes the stale object URL and refetches once the cached entry has 
expired", async () => {
+      const blob = new Blob(["fresh-img"]);
+      globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, blob: () => 
Promise.resolve(blob) }) as any;
+      URL.createObjectURL = vi.fn().mockReturnValue("blob:fresh");
+      URL.revokeObjectURL = vi.fn();
+      (service as any).cache.set(AVATAR_URL, { url: "blob:stale", expiry: 
Date.now() - 1 });
+
+      const result = await firstValueFrom(service.getAvatar(AVATAR_URL));
+
+      // Released by its own url, not by the cache key it was filed under.
+      expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:stale");
+      expect(globalThis.fetch).toHaveBeenCalledWith(AVATAR_URL, { 
referrerPolicy: "no-referrer" });
+      expect(result).toBe("blob:fresh");
+      // The whole entry, not just its url. Both freshness tests hand the 
comparison an expiry the
+      // test itself supplied, so without this nothing anywhere reads back the 
expiry getAvatar
+      // *writes*: `Date.now() + cacheDuration` could read `- cacheDuration` 
and every entry would
+      // be born stale, turning every avatar render into a revoke-and-refetch.
+      const entry = (service as any).cache.get(AVATAR_URL);
+      expect(entry.url).toBe("blob:fresh");
+      expect(entry.expiry).toBeGreaterThan(Date.now());
+    });
+
+    it("drops the revoked entry from the cache even when the refetch then 
fails", async () => {
+      URL.revokeObjectURL = vi.fn();
+      globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) 
as any;
+      (service as any).cache.set(AVATAR_URL, { url: "blob:stale", expiry: 
Date.now() - 1 });
+
+      expect(await 
firstValueFrom(service.getAvatar(AVATAR_URL))).toBeUndefined();
+
+      // The url has been handed back to the browser, so the entry holding it 
is dead. Keeping it
+      // would leave a revoked object URL reachable and revoke it a second 
time on the next call.
+      expect((service as any).cache.has(AVATAR_URL)).toBe(false);
+      expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
+    });
+
     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("https://lh3.googleusercontent.com/a/BAD";))).toBeUndefined();
diff --git 
a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
 
b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
index 65efc56709..0b15d9be65 100644
--- 
a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
+++ 
b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
@@ -27,6 +27,7 @@ import { DatasetService } from "../dataset/dataset.service";
 import { ModelService } from "../model/model.service";
 import { WorkflowPersistService } from 
"../../../../common/service/workflow-persist/workflow-persist.service";
 import { DownloadService } from "../download/download.service";
+import { FileResourceDescriptor } from "./file-resource.descriptor";
 import {
   HUB_DATASET_RESULT_DETAIL,
   HUB_MODEL_RESULT_DETAIL,
@@ -247,4 +248,41 @@ describe("ResourceRegistryService", () => {
     expect(registry.entryLink(entry({ type: EntityType.Dataset, id: undefined 
}), 42)).toEqual([]);
     expect(registry.entryLink(entry({ type: EntityType.Workflow, id: "draft" 
}), 42)).toEqual([]);
   });
+
+  /**
+   * `hubRoute` is optional on the descriptor contract, and the shipped kinds 
happen to declare
+   * both routes or neither — so nothing had ever asked what a 
private-page-only kind links to.
+   * The answer must not depend on the viewer: with nowhere else to send them, 
the private page is
+   * the only link there is, and the access check further down would otherwise 
route an outsider to
+   * `undefined`. Descriptors reach the registry by injection, so the kind is 
supplied as one.
+   */
+  it("links a kind with a private page and no hub page straight to its private 
page", () => {
+    TestBed.resetTestingModule();
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [
+        { provide: DownloadService, useValue: downloadService },
+        { provide: WorkflowPersistService, useValue: workflowPersistService },
+        { provide: DatasetService, useValue: datasetService },
+        { provide: ModelService, useValue: modelService },
+        {
+          provide: FileResourceDescriptor,
+          useValue: {
+            type: EntityType.File,
+            iconType: "folder-open",
+            privateRoute: "/private-files",
+            isOwner: () => true,
+          },
+        },
+        ...commonTestProviders,
+      ],
+    });
+    const privatePageOnly = TestBed.inject(ResourceRegistryService);
+    const file = entry({ type: EntityType.File, id: 7, accessibleUserIds: [42] 
});
+
+    expect(privatePageOnly.entryLink(file, 42)).toEqual(["/private-files", 
"7"]);
+    // Same link for a viewer with no access, and for an anonymous one.
+    expect(privatePageOnly.entryLink(file, 99)).toEqual(["/private-files", 
"7"]);
+    expect(privatePageOnly.entryLink(file, 
undefined)).toEqual(["/private-files", "7"]);
+  });
 });
diff --git 
a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
 
b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
index 07feb44e51..8531fb5247 100644
--- 
a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
+++ 
b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
@@ -128,7 +128,12 @@ describe("BrowseSectionComponent", () => {
   });
 
   describe("cover images", () => {
-    it("caches the cover URL the descriptor resolves for an entity that has a 
cover", () => {
+    it("caches the cover URL the descriptor resolves for an entity that has a 
cover, and asks once", () => {
+      // Spied, not replaced, so the double still answers: the call count is 
what pins the
+      // `!coverImageUrls.has(cacheKey(entity))` filter. ngOnChanges runs on 
every input change of
+      // every one of the landing page's four sections, so without that filter 
each pass would
+      // re-resolve every cover — a fresh presigned-URL request per card per 
change-detection run.
+      const cover = vi.spyOn(TestBed.inject(DatasetService) as any, 
"getDatasetCoverUrl");
       const entity = {
         id: 5,
         type: "dataset",
@@ -137,8 +142,10 @@ describe("BrowseSectionComponent", () => {
       } as unknown as DashboardEntry;
       component.entities = [entity];
       component.ngOnInit();
+      component.ngOnChanges({} as any);
 
       expect(component.getCoverImage(entity)).toBe(PRESIGNED_COVER);
+      expect(cover).toHaveBeenCalledTimes(1);
     });
 
     it("falls back to the default background when no cover was cached", () => {
@@ -149,6 +156,58 @@ describe("BrowseSectionComponent", () => {
 
       
expect(component.getCoverImage(entity)).toBe(component.defaultBackground);
     });
+
+    // `this.resourceRegistry.find(entity.type)?.coverUrl` carries two guards, 
and a mixed section
+    // can trip either. A workflow's cover is a data URL carried on the entry 
itself, so
+    // WorkflowResourceDescriptor deliberately declares no `coverUrl`; and a 
kind the registry does
+    // not carry at all has no descriptor to ask, which is why this is `find`, 
not `get` — one such
+    // row must not take the whole section's covers down, exactly as 
`routeFor` five lines up
+    // already promises for links.
+    it("skips an entity whose descriptor resolves no cover, rather than 
calling undefined", () => {
+      const workflow = {
+        id: 10,
+        type: "workflow",
+        coverImageUrl: "carried-on-the-entry",
+        accessibleUserIds: [],
+      } as unknown as DashboardEntry;
+      const unregistered = {
+        id: 12,
+        type: "computing-unit",
+        coverImageUrl: "carried-on-the-entry",
+        accessibleUserIds: [],
+      } as unknown as DashboardEntry;
+      component.entities = [workflow, unregistered];
+
+      expect(() => component.ngOnInit()).not.toThrow();
+      expect(coverCache(component).has("workflow:10")).toBe(false);
+      expect(coverCache(component).has("computing-unit:12")).toBe(false);
+      
expect(component.getCoverImage(workflow)).toBe(component.defaultBackground);
+    });
+
+    it("caches nothing when the descriptor resolves an empty cover url", () => 
{
+      // A presigned-URL endpoint with nothing to sign answers with an empty 
string; caching that
+      // would put an <img src=""> on the card, which the browser resolves to 
the page itself.
+      vi.spyOn(TestBed.inject(DatasetService) as any, 
"getDatasetCoverUrl").mockReturnValue(of({ url: "" }));
+      const entity = {
+        id: 11,
+        type: "dataset",
+        coverImageUrl: "has-cover",
+        accessibleUserIds: [],
+      } as unknown as DashboardEntry;
+      component.entities = [entity];
+      component.ngOnInit();
+
+      // White-box on purpose: getCoverImage's `|| defaultBackground` makes 
"cached an empty string"
+      // and "cached nothing" indistinguishable through the public API, so 
only the map itself can
+      // say whether the guard ran.
+      expect(coverCache(component).has("dataset:11")).toBe(false);
+      
expect(component.getCoverImage(entity)).toBe(component.defaultBackground);
+    });
+
+    /** The component's cover cache, which no public member exposes. */
+    function coverCache(c: BrowseSectionComponent): Map<string, string> {
+      return (c as unknown as { coverImageUrls: Map<string, string> 
}).coverImageUrls;
+    }
   });
 });
 /**

Reply via email to