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-6740-3da5f49c0a3727b1b57756265e84a5957551c036 in repository https://gitbox.apache.org/repos/asf/texera.git
commit fb4a0e2ba17a5fae6989be1b209b3d07d19384e0 Author: Xinyuan Lin <[email protected]> AuthorDate: Tue Jul 21 20:57:56 2026 -0700 test(frontend): extend DatasetService unit test coverage (#6740) ### What changes were proposed in this PR? Extends `dataset.service.spec.ts` with 8 tests (40 -> 48) covering `getDatasetCoverUrl` and the `multipartUpload` internals (upload-progress events, HTTP 200 / non-2xx branches, auth Bearer header, sparse-init nullish defaults, empty-file path, and the `calculateStats` throttle/smoothing/ETA-cap logic). Coverage 83% -> ~100%. No existing tests modified. ### Any related issues, documentation, discussions? Closes #6733. ### How was this PR tested? `ng test --include='**/dataset.service.spec.ts'` -> 48/48 passing. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --------- Signed-off-by: Xinyuan Lin <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../service/user/dataset/dataset.service.spec.ts | 246 ++++++++++++++++++++- 1 file changed, 243 insertions(+), 3 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 index c5ec10cd7c..b71ea4a5d2 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts @@ -21,8 +21,9 @@ import { TestBed } from "@angular/core/testing"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { firstValueFrom } from "rxjs"; -import { DATASET_BASE_URL, DatasetService, validateDatasetName } from "./dataset.service"; +import { DATASET_BASE_URL, DatasetService, MultipartUploadProgress, validateDatasetName } from "./dataset.service"; import { AppSettings } from "../../../../common/app-setting"; +import { AuthService } from "../../../../common/service/user/auth.service"; import { commonTestProviders } from "../../../../common/testing/test-utils"; import { Dataset, DatasetVersion } from "../../../../common/type/dataset"; import { DashboardDataset } from "../../../type/dashboard-dataset.interface"; @@ -77,18 +78,25 @@ const SAMPLE_FILE_NODES: DatasetFileNode[] = [ class FakeXMLHttpRequest { static instances: FakeXMLHttpRequest[] = []; + // Capturing upload target so tests can drive `upload.progress` events. readonly upload = { - addEventListener: vi.fn(), + listeners: new Map<string, EventListener[]>(), + addEventListener(type: string, listener: EventListener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + }, }; status = 0; url = ""; + readonly requestHeaders = new Map<string, string>(); private listeners = new Map<string, EventListener[]>(); open(_method: string, url: string): void { this.url = url; } - setRequestHeader(): void {} + setRequestHeader(name: string, value: string): void { + this.requestHeaders.set(name, value); + } send(): void { FakeXMLHttpRequest.instances.push(this); @@ -100,6 +108,14 @@ class FakeXMLHttpRequest { this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); } + /** Drives the `upload.progress` listener registered by the service. */ + emitProgress(loaded: number, lengthComputable = true): void { + const event = { lengthComputable, loaded } as unknown as Event; + for (const listener of this.upload.listeners.get("progress") ?? []) { + listener(event); + } + } + respond(status: number): void { this.status = status; this.emit("load"); @@ -508,4 +524,228 @@ describe("DatasetService", () => { expect(req.request.body).toEqual({ coverImage: "data:image/png;base64,ZGF0YQ==" }); req.flush({}); }); + + // ─── getDatasetCoverUrl ─────────────────────────────────────────────────── + + it("getDatasetCoverUrl GETs /dataset/{did}/cover-url and returns the mapped payload", async () => { + const pending = firstValueFrom(service.getDatasetCoverUrl(4)); + const req = http.expectOne(`${API}/dataset/4/cover-url`); + expect(req.request.method).toBe("GET"); + req.flush({ url: "https://img.example/cover.png" }); + expect(await pending).toEqual({ url: "https://img.example/cover.png" }); + }); + + it("getDatasetCoverUrl passes through a null url", async () => { + const pending = firstValueFrom(service.getDatasetCoverUrl(4)); + http.expectOne(`${API}/dataset/4/cover-url`).flush({ url: null }); + expect(await pending).toEqual({ url: null }); + }); + + // ─── multipartUpload: progress / stats / load / error branches ──────────── + + const isInit = (r: { url: string; params: { get(k: string): string | null } }) => + r.url === `${API}/${DATASET_BASE_URL}/multipart-upload` && r.params.get("type") === "init"; + const isFinish = (r: { url: string; params: { get(k: string): string | null } }) => + r.url === `${API}/${DATASET_BASE_URL}/multipart-upload` && r.params.get("type") === "finish"; + + it("multipartUpload emits uploading progress, attaches the auth header, and finishes on HTTP 200", async () => { + const tokenSpy = vi.spyOn(AuthService, "getAccessToken").mockReturnValue("tok123"); + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + FakeXMLHttpRequest.instances = []; + try { + const file = new File([new Uint8Array(8)], "d.bin"); // 8 bytes, partSize 8 => 1 part + const emissions: MultipartUploadProgress[] = []; + const done = new Promise<void>((resolve, reject) => { + service.multipartUpload("[email protected]", "ds", "d.bin", file, 8, 1, false).subscribe({ + next: p => emissions.push(p), + error: (error: unknown): void => reject(error), + complete: resolve, + }); + }); + + http.expectOne(isInit).flush({ missingParts: [1], completedPartsCount: 0 }); + + const xhr = FakeXMLHttpRequest.instances[0]; + expect(xhr.requestHeaders.get("Content-Type")).toBe("application/octet-stream"); + expect(xhr.requestHeaders.get("Authorization")).toBe("Bearer tok123"); + + xhr.emitProgress(4); // half a part uploaded -> "uploading" emission + xhr.respond(200); // load handler takes the 200 branch + http.expectOne(isFinish).flush({}); + await done; + + const uploading = emissions.filter(e => e.status === "uploading"); + expect(uploading.length).toBeGreaterThan(0); + expect(uploading.some(e => e.percentage > 0 && e.percentage <= 99)).toBe(true); + expect(emissions.at(-1)).toMatchObject({ status: "finished", percentage: 100 }); + } finally { + tokenSpy.mockRestore(); + } + }); + + it("multipartUpload errors out when a part upload load returns a non-2xx status", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + FakeXMLHttpRequest.instances = []; + const file = new File([new Uint8Array(4)], "e.bin"); + const emissions: MultipartUploadProgress[] = []; + const outcome = new Promise<unknown>(resolve => { + service.multipartUpload("[email protected]", "ds", "e.bin", file, 4, 1, false).subscribe({ + next: p => emissions.push(p), + error: (error: unknown): void => resolve(error), + complete: () => resolve(null), + }); + }); + + http.expectOne(isInit).flush({ missingParts: [1], completedPartsCount: 0 }); + FakeXMLHttpRequest.instances[0].respond(500); + + const err = await outcome; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("HTTP 500"); + expect(emissions.at(-1)).toMatchObject({ status: "failed", percentage: 0 }); + }); + + it("multipartUpload tolerates a sparse init payload and finishes when no parts are missing", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + FakeXMLHttpRequest.instances = []; + const file = new File([new Uint8Array(4)], "c.bin"); + const emissions: MultipartUploadProgress[] = []; + const done = new Promise<void>((resolve, reject) => { + service.multipartUpload("[email protected]", "ds", "c.bin", file, 4, 1, false).subscribe({ + next: p => emissions.push(p), + error: reject, + complete: resolve, + }); + }); + + // Missing `missingParts` / `completedPartsCount` exercise the nullish-coalescing defaults. + http.expectOne(isInit).flush({}); + expect(FakeXMLHttpRequest.instances.length).toBe(0); + http.expectOne(isFinish).flush({}); + await done; + + expect(emissions[0]).toMatchObject({ status: "initializing", percentage: 0 }); + expect(emissions.at(-1)).toMatchObject({ status: "finished", percentage: 100 }); + }); + + it("multipartUpload reports a 0% failure when the finish call errors for an empty file", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + FakeXMLHttpRequest.instances = []; + const file = new File([], "empty.bin"); // size 0 => partCount 0 (partCount>0 false branch) + const emissions: MultipartUploadProgress[] = []; + const outcome = new Promise<unknown>(resolve => { + service.multipartUpload("[email protected]", "ds", "empty.bin", file, 4, 1, false).subscribe({ + next: p => emissions.push(p), + error: (error: unknown): void => resolve(error), + complete: () => resolve(null), + }); + }); + + http.expectOne(isInit).flush({ missingParts: [], completedPartsCount: 0 }); + expect(FakeXMLHttpRequest.instances.length).toBe(0); + http.expectOne(isFinish).error(new ProgressEvent("error")); + + const err = await outcome; + expect(err).not.toBeNull(); + expect(emissions[0]).toMatchObject({ status: "initializing", percentage: 0 }); + expect(emissions.at(-1)).toMatchObject({ status: "failed", percentage: 0 }); + }); + + it("multipartUpload smooths, throttles and shifts the progress statistics across events", async () => { + let now = 1_000_000; + const dateSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + FakeXMLHttpRequest.instances = []; + try { + const file = new File([new Uint8Array(100)], "big.bin"); // 100 bytes, partSize 100 => 1 part + const emissions: MultipartUploadProgress[] = []; + const done = new Promise<void>((resolve, reject) => { + service.multipartUpload("[email protected]", "ds", "big.bin", file, 100, 1, false).subscribe({ + next: p => emissions.push(p), + error: (error: unknown): void => reject(error), + complete: resolve, + }); + }); + + http.expectOne(isInit).flush({ missingParts: [1], completedPartsCount: 0 }); + const xhr = FakeXMLHttpRequest.instances[0]; + + xhr.emitProgress(5, false); // non-lengthComputable -> ignored + xhr.emitProgress(10); // first update: startTime set, elapsed 0 + xhr.emitProgress(20); // same timestamp -> throttled (returns cached stats) + now = 1_002_000; + xhr.emitProgress(30); // elapsed>0 so speed/avg become positive + now = 1_003_000; + xhr.emitProgress(90); // large ETA drop -> smoothing clamps the change + now = 1_004_000; + xhr.emitProgress(99); // >95% complete -> ETA capped to 10s + now = 1_005_000; + xhr.emitProgress(99); + now = 1_006_000; + xhr.emitProgress(99); // 6th sample -> speedSamples window shifts + xhr.respond(200); + http.expectOne(isFinish).flush({}); + await done; + + const uploading = emissions.filter(e => e.status === "uploading"); + expect(uploading.length).toBeGreaterThan(0); + expect(uploading.every(e => e.percentage <= 99)).toBe(true); + // Once real elapsed time exists the smoothed speed becomes positive. + expect(uploading.some(e => (e.uploadSpeed ?? 0) > 0)).toBe(true); + // ETA is always reported as a non-negative integer number of seconds. + expect( + uploading.every(e => Number.isInteger(e.estimatedTimeRemaining) && (e.estimatedTimeRemaining ?? -1) >= 0) + ).toBe(true); + expect(emissions.at(-1)).toMatchObject({ + status: "finished", + percentage: 100, + uploadSpeed: 0, + estimatedTimeRemaining: 0, + }); + } finally { + dateSpy.mockRestore(); + } + }); + + it("multipartUpload clamps a sharply rising ETA to the +30% smoothing bound", async () => { + let now = 1_000_000; + const dateSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + FakeXMLHttpRequest.instances = []; + try { + const file = new File([new Uint8Array(100)], "rise.bin"); // 100 bytes, 1 part + const emissions: MultipartUploadProgress[] = []; + const done = new Promise<void>((resolve, reject) => { + service.multipartUpload("[email protected]", "ds", "rise.bin", file, 100, 1, false).subscribe({ + next: p => emissions.push(p), + error: (error: unknown): void => reject(error), + complete: resolve, + }); + }); + + http.expectOne(isInit).flush({ missingParts: [1], completedPartsCount: 0 }); + const xhr = FakeXMLHttpRequest.instances[0]; + + xhr.emitProgress(50); // first update: elapsed 0 -> ETA 0 + now = 1_001_000; + xhr.emitProgress(50); // fast sample -> ETA becomes small (~2s), sets lastETA + now = 1_100_000; + xhr.emitProgress(50); // huge elapsed collapses avg speed -> ETA rises >30% and is clamped up + xhr.respond(200); + http.expectOne(isFinish).flush({}); + await done; + + const uploading = emissions.filter(e => e.status === "uploading"); + expect(uploading.length).toBeGreaterThanOrEqual(3); + + const etaBefore = uploading[1].estimatedTimeRemaining ?? 0; + const etaAfter = uploading[2].estimatedTimeRemaining ?? 0; + + expect(etaBefore).toBeGreaterThan(0); + expect(etaAfter).toBeLessThanOrEqual(Math.round(etaBefore * 1.3)); + expect(emissions.at(-1)).toMatchObject({ status: "finished", percentage: 100 }); + } finally { + dateSpy.mockRestore(); + } + }); });
