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-5203-2153c17879ca4504da09d4d7dc9648d17f364aa0 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 767219abe30851cd5754dd10b93d467c9c90e4ec Author: Yicong Huang <[email protected]> AuthorDate: Mon May 25 15:35:29 2026 -0700 test(frontend): cover three dashboard services (#5203) ### What changes were proposed in this PR? Adds `HttpClientTestingModule`-based specs for three dashboard services that previously had no (or near-empty) tests: - `dataset.service.ts` — pins the URL, HTTP method, body / query string, and response mapping for every public method, including the authenticated / anonymous endpoint splits, the presigned-URL chain on `retrieveDatasetVersionSingleFile`, and the file-node attaching that `createDatasetVersion` / `retrieveDatasetLatestVersion` perform. - `workflow-version.service.ts` — replaces a 2-test stub with coverage of the readonly-display lifecycle (snapshot, swap, restore), joint-paper highlight helpers, the forward-diff classification in `getWorkflowsDifference` / `getOperatorsDifference`, and the three version-API HTTP endpoints. - `search.service.ts` — covers the authenticated vs public search routing (with the forced `includePublic=true` on the anonymous path), the dataset `hasMismatch` filtering inside `executeSearch`, and the branchy enrich pipeline in `extendSearchResultsWithHubActivityInfo` (Workflow / Project / Dataset entity routing, activity-list narrowing, and the workflow-only size-lookup gating). No production code is touched. ### Any related issues, documentation, discussions? Closes #5202. ### How was this PR tested? Added unit tests for the three services listed above. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Opus 4.7 Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]> --- .../service/user/dataset/dataset.service.spec.ts | 356 +++++++++++++++++ .../dashboard/service/user/search.service.spec.ts | 317 +++++++++++++++ .../workflow-version.service.spec.ts | 445 ++++++++++++++++++++- 3 files changed, 1099 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts new file mode 100644 index 0000000000..d930e50835 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts @@ -0,0 +1,356 @@ +/** + * 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 { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { firstValueFrom } from "rxjs"; + +import { DATASET_BASE_URL, DatasetService } from "./dataset.service"; +import { AppSettings } from "../../../../common/app-setting"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { Dataset, DatasetVersion } from "../../../../common/type/dataset"; +import { DashboardDataset } from "../../../type/dashboard-dataset.interface"; +import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; + +const API = "api"; + +function buildDataset(overrides: Partial<Dataset> = {}): Dataset { + return { + did: 1, + ownerUid: 1, + name: "ds", + isPublic: false, + isDownloadable: false, + storagePath: undefined, + description: "", + creationTime: undefined, + coverImage: undefined, + ...overrides, + }; +} + +function buildDashboardDataset(overrides: Partial<DashboardDataset> = {}): DashboardDataset { + return { + isOwner: true, + ownerEmail: "[email protected]", + dataset: buildDataset(), + accessPrivilege: "WRITE", + size: 0, + ...overrides, + }; +} + +function buildDatasetVersion(overrides: Partial<DatasetVersion> = {}): DatasetVersion { + return { + dvid: 5, + did: 1, + creatorUid: 1, + name: "v1", + versionHash: "abc", + creationTime: 0, + fileNodes: undefined, + ...overrides, + }; +} + +const SAMPLE_FILE_NODES: DatasetFileNode[] = [ + { name: "root", type: "directory", parentDir: "", children: [] as DatasetFileNode[] } as DatasetFileNode, +]; + +describe("DatasetService", () => { + let service: DatasetService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [DatasetService, ...commonTestProviders], + }); + service = TestBed.inject(DatasetService); + http = TestBed.inject(HttpTestingController); + vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API); + }); + + afterEach(() => { + http.verify(); + }); + + // ─── createDataset ──────────────────────────────────────────────────────── + + it("createDataset POSTs the dataset metadata under the create endpoint", async () => { + const dataset = buildDataset({ name: "demo", description: "desc", isPublic: true, isDownloadable: true }); + const dashboard = buildDashboardDataset(); + const pending = firstValueFrom(service.createDataset(dataset)); + + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/create`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ + datasetName: "demo", + datasetDescription: "desc", + isDatasetPublic: true, + isDatasetDownloadable: true, + }); + req.flush(dashboard); + expect(await pending).toEqual(dashboard); + }); + + // ─── getDataset (login vs public branch) ────────────────────────────────── + + it("getDataset hits /dataset/{did} when logged in", () => { + service.getDataset(7).subscribe(); + http.expectOne(`${API}/${DATASET_BASE_URL}/7`).flush(buildDashboardDataset()); + }); + + it("getDataset hits /dataset/public/{did} when anonymous", () => { + service.getDataset(7, false).subscribe(); + http.expectOne(`${API}/${DATASET_BASE_URL}/public/7`).flush(buildDashboardDataset()); + }); + + // ─── retrieveDatasetVersionSingleFile (chained presigned URL) ───────────── + + it("retrieveDatasetVersionSingleFile resolves the presigned URL then GETs the blob", async () => { + const filePath = "folder/file.txt"; + const blob = new Blob(["bytes"]); + const pending = firstValueFrom(service.retrieveDatasetVersionSingleFile(filePath)); + + const presignReq = http.expectOne( + `${API}/${DATASET_BASE_URL}/presign-download?filePath=${encodeURIComponent(filePath)}` + ); + expect(presignReq.request.method).toBe("GET"); + presignReq.flush({ presignedUrl: "https://s3.example/blob" }); + + const blobReq = http.expectOne("https://s3.example/blob"); + expect(blobReq.request.responseType).toBe("blob"); + blobReq.flush(blob); + + expect(await pending).toBe(blob); + }); + + it("retrieveDatasetVersionSingleFile uses the public presign endpoint when anonymous", () => { + const filePath = "f.txt"; + service.retrieveDatasetVersionSingleFile(filePath, false).subscribe(); + const presignReq = http.expectOne( + `${API}/${DATASET_BASE_URL}/public-presign-download?filePath=${encodeURIComponent(filePath)}` + ); + presignReq.flush({ presignedUrl: "https://s3.example/x" }); + http.expectOne("https://s3.example/x").flush(new Blob()); + }); + + // ─── retrieveDatasetVersionZip ──────────────────────────────────────────── + + it("retrieveDatasetVersionZip sets dvid when a version is specified", () => { + service.retrieveDatasetVersionZip(3, 99).subscribe(); + const req = http.expectOne(r => r.url === `${API}/dataset/3/versionZip`); + expect(req.request.params.get("dvid")).toBe("99"); + expect(req.request.params.get("latest")).toBeNull(); + expect(req.request.responseType).toBe("blob"); + req.flush(new Blob()); + }); + + it("retrieveDatasetVersionZip sets latest=true when dvid is omitted", () => { + service.retrieveDatasetVersionZip(3).subscribe(); + const req = http.expectOne(r => r.url === `${API}/dataset/3/versionZip`); + expect(req.request.params.get("latest")).toBe("true"); + expect(req.request.params.get("dvid")).toBeNull(); + req.flush(new Blob()); + }); + + // ─── retrieveAccessibleDatasets ─────────────────────────────────────────── + + it("retrieveAccessibleDatasets GETs /dataset/list", async () => { + const datasets = [buildDashboardDataset()]; + const pending = firstValueFrom(service.retrieveAccessibleDatasets()); + http.expectOne(`${API}/${DATASET_BASE_URL}/list`).flush(datasets); + expect(await pending).toEqual(datasets); + }); + + // ─── createDatasetVersion (mapper attaches fileNodes) ───────────────────── + + it("createDatasetVersion attaches fileNodes onto the returned DatasetVersion", async () => { + const did = 1; + const newVersion = "v2"; + const dv = buildDatasetVersion({ name: newVersion }); + const pending = firstValueFrom(service.createDatasetVersion(did, newVersion)); + + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/${did}/version/create`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toBe(newVersion); + expect(req.request.headers.get("Content-Type")).toBe("text/plain"); + req.flush({ datasetVersion: dv, fileNodes: SAMPLE_FILE_NODES }); + + const result = await pending; + expect(result.fileNodes).toBe(SAMPLE_FILE_NODES); + expect(result.name).toBe(newVersion); + }); + + // ─── listMultipartUploads ───────────────────────────────────────────────── + + it("listMultipartUploads returns the filePaths array", async () => { + const pending = firstValueFrom(service.listMultipartUploads("[email protected]", "ds")); + const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`); + expect(req.request.method).toBe("POST"); + expect(req.request.params.get("type")).toBe("list"); + expect(req.request.params.get("ownerEmail")).toBe("[email protected]"); + expect(req.request.params.get("datasetName")).toBe("ds"); + req.flush({ filePaths: ["a", "b"] }); + expect(await pending).toEqual(["a", "b"]); + }); + + it("listMultipartUploads tolerates a null payload", async () => { + const pending = firstValueFrom(service.listMultipartUploads("[email protected]", "ds")); + const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`); + req.flush(null); + expect(await pending).toEqual([]); + }); + + // ─── finalizeMultipartUpload (abort vs finish) ──────────────────────────── + + it("finalizeMultipartUpload routes through type=finish when not aborting", () => { + service.finalizeMultipartUpload("[email protected]", "ds", "f", false).subscribe(); + const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`); + expect(req.request.params.get("type")).toBe("finish"); + expect(req.request.params.get("filePath")).toBe(encodeURIComponent("f")); + req.flush({}); + }); + + it("finalizeMultipartUpload routes through type=abort when aborting", () => { + service.finalizeMultipartUpload("[email protected]", "ds", "f", true).subscribe(); + const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`); + expect(req.request.params.get("type")).toBe("abort"); + req.flush({}); + }); + + // ─── resetDatasetFileDiff / deleteDatasetFile ───────────────────────────── + + it("resetDatasetFileDiff PUTs /dataset/{did}/diff with the encoded filePath", () => { + service.resetDatasetFileDiff(2, "a/b.txt").subscribe(); + const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/2/diff`); + expect(req.request.method).toBe("PUT"); + expect(req.request.params.get("filePath")).toBe(encodeURIComponent("a/b.txt")); + req.flush({}); + }); + + it("deleteDatasetFile DELETEs /dataset/{did}/file with the encoded filePath", () => { + service.deleteDatasetFile(2, "a/b.txt").subscribe(); + const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/2/file`); + expect(req.request.method).toBe("DELETE"); + expect(req.request.params.get("filePath")).toBe(encodeURIComponent("a/b.txt")); + req.flush({}); + }); + + // ─── getDatasetDiff ─────────────────────────────────────────────────────── + + it("getDatasetDiff returns the staged-object list", async () => { + const diff: DatasetStagedObject[] = [{ path: "p", pathType: "file", diffType: "added" }]; + const pending = firstValueFrom(service.getDatasetDiff(9)); + http.expectOne(`${API}/${DATASET_BASE_URL}/9/diff`).flush(diff); + expect(await pending).toEqual(diff); + }); + + // ─── retrieveDatasetVersionList (login vs public) ───────────────────────── + + it("retrieveDatasetVersionList hits the authenticated path when logged in", () => { + service.retrieveDatasetVersionList(1).subscribe(); + http.expectOne(`${API}/${DATASET_BASE_URL}/1/version/list`).flush([]); + }); + + it("retrieveDatasetVersionList hits the public path when anonymous", () => { + service.retrieveDatasetVersionList(1, false).subscribe(); + http.expectOne(`${API}/${DATASET_BASE_URL}/1/publicVersion/list`).flush([]); + }); + + // ─── retrieveDatasetLatestVersion (mapper attaches fileNodes) ───────────── + + it("retrieveDatasetLatestVersion attaches fileNodes onto the returned version", async () => { + const dv = buildDatasetVersion(); + const pending = firstValueFrom(service.retrieveDatasetLatestVersion(1)); + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/1/version/latest`); + req.flush({ datasetVersion: dv, fileNodes: SAMPLE_FILE_NODES }); + const result = await pending; + expect(result.fileNodes).toBe(SAMPLE_FILE_NODES); + }); + + // ─── retrieveDatasetVersionFileTree (login vs public) ───────────────────── + + it("retrieveDatasetVersionFileTree picks the authenticated path when logged in", () => { + service.retrieveDatasetVersionFileTree(1, 2).subscribe(); + http.expectOne(`${API}/${DATASET_BASE_URL}/1/version/2/rootFileNodes`).flush({ fileNodes: [], size: 0 }); + }); + + it("retrieveDatasetVersionFileTree picks the public path when anonymous", () => { + service.retrieveDatasetVersionFileTree(1, 2, false).subscribe(); + http.expectOne(`${API}/${DATASET_BASE_URL}/1/publicVersion/2/rootFileNodes`).flush({ fileNodes: [], size: 0 }); + }); + + // ─── deleteDatasets ─────────────────────────────────────────────────────── + + it("deleteDatasets DELETEs /dataset/{did}", () => { + service.deleteDatasets(5).subscribe(); + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/5`); + expect(req.request.method).toBe("DELETE"); + req.flush({}); + }); + + // ─── updateDatasetName / Description / Publicity / Downloadable ─────────── + + it("updateDatasetName POSTs name + did into /update/name", () => { + service.updateDatasetName(2, "renamed").subscribe(); + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/update/name`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ did: 2, name: "renamed" }); + req.flush({}); + }); + + it("updateDatasetDescription POSTs description + did into /update/description", () => { + service.updateDatasetDescription(2, "newdesc").subscribe(); + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/update/description`); + expect(req.request.body).toEqual({ did: 2, description: "newdesc" }); + req.flush({}); + }); + + it("updateDatasetPublicity POSTs to /dataset/{did}/update/publicity", () => { + service.updateDatasetPublicity(2).subscribe(); + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/2/update/publicity`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({}); + req.flush({}); + }); + + it("updateDatasetDownloadable POSTs to /dataset/{did}/update/downloadable", () => { + service.updateDatasetDownloadable(2).subscribe(); + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/2/update/downloadable`); + req.flush({}); + }); + + // ─── retrieveOwners / updateDatasetCoverImage ───────────────────────────── + + it("retrieveOwners GETs /dataset/user-dataset-owners", async () => { + const pending = firstValueFrom(service.retrieveOwners()); + http.expectOne(`${API}/${DATASET_BASE_URL}/user-dataset-owners`).flush(["a", "b"]); + expect(await pending).toEqual(["a", "b"]); + }); + + it("updateDatasetCoverImage POSTs the cover image base64 to /dataset/{did}/update/cover", () => { + service.updateDatasetCoverImage(3, "data:image/png;base64,ZGF0YQ==").subscribe(); + const req = http.expectOne(`${API}/dataset/3/update/cover`); + expect(req.request.body).toEqual({ coverImage: "data:image/png;base64,ZGF0YQ==" }); + req.flush({}); + }); +}); diff --git a/frontend/src/app/dashboard/service/user/search.service.spec.ts b/frontend/src/app/dashboard/service/user/search.service.spec.ts new file mode 100644 index 0000000000..c80df0441b --- /dev/null +++ b/frontend/src/app/dashboard/service/user/search.service.spec.ts @@ -0,0 +1,317 @@ +/** + * 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 { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { firstValueFrom, of } from "rxjs"; + +import { SearchService } from "./search.service"; +import { AppSettings } from "../../../common/app-setting"; +import { commonTestProviders } from "../../../common/testing/test-utils"; +import { ActionType, EntityType, HubService } from "../../../hub/service/hub.service"; +import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { SearchFilterParameters } from "../../type/search-filter-parameters"; +import { SortMethod } from "../../type/sort-method"; +import { SearchResult, SearchResultItem } from "../../type/search-result"; +import { DashboardWorkflow } from "../../type/dashboard-workflow.interface"; +import { DashboardProject } from "../../type/dashboard-project.interface"; +import { DashboardDataset } from "../../type/dashboard-dataset.interface"; + +const API = "api"; + +function makeEmptyFilter(): SearchFilterParameters { + return { + createDateStart: null, + createDateEnd: null, + modifiedDateStart: null, + modifiedDateEnd: null, + owners: [], + ids: [], + operators: [], + projectIds: [], + }; +} + +function makeWorkflowItem(wid: number, ownerId: number): SearchResultItem { + const workflow: DashboardWorkflow = { + isOwner: true, + ownerName: undefined, + workflow: { + name: `wf-${wid}`, + description: undefined, + wid, + creationTime: 0, + lastModifiedTime: 0, + isPublished: 0, + readonly: false, + content: { + operators: [], + operatorPositions: {}, + links: [], + commentBoxes: [], + settings: { dataTransferBatchSize: 400, executionMode: "PIPELINED" as any }, + }, + }, + projectIDs: [], + accessLevel: "WRITE", + ownerId, + }; + return { resourceType: "workflow", workflow }; +} + +function makeProjectItem(pid: number, ownerId: number): SearchResultItem { + const project: DashboardProject = { + pid, + name: `proj-${pid}`, + description: "", + ownerId, + creationTime: 0, + color: null, + accessLevel: "WRITE", + }; + return { resourceType: "project", project }; +} + +function makeDatasetItem(did: number, ownerUid: number): SearchResultItem { + const dataset: DashboardDataset = { + isOwner: true, + ownerEmail: "[email protected]", + accessPrivilege: "WRITE", + size: 17, + dataset: { + did, + ownerUid, + name: `ds-${did}`, + isPublic: false, + isDownloadable: false, + storagePath: undefined, + description: "", + creationTime: 0, + coverImage: undefined, + }, + }; + return { resourceType: "dataset", dataset }; +} + +describe("SearchService", () => { + let service: SearchService; + let http: HttpTestingController; + let hubSpy: { + getCounts: ReturnType<typeof vi.fn>; + isLiked: ReturnType<typeof vi.fn>; + getUserAccess: ReturnType<typeof vi.fn>; + }; + let persistSpy: { getSizes: ReturnType<typeof vi.fn> }; + + beforeEach(() => { + hubSpy = { + getCounts: vi.fn().mockReturnValue(of([])), + isLiked: vi.fn().mockReturnValue(of([])), + getUserAccess: vi.fn().mockReturnValue(of([])), + }; + persistSpy = { getSizes: vi.fn().mockReturnValue(of({})) }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + SearchService, + { provide: HubService, useValue: hubSpy }, + { provide: WorkflowPersistService, useValue: persistSpy }, + ...commonTestProviders, + ], + }); + service = TestBed.inject(SearchService); + http = TestBed.inject(HttpTestingController); + vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API); + }); + + afterEach(() => { + http.verify(); + }); + + // ─── search ─────────────────────────────────────────────────────────────── + + describe("search", () => { + it("hits dashboard/search with includePublic=true when logged in and asking for public", async () => { + const result: SearchResult = { results: [], more: false }; + const pending = firstValueFrom( + service.search(["k"], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, true, true) + ); + const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/search`)); + expect(req.request.method).toBe("GET"); + expect(req.request.url).toContain("includePublic=true"); + req.flush(result); + expect(await pending).toEqual(result); + }); + + it("hits dashboard/search with includePublic=false when logged in and asking for private only", () => { + service.search(["k"], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, true, false).subscribe(); + const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/search`)); + expect(req.request.url).toContain("includePublic=false"); + req.flush({ results: [], more: false }); + }); + + it("hits dashboard/publicSearch and forces includePublic=true when anonymous", () => { + service.search(["k"], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, false, false).subscribe(); + const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/publicSearch`)); + expect(req.request.url).toContain("includePublic=true"); + req.flush({ results: [], more: false }); + }); + }); + + // ─── getUserInfo ────────────────────────────────────────────────────────── + + it("getUserInfo encodes each user id as a repeated `userIds` query param", async () => { + const pending = firstValueFrom(service.getUserInfo([1, 2])); + const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/resultsOwnersInfo`)); + expect(req.request.urlWithParams).toContain("userIds=1"); + expect(req.request.urlWithParams).toContain("userIds=2"); + req.flush({ 1: { userName: "alice" } }); + expect(await pending).toEqual({ 1: { userName: "alice" } }); + }); + + // ─── executeSearch ──────────────────────────────────────────────────────── + + describe("executeSearch", () => { + it("filters null/mismatched datasets and surfaces hasMismatch", async () => { + const dsItem = makeDatasetItem(10, 5); + const flagged: any = { resourceType: "dataset", dataset: null }; + const result: SearchResult = { results: [dsItem, flagged, null as any], more: true, hasMismatch: true }; + vi.spyOn(service, "search").mockReturnValue(of(result)); + vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any)); + + const batch = await firstValueFrom( + service.executeSearch([], makeEmptyFilter(), 0, 10, "dataset", SortMethod.NameAsc, true, false) + ); + + expect(batch.entries).toHaveLength(1); + expect(batch.entries[0].id).toBe(10); + expect(batch.more).toBe(true); + expect(batch.hasMismatch).toBe(true); + }); + + it("leaves hasMismatch undefined and skips filtering for non-dataset searches", async () => { + const wf = makeWorkflowItem(11, 7); + vi.spyOn(service, "search").mockReturnValue(of({ results: [wf], more: false, hasMismatch: true })); + vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any)); + + const batch = await firstValueFrom( + service.executeSearch([], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, true, false) + ); + + expect(batch.hasMismatch).toBeUndefined(); + expect(batch.entries).toHaveLength(1); + }); + }); + + // ─── extendSearchResultsWithHubActivityInfo ─────────────────────────────── + + describe("extendSearchResultsWithHubActivityInfo", () => { + it("skips hub fetches and persist size lookup when there are no items", async () => { + const entries = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([], true)); + expect(entries).toEqual([]); + expect(hubSpy.getCounts).not.toHaveBeenCalled(); + expect(hubSpy.isLiked).not.toHaveBeenCalled(); + expect(hubSpy.getUserAccess).not.toHaveBeenCalled(); + expect(persistSpy.getSizes).not.toHaveBeenCalled(); + }); + + it("hydrates counts, like flags, access ids, and sizes for a workflow item", async () => { + const wf = makeWorkflowItem(11, 7); + hubSpy.getCounts.mockReturnValue( + of([{ entityId: 11, entityType: EntityType.Workflow, counts: { [ActionType.View]: 3, [ActionType.Like]: 1 } }]) + ); + hubSpy.isLiked.mockReturnValue(of([{ entityId: 11, entityType: EntityType.Workflow, isLiked: true }])); + hubSpy.getUserAccess.mockReturnValue(of([{ entityId: 11, entityType: EntityType.Workflow, userIds: [99] }])); + persistSpy.getSizes.mockReturnValue(of({ 11: 4096 })); + + const userInfoSpy = vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 7: { userName: "alice" } })); + + const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([wf], true)); + + expect(userInfoSpy).toHaveBeenCalledWith([7]); + expect(entry.viewCount).toBe(3); + expect(entry.likeCount).toBe(1); + expect(entry.isLiked).toBe(true); + expect(entry.accessibleUserIds).toEqual([99]); + expect(entry.size).toBe(4096); + expect(entry.ownerName).toBe("alice"); + }); + + it("skips like lookup when the user is not logged in", async () => { + const wf = makeWorkflowItem(11, 7); + vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any)); + + const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([wf], false)); + + expect(hubSpy.isLiked).not.toHaveBeenCalled(); + expect(entry.isLiked).toBe(false); + }); + + it("honors a narrowed activities list (counts only)", async () => { + const wf = makeWorkflowItem(11, 7); + vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any)); + + await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([wf], true, ["counts"])); + + expect(hubSpy.getCounts).toHaveBeenCalled(); + expect(hubSpy.isLiked).not.toHaveBeenCalled(); + expect(hubSpy.getUserAccess).not.toHaveBeenCalled(); + expect(persistSpy.getSizes).not.toHaveBeenCalled(); + }); + + it("uses Project entity routing for project items", async () => { + const proj = makeProjectItem(20, 8); + vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 8: { userName: "bob" } })); + hubSpy.getCounts.mockReturnValue( + of([{ entityId: 20, entityType: EntityType.Project, counts: { [ActionType.Clone]: 2 } }]) + ); + + const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([proj], true)); + + const [types, ids] = hubSpy.getCounts.mock.calls[0]; + expect(types).toEqual([EntityType.Project]); + expect(ids).toEqual([20]); + expect(entry.cloneCount).toBe(2); + expect(entry.ownerName).toBe("bob"); + }); + + it("uses Dataset entity routing and pulls ownerUid for dataset items", async () => { + const ds = makeDatasetItem(30, 9); + const userInfoSpy = vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 9: { userName: "carol" } })); + hubSpy.getUserAccess.mockReturnValue(of([{ entityId: 30, entityType: EntityType.Dataset, userIds: [42, 43] }])); + + const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([ds], true)); + + expect(userInfoSpy).toHaveBeenCalledWith([9]); + expect(hubSpy.getUserAccess.mock.calls[0][0]).toEqual([EntityType.Dataset]); + expect(entry.accessibleUserIds).toEqual([42, 43]); + expect(entry.ownerName).toBe("carol"); + }); + + it("does not request sizes when there are no workflow items", async () => { + const proj = makeProjectItem(20, 8); + vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any)); + + await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([proj], true)); + + expect(persistSpy.getSizes).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts b/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts index 37e73beca4..85c9016500 100644 --- a/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts @@ -18,44 +18,451 @@ */ import { TestBed } from "@angular/core/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { WorkflowVersionService } from "./workflow-version.service"; -import { WorkflowActionService } from "src/app/workspace/service/workflow-graph/model/workflow-action.service"; -import { OperatorMetadataService } from "src/app/workspace/service/operator-metadata/operator-metadata.service"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { firstValueFrom } from "rxjs"; +import { take, toArray } from "rxjs/operators"; + +import { WORKFLOW_VERSIONS_API_BASE_URL, WorkflowVersionService } from "./workflow-version.service"; +import { WorkflowActionService } from "../../../../workspace/service/workflow-graph/model/workflow-action.service"; +import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service"; +import { UndoRedoService } from "../../../../workspace/service/undo-redo/undo-redo.service"; +import { AppSettings } from "../../../../common/app-setting"; import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { ExecutionMode, Workflow, WorkflowContent } from "../../../../common/type/workflow"; +import { CommentBox, OperatorLink, OperatorPredicate } from "../../../../workspace/types/workflow-common.interface"; +import { WorkflowVersionEntry } from "../../../type/workflow-version-entry"; + +const API = "api"; + +function buildOperator(overrides: Partial<OperatorPredicate> = {}): OperatorPredicate { + return { + operatorID: "op-1", + operatorType: "Filter", + operatorVersion: "v1", + operatorProperties: {}, + inputPorts: [], + outputPorts: [], + showAdvanced: false, + ...overrides, + }; +} + +function buildLink(source: string, target: string): OperatorLink { + return { + linkID: `${source}->${target}`, + source: { operatorID: source, portID: "out-0" }, + target: { operatorID: target, portID: "in-0" }, + }; +} + +function buildContent(overrides: Partial<WorkflowContent> = {}): WorkflowContent { + return { + operators: [], + operatorPositions: {}, + links: [], + commentBoxes: [] as CommentBox[], + settings: { dataTransferBatchSize: 400, executionMode: ExecutionMode.PIPELINED }, + ...overrides, + }; +} + +function buildWorkflow(overrides: Partial<Workflow> = {}): Workflow { + return { + name: "wf", + description: undefined, + wid: 1, + creationTime: 0, + lastModifiedTime: 0, + isPublished: 0, + readonly: false, + content: buildContent(), + ...overrides, + }; +} describe("WorkflowVersionService", () => { let service: WorkflowVersionService; + let http: HttpTestingController; + let actionSpy: { + getJointGraphWrapper: ReturnType<typeof vi.fn>; + getWorkflow: ReturnType<typeof vi.fn>; + getWorkflowContent: ReturnType<typeof vi.fn>; + setTempWorkflow: ReturnType<typeof vi.fn>; + getTempWorkflow: ReturnType<typeof vi.fn>; + resetTempWorkflow: ReturnType<typeof vi.fn>; + reloadWorkflow: ReturnType<typeof vi.fn>; + enableWorkflowModification: ReturnType<typeof vi.fn>; + disableWorkflowModification: ReturnType<typeof vi.fn>; + checkWorkflowModificationEnabled: ReturnType<typeof vi.fn>; + }; + let persistSpy: { setWorkflowPersistFlag: ReturnType<typeof vi.fn> }; + let undoRedoSpy: { + clearRedoStack: ReturnType<typeof vi.fn>; + clearUndoStack: ReturnType<typeof vi.fn>; + enableWorkFlowModification: ReturnType<typeof vi.fn>; + disableWorkFlowModification: ReturnType<typeof vi.fn>; + }; + let highlightedSpy: { getCurrentHighlights: ReturnType<typeof vi.fn>; unhighlightElements: ReturnType<typeof vi.fn> }; + let modelAttr: ReturnType<typeof vi.fn>; + let paperGetModelById: ReturnType<typeof vi.fn>; + let getMainJointPaper: ReturnType<typeof vi.fn>; beforeEach(() => { + modelAttr = vi.fn(); + paperGetModelById = vi.fn().mockReturnValue({ attr: modelAttr }); + getMainJointPaper = vi.fn().mockReturnValue({ getModelById: paperGetModelById }); + highlightedSpy = { + getCurrentHighlights: vi.fn().mockReturnValue(["a", "b"]), + unhighlightElements: vi.fn(), + }; + actionSpy = { + getJointGraphWrapper: vi.fn().mockReturnValue({ + getCurrentHighlights: highlightedSpy.getCurrentHighlights, + unhighlightElements: highlightedSpy.unhighlightElements, + getMainJointPaper, + }), + getWorkflow: vi.fn().mockReturnValue(buildWorkflow()), + getWorkflowContent: vi.fn().mockReturnValue(buildContent()), + setTempWorkflow: vi.fn(), + getTempWorkflow: vi.fn().mockReturnValue(undefined), + resetTempWorkflow: vi.fn(), + reloadWorkflow: vi.fn(), + enableWorkflowModification: vi.fn(), + disableWorkflowModification: vi.fn(), + checkWorkflowModificationEnabled: vi.fn().mockReturnValue(true), + }; + persistSpy = { setWorkflowPersistFlag: vi.fn() }; + undoRedoSpy = { + clearRedoStack: vi.fn(), + clearUndoStack: vi.fn(), + enableWorkFlowModification: vi.fn(), + disableWorkFlowModification: vi.fn(), + }; + TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [WorkflowVersionService, WorkflowActionService, OperatorMetadataService, ...commonTestProviders], + providers: [ + WorkflowVersionService, + { provide: WorkflowActionService, useValue: actionSpy }, + { provide: WorkflowPersistService, useValue: persistSpy }, + { provide: UndoRedoService, useValue: undoRedoSpy }, + ...commonTestProviders, + ], }); service = TestBed.inject(WorkflowVersionService); + http = TestBed.inject(HttpTestingController); + vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API); + }); + + afterEach(() => { + http.verify(); }); + // ─── canRestoreVersion getter ───────────────────────────────────────────── + describe("canRestoreVersion", () => { - it("should return true when modificationEnabledBeforeTempWorkflow is true", () => { - // Arrange - service["modificationEnabledBeforeTempWorkflow"] = true; + it("is false when no readonly display has been entered", () => { + expect(service.canRestoreVersion).toBe(false); + }); + + it("is true after entering readonly display from a modification-enabled state", () => { + actionSpy.checkWorkflowModificationEnabled.mockReturnValue(true); + service.displayReadonlyWorkflow(buildWorkflow()); + expect(service.canRestoreVersion).toBe(true); + }); + + it("is false after entering readonly display from an already-disabled state", () => { + actionSpy.checkWorkflowModificationEnabled.mockReturnValue(false); + service.displayReadonlyWorkflow(buildWorkflow()); + expect(service.canRestoreVersion).toBe(false); + }); + }); + + // ─── setDisplayParticularVersion + streams ──────────────────────────────── + + describe("setDisplayParticularVersion", () => { + it("publishes both ids and the flag when entering a particular version", async () => { + const flag = firstValueFrom(service.getDisplayParticularVersionStream().pipe(take(2), toArray())); + service.setDisplayParticularVersion(true, 7, 9); + expect(service.selectedVersionId.getValue()).toBe(7); + expect(service.selectedDisplayedVersionId.getValue()).toBe(9); + expect(await flag).toEqual([false, true]); + }); + + it("ignores undefined version ids but still flips the flag", () => { + service.setDisplayParticularVersion(true); + expect(service.selectedVersionId.getValue()).toBeNull(); + expect(service.selectedDisplayedVersionId.getValue()).toBeNull(); + }); + + it("nulls both ids when leaving a particular version", () => { + service.setDisplayParticularVersion(true, 7, 9); + service.setDisplayParticularVersion(false); + expect(service.selectedVersionId.getValue()).toBeNull(); + expect(service.selectedDisplayedVersionId.getValue()).toBeNull(); + }); + }); - // Act - const result = service.canRestoreVersion; + // ─── displayWorkflowVersions ────────────────────────────────────────────── - // Assert - expect(result).toBe(true); + it("displayWorkflowVersions unhighlights whatever is currently highlighted", () => { + service.displayWorkflowVersions(); + expect(highlightedSpy.getCurrentHighlights).toHaveBeenCalled(); + expect(highlightedSpy.unhighlightElements).toHaveBeenCalledWith(["a", "b"]); + }); + + // ─── displayReadonlyWorkflow ────────────────────────────────────────────── + + describe("displayReadonlyWorkflow", () => { + it("snapshots the current workflow, disables persist+undo, then reloads readonly", () => { + const live = buildWorkflow({ name: "live" }); + const incoming = buildWorkflow({ name: "incoming" }); + actionSpy.getWorkflow.mockReturnValue(live); + + service.displayReadonlyWorkflow(incoming); + + expect(actionSpy.setTempWorkflow).toHaveBeenCalledWith(live); + expect(persistSpy.setWorkflowPersistFlag).toHaveBeenCalledWith(false); + expect(undoRedoSpy.disableWorkFlowModification).toHaveBeenCalled(); + expect(actionSpy.reloadWorkflow).toHaveBeenCalledWith(incoming); + expect(actionSpy.disableWorkflowModification).toHaveBeenCalled(); + }); + + it("only captures the modification state once across nested calls", () => { + actionSpy.checkWorkflowModificationEnabled.mockReturnValue(true); + service.displayReadonlyWorkflow(buildWorkflow()); + + // Second entry must not overwrite the snapshot to `false`. + actionSpy.checkWorkflowModificationEnabled.mockReturnValue(false); + service.displayReadonlyWorkflow(buildWorkflow()); + + expect(service.canRestoreVersion).toBe(true); + }); + }); + + // ─── displayParticularVersion ───────────────────────────────────────────── + + it("displayParticularVersion diffs, swaps the paper, and emits the flag", () => { + const liveContent = buildContent({ operators: [buildOperator({ operatorID: "live" })] }); + const versionContent = buildContent({ operators: [buildOperator({ operatorID: "old" })] }); + actionSpy.getWorkflowContent.mockReturnValue(liveContent); + const versionWorkflow = buildWorkflow({ content: versionContent }); + + service.displayParticularVersion(versionWorkflow, 5, 6); + + expect(actionSpy.reloadWorkflow).toHaveBeenCalledWith(versionWorkflow); + expect(service.selectedVersionId.getValue()).toBe(5); + expect(service.selectedDisplayedVersionId.getValue()).toBe(6); + }); + + // ─── highlightOpBoundary / highlightOpBracket ───────────────────────────── + + describe("highlight helpers", () => { + it("highlightOpBoundary writes rect.boundary/fill with the rgba color", () => { + service.highlightOpBoundary("op-1", "1,2,3,0.5"); + expect(paperGetModelById).toHaveBeenCalledWith("op-1"); + expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(1,2,3,0.5)"); }); - it("should return false when modificationEnabledBeforeTempWorkflow is undefined", () => { - // Arrange - service["modificationEnabledBeforeTempWorkflow"] = undefined; + it("highlightOpBracket writes the position-prefixed stroke attribute", () => { + service.highlightOpBracket("op-1", "1,2,3,0.5", "left-"); + expect(modelAttr).toHaveBeenCalledWith("path.left-boundary/stroke", "rgba(1,2,3,0.5)"); + }); + + it("is a no-op when the joint paper is not yet bound", () => { + getMainJointPaper.mockReturnValue(undefined); + service.highlightOpBoundary("op-1", "1,2,3,0.5"); + expect(paperGetModelById).not.toHaveBeenCalled(); + }); + }); + + // ─── highlightOpVersionDiff ─────────────────────────────────────────────── + + describe("highlightOpVersionDiff", () => { + it("colors modified ops orange and added ops green", () => { + service.highlightOpVersionDiff({ modified: ["m"], added: ["a"], deleted: [] }); - // Act - const result = service.canRestoreVersion; + expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(255,118,20,0.5)"); + expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(0,255,0,0.5)"); + }); + + it("draws left/right red brackets around the neighbors of deleted ops", () => { + const tempWorkflow = buildWorkflow({ + content: buildContent({ + operators: [buildOperator({ operatorID: "alive-left" }), buildOperator({ operatorID: "alive-right" })], + links: [buildLink("dead", "alive-right"), buildLink("alive-left", "dead")], + }), + }); + actionSpy.getTempWorkflow.mockReturnValue(tempWorkflow); + + service.highlightOpVersionDiff({ modified: [], added: [], deleted: ["dead"] }); + + expect(modelAttr).toHaveBeenCalledWith("path.left-boundary/stroke", "rgba(255,0,0,0.5)"); + expect(modelAttr).toHaveBeenCalledWith("path.right-boundary/stroke", "rgba(255,0,0,0.5)"); + }); + + it("skips bracket drawing when the temp workflow is missing", () => { + actionSpy.getTempWorkflow.mockReturnValue(undefined); + + service.highlightOpVersionDiff({ modified: [], added: [], deleted: ["dead"] }); + + expect(getMainJointPaper).not.toHaveBeenCalled(); + }); + }); + + // ─── getWorkflowsDifference / getOperatorsDifference ────────────────────── + + describe("getWorkflowsDifference", () => { + it("classifies operators into added, deleted, and modified", () => { + // Forward diff semantics: ids present in arg2 but not arg1 are "added" + // (to go from arg1 to arg2 you'd add them), ids in arg1 only are + // "deleted", and ids in both with differing content are "modified". + const a1 = buildOperator({ operatorID: "stay", operatorProperties: { a: 1 } }); + const a2 = buildOperator({ operatorID: "stay", operatorProperties: { a: 2 } }); + const onlyInArg1 = buildOperator({ operatorID: "in-arg1-only" }); + const onlyInArg2 = buildOperator({ operatorID: "in-arg2-only" }); + + const diff = service.getWorkflowsDifference( + buildContent({ operators: [a1, onlyInArg1] }), + buildContent({ operators: [a2, onlyInArg2] }) + ); + + expect(diff.added.sort()).toEqual(["in-arg2-only"]); + expect(diff.modified.sort()).toEqual(["stay"]); + expect(diff.deleted.sort()).toEqual(["in-arg1-only"]); + }); + + it("returns empty diffs when the contents are identical", () => { + const op = buildOperator(); + const diff = service.getWorkflowsDifference(buildContent({ operators: [op] }), buildContent({ operators: [op] })); + expect(diff).toEqual({ added: [], modified: [], deleted: [] }); + expect(service.operatorPropertyDiff).toEqual({}); + }); + + it("records per-property and version-bump diffs in operatorPropertyDiff", () => { + const live = buildOperator({ + operatorID: "x", + operatorVersion: "v2", + operatorProperties: { foo: "a", bar: 1 }, + }); + const old = buildOperator({ + operatorID: "x", + operatorVersion: "v1", + operatorProperties: { foo: "b", bar: 1 }, + }); + + service.getWorkflowsDifference(buildContent({ operators: [live] }), buildContent({ operators: [old] })); + + const diffMap = service.operatorPropertyDiff["x"]; + expect(diffMap.get("foo" as unknown as String)).toContain("rgb(255, 118, 20)"); + expect(diffMap.has("bar" as unknown as String)).toBe(false); + expect(diffMap.has("operatorVersion" as unknown as String)).toBe(true); + }); + }); + + // ─── revertToVersion / closeReadonlyWorkflowDisplay / closeParticular ───── + + describe("close + revert helpers", () => { + it("revertToVersion clears stacks, re-enables modification, and exits readonly mode", () => { + service.setDisplayParticularVersion(true, 4, 8); + service["differentOpIDsList"] = { modified: ["m"], added: ["a"], deleted: [] }; + + service.revertToVersion(); + + expect(undoRedoSpy.clearRedoStack).toHaveBeenCalled(); + expect(undoRedoSpy.clearUndoStack).toHaveBeenCalled(); + expect(actionSpy.enableWorkflowModification).toHaveBeenCalled(); + expect(actionSpy.resetTempWorkflow).toHaveBeenCalled(); + expect(persistSpy.setWorkflowPersistFlag).toHaveBeenCalledWith(true); + expect(service.selectedVersionId.getValue()).toBeNull(); + expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(0,0,0,0)"); + }); + + it("closeReadonlyWorkflowDisplay restores the previous workflow and re-enables undo", () => { + const previous = buildWorkflow({ name: "previous" }); + actionSpy.getTempWorkflow.mockReturnValue(previous); + + service.closeReadonlyWorkflowDisplay(); + + expect(actionSpy.enableWorkflowModification).toHaveBeenCalled(); + expect(undoRedoSpy.disableWorkFlowModification).toHaveBeenCalled(); + expect(actionSpy.reloadWorkflow).toHaveBeenCalledWith(previous); + expect(actionSpy.resetTempWorkflow).toHaveBeenCalled(); + expect(undoRedoSpy.enableWorkFlowModification).toHaveBeenCalled(); + expect(persistSpy.setWorkflowPersistFlag).toHaveBeenCalledWith(true); + }); + + it("closeParticularVersionDisplay unhighlights, restores, and flips the flag", () => { + service["differentOpIDsList"] = { modified: ["m"], added: [], deleted: [] }; + service.setDisplayParticularVersion(true, 1, 2); + + service.closeParticularVersionDisplay(); + + expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(0,0,0,0)"); + expect(actionSpy.reloadWorkflow).toHaveBeenCalled(); + expect(service.selectedVersionId.getValue()).toBeNull(); + }); + + it("restoreModificationState re-disables modification when the snapshot was false", () => { + actionSpy.checkWorkflowModificationEnabled.mockReturnValue(false); + service.displayReadonlyWorkflow(buildWorkflow()); + actionSpy.disableWorkflowModification.mockClear(); + + service.closeReadonlyWorkflowDisplay(); + + expect(actionSpy.disableWorkflowModification).toHaveBeenCalled(); + actionSpy.disableWorkflowModification.mockClear(); + service.closeReadonlyWorkflowDisplay(); + expect(actionSpy.disableWorkflowModification).not.toHaveBeenCalled(); + }); + }); + + // ─── HTTP endpoints ─────────────────────────────────────────────────────── + + describe("HTTP", () => { + it("retrieveVersionsOfWorkflow GETs /version/{wid}", async () => { + const entries: WorkflowVersionEntry[] = [{ vId: 1, creationTime: 0, content: "{}", importance: false }]; + const pending = firstValueFrom(service.retrieveVersionsOfWorkflow(42)); + const req = http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/42`); + expect(req.request.method).toBe("GET"); + req.flush(entries); + expect(await pending).toEqual(entries); + }); + + it("retrieveWorkflowByVersion parses a string `content` into an object", async () => { + const pending = firstValueFrom(service.retrieveWorkflowByVersion(42, 7)); + const req = http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/42/7`); + expect(req.request.method).toBe("GET"); + req.flush({ + ...buildWorkflow(), + content: + '{"operators":[],"operatorPositions":{},"links":[],"commentBoxes":[],"settings":{"dataTransferBatchSize":400,"executionMode":"PIPELINED"}}', + }); + const result = await pending; + expect(typeof result.content).toBe("object"); + expect(result.content.operators).toEqual([]); + }); + + it("retrieveWorkflowByVersion drops null payloads (filter blocks the emission)", () => { + let nextCalled = false; + let completed = false; + service.retrieveWorkflowByVersion(42, 7).subscribe({ + next: () => (nextCalled = true), + complete: () => (completed = true), + }); + http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/42/7`).flush(null); + expect(nextCalled).toBe(false); + expect(completed).toBe(true); + }); - // Assert - expect(result).toBe(false); + it("cloneWorkflowVersion POSTs to /version/clone/{vid} with the displayed id", async () => { + service.setDisplayParticularVersion(true, 11, 12); + const pending = firstValueFrom(service.cloneWorkflowVersion()); + const req = http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/clone/11`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ displayedVersionId: 12 }); + req.flush(99); + expect(await pending).toBe(99); }); }); });
