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-5238-f967080e387f78f0fcf71025ccca1e20ba33ad57 in repository https://gitbox.apache.org/repos/asf/texera.git
commit d8c254c75a024f00106528dfb66f4ccc4084f012 Author: Matthew B. <[email protected]> AuthorDate: Wed May 27 00:00:23 2026 -0700 test: add unit spec for UserDatasetComponent (#5238) ### What changes were proposed in this PR? - Adds `user-dataset.component.spec.ts` covering `UserDatasetComponent` (previously uncovered apart from its file-renderer child). - Verifies user-state tracking via `UserService.userChanged()`, `ngAfterViewInit` re-search behavior, `search()` filterScope variants (`private` / `public` / `all`), the full 8-argument `SearchService.executeSearch` call shape, the LakeFS mismatch warning (text + 4000ms duration), the `UserDatasetVersionCreatorComponent` modal config and post-close navigation, and `deleteDataset` (undefined-`did` no-op plus filtering of the deleted entry). - Uses direct component instantiation with `vi.fn()` mocks rather than the heavier TestBed pattern, so assertions can target exact positional argument shapes without bootstrapping the modal content component or the ng-zorro module graph. ### Any related issues, documentation, or discussions? Closes: #5229 ### How was this PR tested? - `yarn test --include='**/user-dataset/user-dataset.component.spec.ts' --watch=false` (14 tests passed, ~12ms). ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.7 in compliance with ASF --- .../user-dataset/user-dataset.component.spec.ts | 312 +++++++++++++++++++++ 1 file changed, 312 insertions(+) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts new file mode 100644 index 0000000000..1556c2db63 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts @@ -0,0 +1,312 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { of, Subject } from "rxjs"; +import { UserDatasetComponent } from "./user-dataset.component"; +import { DASHBOARD_USER_DATASET } from "../../../../app-routing.constant"; +import { UserDatasetVersionCreatorComponent } from "./user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component"; +import { SortMethod } from "../../../type/sort-method"; +import { User } from "../../../../common/type/user"; + +type LoadMoreFn = (start: number, count: number) => Promise<{ entries: any[]; more: boolean }>; + +describe("UserDatasetComponent", () => { + let component: UserDatasetComponent; + + let userChangedSubject: Subject<User | undefined>; + let isLoginSpy: ReturnType<typeof vi.fn>; + let getCurrentUserSpy: ReturnType<typeof vi.fn>; + + let modalServiceMock: { create: ReturnType<typeof vi.fn> }; + let routerMock: { navigate: ReturnType<typeof vi.fn> }; + let searchServiceMock: { executeSearch: ReturnType<typeof vi.fn> }; + let datasetServiceMock: { deleteDatasets: ReturnType<typeof vi.fn> }; + let messageMock: { warning: ReturnType<typeof vi.fn> }; + + let filtersStub: any; + let searchResultsStub: any; + let capturedLoadMoreFn: LoadMoreFn | null; + + const buildEntry = (did: number | undefined, name = `dataset-${did}`) => + ({ + type: "dataset", + dataset: { + dataset: { + did, + name, + ownerUid: 1, + isPublic: false, + isDownloadable: false, + storagePath: undefined, + description: "", + creationTime: 0, + coverImage: undefined, + }, + }, + }) as any; + + beforeEach(() => { + userChangedSubject = new Subject<User | undefined>(); + isLoginSpy = vi.fn(() => true); + getCurrentUserSpy = vi.fn(() => ({ uid: 42 }) as User); + + const userServiceMock = { + userChanged: () => userChangedSubject.asObservable(), + isLogin: isLoginSpy, + getCurrentUser: getCurrentUserSpy, + }; + + modalServiceMock = { create: vi.fn() }; + routerMock = { navigate: vi.fn() }; + searchServiceMock = { + executeSearch: vi.fn(() => of({ entries: [], more: false, hasMismatch: false })), + }; + datasetServiceMock = { deleteDatasets: vi.fn(() => of({} as Response)) }; + messageMock = { warning: vi.fn() }; + + component = new UserDatasetComponent( + modalServiceMock as any, + userServiceMock as any, + routerMock as any, + searchServiceMock as any, + datasetServiceMock as any, + messageMock as any + ); + + capturedLoadMoreFn = null; + filtersStub = { + masterFilterList: [] as string[], + masterFilterListChange: new Subject<void>(), + getSearchKeywords: vi.fn(() => ["kw1"]), + getSearchFilterParameters: vi.fn(() => ({ ids: [1, 2] })), + }; + searchResultsStub = { + entries: [] as any[], + reset: vi.fn((fn: LoadMoreFn) => { + capturedLoadMoreFn = fn; + }), + loadMore: vi.fn(async () => {}), + }; + + component.filters = filtersStub; + component.searchResultsComponent = searchResultsStub; + }); + + describe("user state tracking", () => { + it("updates isLogin and currentUid when userChanged emits", () => { + // initial state pulled synchronously in field initializers + expect(component.isLogin).toBe(true); + expect(component.currentUid).toBe(42); + + isLoginSpy.mockReturnValue(false); + getCurrentUserSpy.mockReturnValue(undefined); + userChangedSubject.next(undefined); + + expect(component.isLogin).toBe(false); + expect(component.currentUid).toBeUndefined(); + + isLoginSpy.mockReturnValue(true); + getCurrentUserSpy.mockReturnValue({ uid: 99 } as User); + userChangedSubject.next({ uid: 99 } as User); + + expect(component.isLogin).toBe(true); + expect(component.currentUid).toBe(99); + }); + }); + + describe("ngAfterViewInit", () => { + it("subscribes to userChanged and calls search on each emission", () => { + const searchSpy = vi.spyOn(component, "search").mockResolvedValue(); + component.ngAfterViewInit(); + + expect(searchSpy).not.toHaveBeenCalled(); + userChangedSubject.next({ uid: 42 } as User); + expect(searchSpy).toHaveBeenCalledTimes(1); + + userChangedSubject.next(undefined); + expect(searchSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("search filterScope variants", () => { + it('defaults to "private": passes isLogin through, includePublic = false', async () => { + component.isLogin = true; + component.sortMethod = SortMethod.EditTimeDesc; + await component.search(); + + expect(searchResultsStub.reset).toHaveBeenCalledTimes(1); + expect(searchResultsStub.loadMore).toHaveBeenCalledTimes(1); + expect(capturedLoadMoreFn).not.toBeNull(); + + await capturedLoadMoreFn!(5, 10); + expect(searchServiceMock.executeSearch).toHaveBeenCalledWith( + ["kw1"], + { ids: [1, 2] }, + 5, + 10, + "dataset", + SortMethod.EditTimeDesc, + true, + false + ); + }); + + it('"public": forces isLogin = false, includePublic = true', async () => { + component.isLogin = true; + await component.search(false, "public"); + await capturedLoadMoreFn!(0, 20); + + const args = searchServiceMock.executeSearch.mock.calls[0]; + expect(args[6]).toBe(false); // isLogin + expect(args[7]).toBe(true); // includePublic + }); + + it('"all": passes isLogin through, includePublic = true', async () => { + component.isLogin = true; + await component.search(false, "all"); + await capturedLoadMoreFn!(0, 20); + + const args = searchServiceMock.executeSearch.mock.calls[0]; + expect(args[6]).toBe(true); + expect(args[7]).toBe(true); + }); + + it('"private" with isLogin = false: passes false through, includePublic = false', async () => { + component.isLogin = false; + await component.search(false, "private"); + await capturedLoadMoreFn!(0, 20); + + const args = searchServiceMock.executeSearch.mock.calls[0]; + expect(args[6]).toBe(false); + expect(args[7]).toBe(false); + }); + }); + + describe("search call shape", () => { + it("invokes executeSearch with the documented argument order via reset(...) then loadMore()", async () => { + filtersStub.getSearchKeywords.mockReturnValue(["alpha", "beta"]); + filtersStub.getSearchFilterParameters.mockReturnValue({ resourceType: "dataset" }); + component.sortMethod = SortMethod.NameAsc; + component.isLogin = true; + + await component.search(); + expect(searchResultsStub.reset).toHaveBeenCalledTimes(1); + expect(searchResultsStub.loadMore).toHaveBeenCalledTimes(1); + expect(searchResultsStub.reset.mock.invocationCallOrder[0]).toBeLessThan( + searchResultsStub.loadMore.mock.invocationCallOrder[0] + ); + + await capturedLoadMoreFn!(7, 25); + expect(searchServiceMock.executeSearch).toHaveBeenCalledWith( + ["alpha", "beta"], + { resourceType: "dataset" }, + 7, + 25, + "dataset", + SortMethod.NameAsc, + true, + false + ); + }); + }); + + describe("mismatch warning", () => { + it("when hasMismatch = true: sets component.hasMismatch and warns for 4000ms", async () => { + searchServiceMock.executeSearch.mockReturnValue(of({ entries: [], more: false, hasMismatch: true })); + + await component.search(); + await capturedLoadMoreFn!(0, 20); + + expect(component.hasMismatch).toBe(true); + expect(messageMock.warning).toHaveBeenCalledTimes(1); + const [msg, opts] = messageMock.warning.mock.calls[0]; + expect(typeof msg).toBe("string"); + expect(msg.length).toBeGreaterThan(0); + expect(opts).toEqual({ nzDuration: 4000 }); + }); + + it("when hasMismatch is missing/false: does not warn and clears hasMismatch", async () => { + component.hasMismatch = true; + searchServiceMock.executeSearch.mockReturnValue(of({ entries: [], more: false })); + + await component.search(); + await capturedLoadMoreFn!(0, 20); + + expect(component.hasMismatch).toBe(false); + expect(messageMock.warning).not.toHaveBeenCalled(); + }); + }); + + describe("onClickOpenDatasetAddComponent", () => { + it("opens UserDatasetVersionCreatorComponent with isCreatingVersion: false", () => { + modalServiceMock.create.mockReturnValue({ afterClose: of(null) }); + + component.onClickOpenDatasetAddComponent(); + + expect(modalServiceMock.create).toHaveBeenCalledTimes(1); + const config = modalServiceMock.create.mock.calls[0][0]; + expect(config.nzContent).toBe(UserDatasetVersionCreatorComponent); + expect(config.nzData).toEqual({ isCreatingVersion: false }); + expect(config.nzFooter).toBeNull(); + }); + + it("on close with a dataset result: navigates to the new dataset URL", () => { + const dashboardDataset = { + isOwner: true, + ownerEmail: "[email protected]", + accessPrivilege: "WRITE", + size: 0, + dataset: { did: 123 }, + }; + modalServiceMock.create.mockReturnValue({ afterClose: of(dashboardDataset) }); + + component.onClickOpenDatasetAddComponent(); + + expect(routerMock.navigate).toHaveBeenCalledWith([`${DASHBOARD_USER_DATASET}/123`]); + }); + + it("on close with null result: does not navigate", () => { + modalServiceMock.create.mockReturnValue({ afterClose: of(null) }); + + component.onClickOpenDatasetAddComponent(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + }); + + describe("deleteDataset", () => { + it("is a no-op when entry.dataset.dataset.did is undefined", () => { + component.deleteDataset(buildEntry(undefined)); + + expect(datasetServiceMock.deleteDatasets).not.toHaveBeenCalled(); + }); + + it("calls deleteDatasets(did) and filters the entry out of searchResultsComponent.entries", () => { + const e1 = buildEntry(1, "first"); + const e2 = buildEntry(2, "second"); + const e3 = buildEntry(3, "third"); + searchResultsStub.entries = [e1, e2, e3]; + + component.deleteDataset(e2); + + expect(datasetServiceMock.deleteDatasets).toHaveBeenCalledWith(2); + expect(searchResultsStub.entries).toEqual([e1, e3]); + }); + }); +});
