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 802a388d28 test(frontend): cover aborting a dataset upload and its 
conflict retry (#7416)
802a388d28 is described below

commit 802a388d28befb53becb764aee1e0af5fff01117
Author: Xinyuan Lin <[email protected]>
AuthorDate: Fri Aug 7 22:03:54 2026 -0700

    test(frontend): cover aborting a dataset upload and its conflict retry 
(#7416)
    
    ### What changes were proposed in this PR?
    
    `onClickAbortUploadProgress` was the largest uncovered block in
    `DatasetDetailComponent` and the one with the most ways to go wrong.
    Aborting an in-flight upload has to survive the backend still finalizing
    a previous attempt, so the abort is retried on 409:
    
    | Response | Behaviour |
    |---|---|
    | success | notify, report the abort |
    | 404 | already gone — report the abort, no error |
    | 409, attempt < `ABORT_RETRY_MAX_ATTEMPTS` | retry after
    `ABORT_RETRY_BACKOFF_BASE_MS * (attempt + 1)` |
    | 409 at the limit, or any other status | give up, but still report the
    abort |
    
    Adds 9 tests over that ladder plus the surrounding bookkeeping: the
    abort flag on the request, the task moving to `aborted`, the progress
    subscription being dropped so a late event cannot resurrect it, the
    concurrency slot being released so a queued upload starts, and
    `cancelExistingUpload` delegating here for an upload still running. Both
    constants are exported, so the backoff growth and the attempt bound are
    asserted rather than hard-coded.
    
    **Verified by mutation**, all reverted (production diff empty):
    
    | Mutation | Result |
    |---|---|
    | never retry on conflict | red |
    | make the retry unbounded | red |
    | use a constant backoff instead of a growing one | red |
    | skip the unsubscribe | red |
    | leave the task unmarked | red |
    | send the abort flag as false | red |
    | drop the `onUploadComplete()` that frees the slot | red |
    | remove the 404 early return | **survived** |
    | remove the `doneCalled` idempotence guard | **survived** |
    
    The two survivors are reported rather than papered over, because they
    are informative:
    
    - **The 404 early return is behaviourally redundant.** Without it a 404
    falls past the 409 check to the same `done()` at the bottom, so no input
    distinguishes the two. The test still earns its place — it fails if 404
    is ever turned into an error path — but it does not pin the branch
    itself.
    - **The `doneCalled` guard is not reachable.** Exactly one of the
    `next`/404/fallback paths fires per response, and each retry replaces
    the subscription, so `done()` is never invoked twice. It is defensive
    code with no observable behaviour at this level.
    
    The slot-release mutation survived my first pass too; unlike the other
    two that was a genuine gap, so I added the test that covers it.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7413
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/dataset-detail.component.spec.ts"
    ```
    
    ```
     Test Files  1 passed (1)
          Tests  104 passed (104)
    ```
    
    9 new on top of the existing 95. `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../dataset-detail.component.spec.ts               | 146 ++++++++++++++++++++-
 1 file changed, 145 insertions(+), 1 deletion(-)

diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
index 88e0937ac8..43d9481167 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -22,7 +22,11 @@ import { ActivatedRoute, Router } from "@angular/router";
 import { of, Subject, throwError } from "rxjs";
 import { NzModalService } from "ng-zorro-antd/modal";
 import { MarkdownService } from "ngx-markdown";
-import { DatasetDetailComponent } from "./dataset-detail.component";
+import {
+  DatasetDetailComponent,
+  ABORT_RETRY_BACKOFF_BASE_MS,
+  ABORT_RETRY_MAX_ATTEMPTS,
+} from "./dataset-detail.component";
 import { DatasetService, MultipartUploadProgress } from 
"../../../../service/user/dataset/dataset.service";
 import { NotificationService } from 
"../../../../../common/service/notification/notification.service";
 import { DownloadService } from 
"../../../../service/user/download/download.service";
@@ -131,6 +135,146 @@ describe("DatasetDetailComponent upload queue", () => {
     fixture.detectChanges();
   });
 
+  /**
+   * Aborting an in-flight upload has to survive the backend still finalizing 
the previous attempt:
+   * the abort call is retried on 409 up to ABORT_RETRY_MAX_ATTEMPTS, a 404 
means it is already gone,
+   * and the caller's callback must fire exactly once down every one of those 
paths.
+   */
+  describe("aborting an upload", () => {
+    let finalize: ReturnType<typeof vi.fn>;
+
+    beforeEach(() => {
+      vi.useFakeTimers();
+      finalize = TestBed.inject(DatasetService).finalizeMultipartUpload as 
unknown as ReturnType<typeof vi.fn>;
+    });
+
+    afterEach(() => {
+      vi.useRealTimers();
+    });
+
+    /** Starts an upload and reports progress, leaving one task in flight. */
+    function inFlight(name = "a.txt") {
+      dropFiles(name);
+      uploadSubjects[0].next({ filePath: name, percentage: 10, status: 
"uploading", totalTime: 0 });
+      return component.uploadTasks.find(t => t.filePath === name)!;
+    }
+
+    const conflict = () => throwError(() => ({ status: 409 }) as any);
+    const gone = () => throwError(() => ({ status: 404 }) as any);
+
+    it("marks the task aborted and tells the caller once", () => {
+      const task = inFlight();
+      const onAborted = vi.fn();
+
+      component.onClickAbortUploadProgress(task as any, onAborted);
+
+      expect(finalize).toHaveBeenCalledWith("[email protected]", 
"test-dataset", "a.txt", true);
+      expect(component.uploadTasks.find(t => t.filePath === 
"a.txt")!.status).toBe("aborted");
+      expect(onAborted).toHaveBeenCalledTimes(1);
+    });
+
+    it("stops listening to the upload it aborted", () => {
+      const task = inFlight();
+
+      component.onClickAbortUploadProgress(task as any);
+
+      // The progress stream is unsubscribed, so a late event cannot resurrect 
the task.
+      uploadSubjects[0].next({ filePath: "a.txt", percentage: 100, status: 
"finished", totalTime: 1 });
+      expect(component.uploadTasks.find(t => t.filePath === 
"a.txt")!.status).toBe("aborted");
+    });
+
+    it("treats a 404 as already aborted rather than an error", () => {
+      finalize.mockReturnValueOnce(gone());
+      const task = inFlight();
+      const onAborted = vi.fn();
+
+      component.onClickAbortUploadProgress(task as any, onAborted);
+
+      expect(onAborted).toHaveBeenCalledTimes(1);
+      expect(finalize).toHaveBeenCalledTimes(1);
+    });
+
+    it("retries a 409 after a backoff and finishes once the server catches 
up", () => {
+      // The server is still finalizing the previous attempt; the abort has to 
wait it out.
+      finalize.mockReturnValueOnce(conflict());
+      const task = inFlight();
+      const onAborted = vi.fn();
+
+      component.onClickAbortUploadProgress(task as any, onAborted);
+      expect(onAborted).not.toHaveBeenCalled();
+
+      vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS);
+
+      expect(finalize).toHaveBeenCalledTimes(2);
+      expect(onAborted).toHaveBeenCalledTimes(1);
+    });
+
+    it("backs off further on each successive conflict", () => {
+      finalize.mockReturnValue(conflict());
+      const task = inFlight();
+
+      component.onClickAbortUploadProgress(task as any);
+      expect(finalize).toHaveBeenCalledTimes(1);
+
+      // First wait is BASE * 1, the second BASE * 2, so BASE alone is not 
enough for the third call.
+      vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS);
+      expect(finalize).toHaveBeenCalledTimes(2);
+
+      vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS);
+      expect(finalize).toHaveBeenCalledTimes(2);
+
+      vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS);
+      expect(finalize).toHaveBeenCalledTimes(3);
+    });
+
+    it("gives up after the attempt limit but still reports the abort", () => {
+      // Without the bound this would retry forever against a permanently 
conflicted server.
+      finalize.mockReturnValue(conflict());
+      const task = inFlight();
+      const onAborted = vi.fn();
+
+      component.onClickAbortUploadProgress(task as any, onAborted);
+      vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS * 
ABORT_RETRY_MAX_ATTEMPTS * (ABORT_RETRY_MAX_ATTEMPTS + 1));
+
+      expect(finalize).toHaveBeenCalledTimes(ABORT_RETRY_MAX_ATTEMPTS + 1);
+      expect(onAborted).toHaveBeenCalledTimes(1);
+    });
+
+    it("reports the abort once even on an error the retry does not cover", () 
=> {
+      finalize.mockReturnValueOnce(throwError(() => ({ status: 500 }) as any));
+      const task = inFlight();
+      const onAborted = vi.fn();
+
+      component.onClickAbortUploadProgress(task as any, onAborted);
+
+      expect(onAborted).toHaveBeenCalledTimes(1);
+      expect(finalize).toHaveBeenCalledTimes(1);
+    });
+
+    it("frees the concurrency slot so a queued upload can start", () => {
+      // Aborting has to release the slot as an ordinary completion would; 
otherwise the queue
+      // stalls behind an upload that is no longer running.
+      dropFiles("a.txt", "b.txt", "c.txt", "d.txt");
+      expect(uploadedPaths).toEqual(["a.txt", "b.txt", "c.txt"]);
+      uploadSubjects[0].next({ filePath: "a.txt", percentage: 10, status: 
"uploading", totalTime: 0 });
+      const task = component.uploadTasks.find(t => t.filePath === "a.txt")!;
+
+      component.onClickAbortUploadProgress(task as any);
+
+      expect(uploadedPaths).toContain("d.txt");
+    });
+
+    it("cancelExistingUpload aborts an upload that is still running", () => {
+      inFlight("b.txt");
+      const onCanceled = vi.fn();
+
+      component.cancelExistingUpload("b.txt", onCanceled);
+
+      expect(finalize).toHaveBeenCalledWith("[email protected]", 
"test-dataset", "b.txt", true);
+      expect(onCanceled).toHaveBeenCalledTimes(1);
+    });
+  });
+
   describe("contributor cards", () => {
     const full: Contributor = {
       name: "Contributor A",

Reply via email to