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-6527-90eabda0d24a772b241ad97773c84aac9adaa680 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 11df960395d14765e23ffcec504471a9737d2fb9 Author: Meng Wang <[email protected]> AuthorDate: Sat Jul 18 18:34:49 2026 -0700 test(frontend): extend AdminSettingsComponent coverage for save/reset handlers (#6527) ### What changes were proposed in this PR? Extends the existing `AdminSettingsComponent` spec (previously only `should create` + a template check, ~35% coverage) to cover the untested save/reset handlers. `AdminSettingsService`, `NzMessageService`, and `NotificationService` are stubbed; fake timers keep the handlers' `window.location.reload()` from firing under jsdom. 15 added tests cover: - `saveLogos` — persists only the branding assets that are set + success message; no-op when none set; error message on failure. - `resetBranding` — resets `logo` / `mini_logo` / `favicon`. - `saveTabs` / `resetTabs` — persist / reset every sidebar tab. - `saveDatasetSettings` — persists the four upload settings + success; rejects non-positive values; rejects a config that would exceed the 10,000-part limit (no save in either). - `resetDatasetSettings` — resets all four upload settings. - `saveCsvSettings` — persists the max-columns value + success; error on failure. - `resetCsvSettings` — resets the max-columns setting. - `onFileChange` — rejects a non-image file with an error; reads a valid image into the matching branding field. No production code was changed. ### Any related issues, documentation, discussions? Closes #6517 ### How was this PR tested? Extended unit tests, run locally in `frontend/` (all green; the failure path was verified by deliberately breaking an assertion to confirm the suite goes red): ``` ng test --watch=false --include src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts # Test Files 1 passed (1) | Tests 17 passed (17) prettier --write <spec> # unchanged eslint <spec> # clean ``` ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../settings/admin-settings.component.spec.ts | 295 +++++++++++++++++++++ 1 file changed, 295 insertions(+) diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts index 69daa21716..daf3f7a112 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts @@ -22,6 +22,7 @@ import { AdminSettingsComponent } from "./admin-settings.component"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { NzCardModule } from "ng-zorro-antd/card"; import { NzMessageService } from "ng-zorro-antd/message"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; describe("AdminSettingsComponent", () => { let component: AdminSettingsComponent; @@ -123,4 +124,298 @@ describe("AdminSettingsComponent", () => { httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); expect(errorSpy).toHaveBeenCalledWith("Settings have not loaded; refresh before saving."); }); + + describe("save / reset handlers", () => { + let msgSuccess: ReturnType<typeof vi.fn>; + let msgError: ReturnType<typeof vi.fn>; + let msgInfo: ReturnType<typeof vi.fn>; + let notifySuccess: ReturnType<typeof vi.fn>; + let notifyError: ReturnType<typeof vi.fn>; + let notifyInfo: ReturnType<typeof vi.fn>; + + const updateUrl = (key: string) => `${SETTINGS_URL}/${key}`; + const resetUrl = (key: string) => `${SETTINGS_URL}/reset/${key}`; + const HTTP_ERROR = { status: 500, statusText: "Server Error" }; + + // Flush the ngOnInit bulk GET so the component is `settingsLoaded`. + function completeLoad(settings: Record<string, string> = {}): void { + httpTestingController.expectOne(SETTINGS_URL).flush(settings); + } + + beforeEach(() => { + // The reset/save handlers schedule window.location.reload() via setTimeout; + // fake timers keep it from firing (jsdom can't navigate). + vi.useFakeTimers(); + + const message = TestBed.inject(NzMessageService); + const notification = TestBed.inject(NotificationService); + msgSuccess = vi.spyOn(message, "success").mockReturnValue({} as ReturnType<NzMessageService["success"]>); + msgError = vi.spyOn(message, "error").mockReturnValue({} as ReturnType<NzMessageService["error"]>); + msgInfo = vi.spyOn(message, "info").mockReturnValue({} as ReturnType<NzMessageService["info"]>); + notifySuccess = vi.spyOn(notification, "success").mockImplementation(() => {}); + notifyError = vi.spyOn(notification, "error").mockImplementation(() => {}); + notifyInfo = vi.spyOn(notification, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + // nz-icon lazily fetches its SVG assets over HTTP; drain those so verify() + // only asserts on the requests the handlers under test actually issue. + httpTestingController.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + httpTestingController.verify(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe("branding", () => { + it("saveLogos PUTs only the branding assets that are set and notifies success", () => { + completeLoad(); + component.logoData = "logo.png"; + component.miniLogoData = "mini.png"; + component.faviconData = null; + + component.saveLogos(); + + const logoReq = httpTestingController.expectOne(updateUrl("logo")); + expect(logoReq.request.method).toBe("PUT"); + expect(logoReq.request.body).toEqual({ value: "logo.png" }); + const miniReq = httpTestingController.expectOne(updateUrl("mini_logo")); + expect(miniReq.request.body).toEqual({ value: "mini.png" }); + httpTestingController.expectNone(updateUrl("favicon")); + logoReq.flush(null); + miniReq.flush(null); + + expect(msgSuccess).toHaveBeenCalledWith("Branding saved successfully."); + }); + + it("saveLogos does nothing when no branding asset is set", () => { + completeLoad(); + component.logoData = null; + component.miniLogoData = null; + component.faviconData = null; + + component.saveLogos(); + + httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); + expect(msgSuccess).not.toHaveBeenCalled(); + }); + + it("saveLogos notifies an error when the request fails", () => { + completeLoad(); + component.logoData = "logo.png"; + component.miniLogoData = null; + component.faviconData = null; + + component.saveLogos(); + + httpTestingController.expectOne(updateUrl("logo")).flush("boom", HTTP_ERROR); + expect(msgError).toHaveBeenCalledWith("Failed to save branding."); + }); + + it("resetBranding POSTs a reset for all three branding settings", () => { + completeLoad(); + + component.resetBranding(); + + ["logo", "mini_logo", "favicon"].forEach(key => { + const req = httpTestingController.expectOne(resetUrl(key)); + expect(req.request.method).toBe("POST"); + req.flush(null); + }); + expect(msgInfo).toHaveBeenCalledWith("Resetting branding..."); + }); + }); + + describe("tabs", () => { + it("saveTabs PUTs every sidebar tab and notifies success", () => { + completeLoad(); + + component.saveTabs(); + + Object.keys(component.sidebarTabs).forEach(tab => { + const req = httpTestingController.expectOne(updateUrl(tab)); + expect(req.request.method).toBe("PUT"); + req.flush(null); + }); + expect(msgSuccess).toHaveBeenCalledWith("Tabs saved successfully."); + }); + + it("saveTabs notifies an error when a request fails", () => { + completeLoad(); + + component.saveTabs(); + + // Fail the last request; forkJoin errors only after the earlier ones + // have resolved, so every issued PUT is flushed (none left pending). + const tabs = Object.keys(component.sidebarTabs); + tabs.forEach((tab, i) => { + const req = httpTestingController.expectOne(updateUrl(tab)); + if (i === tabs.length - 1) req.flush("boom", HTTP_ERROR); + else req.flush(null); + }); + expect(msgError).toHaveBeenCalledWith("Failed to save tabs."); + }); + + it("resetTabs POSTs a reset for every sidebar tab", () => { + completeLoad(); + + component.resetTabs(); + + Object.keys(component.sidebarTabs).forEach(tab => { + httpTestingController.expectOne(resetUrl(tab)).flush(null); + }); + expect(msgInfo).toHaveBeenCalledWith("Resetting tabs..."); + }); + }); + + describe("dataset settings", () => { + it("saveDatasetSettings PUTs the four upload settings and notifies success", () => { + completeLoad(); // defaults (20 / 3 / 10 / 50) are valid + + component.saveDatasetSettings(); + + const expectPut = (key: string, value: string) => { + const req = httpTestingController.expectOne(updateUrl(key)); + expect(req.request.method).toBe("PUT"); + expect(req.request.body).toEqual({ value }); + req.flush(null); + }; + expectPut("max_number_of_concurrent_uploading_file", "3"); + expectPut("single_file_upload_max_size_mib", "20"); + expectPut("max_number_of_concurrent_uploading_file_chunks", "10"); + expectPut("multipart_upload_chunk_size_mib", "50"); + + expect(msgSuccess).toHaveBeenCalledWith("Dataset upload settings saved successfully."); + }); + + it("saveDatasetSettings rejects non-positive values without saving", () => { + completeLoad(); + component.maxFileSizeMiB = 0; + + component.saveDatasetSettings(); + + httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); + expect(msgError).toHaveBeenCalledWith("Please enter valid integer values."); + }); + + it("saveDatasetSettings rejects a configuration that would exceed the 10,000-part limit", () => { + completeLoad(); + component.maxFileSizeMiB = 100000; + component.chunkSizeMiB = 1; + + component.saveDatasetSettings(); + + httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); + expect(msgError).toHaveBeenCalled(); + }); + + it("saveDatasetSettings notifies an error when a request fails", () => { + completeLoad(); + + component.saveDatasetSettings(); + + // Fail the last of the four PUTs so forkJoin errors with every request flushed. + const keys = [ + "max_number_of_concurrent_uploading_file", + "single_file_upload_max_size_mib", + "max_number_of_concurrent_uploading_file_chunks", + "multipart_upload_chunk_size_mib", + ]; + keys.forEach((key, i) => { + const req = httpTestingController.expectOne(updateUrl(key)); + if (i === keys.length - 1) req.flush("boom", HTTP_ERROR); + else req.flush(null); + }); + expect(msgError).toHaveBeenCalledWith("Failed to save dataset settings."); + }); + + it("resetDatasetSettings POSTs a reset for all four upload settings", () => { + completeLoad(); + + component.resetDatasetSettings(); + + [ + "max_number_of_concurrent_uploading_file", + "single_file_upload_max_size_mib", + "max_number_of_concurrent_uploading_file_chunks", + "multipart_upload_chunk_size_mib", + ].forEach(key => httpTestingController.expectOne(resetUrl(key)).flush(null)); + expect(msgInfo).toHaveBeenCalledWith("Resetting dataset settings..."); + }); + }); + + describe("csv (result panel) settings", () => { + it("saveCsvSettings PUTs the max-columns value and notifies success", () => { + completeLoad(); + component.csvMaxColumns = 256; + + component.saveCsvSettings(); + + const req = httpTestingController.expectOne(updateUrl("csv_parser_max_columns")); + expect(req.request.method).toBe("PUT"); + expect(req.request.body).toEqual({ value: "256" }); + req.flush(null); + + expect(notifySuccess).toHaveBeenCalledWith("Result panel settings saved."); + }); + + it("saveCsvSettings notifies an error when the request fails", () => { + completeLoad(); + + component.saveCsvSettings(); + + httpTestingController.expectOne(updateUrl("csv_parser_max_columns")).flush("boom", HTTP_ERROR); + expect(notifyError).toHaveBeenCalledWith("Could not save result panel settings."); + }); + + it("resetCsvSettings POSTs a reset and notifies info", () => { + completeLoad(); + + component.resetCsvSettings(); + + httpTestingController.expectOne(resetUrl("csv_parser_max_columns")).flush(null); + expect(notifyInfo).toHaveBeenCalledWith("Resetting result panel settings..."); + }); + }); + + describe("onFileChange", () => { + it("rejects a non-image file with an error and leaves the existing logo untouched", () => { + completeLoad(); + component.logoData = "data:image/png;base64,EXISTING"; + const event = { + target: { files: [new File(["x"], "notes.txt", { type: "text/plain" })] }, + } as unknown as Event; + + component.onFileChange("logo", event); + + expect(msgError).toHaveBeenCalledWith("Please upload a valid image file."); + expect(component.logoData).toBe("data:image/png;base64,EXISTING"); + }); + + it("reads a valid image file into the matching branding field", async () => { + completeLoad(); + const dataUrl = "data:image/png;base64,AAA"; + class FakeFileReader { + onload: ((e: { target: { result: string } }) => void) | null = null; + readAsDataURL(): void { + queueMicrotask(() => this.onload?.({ target: { result: dataUrl } })); + } + } + const realFileReader = globalThis.FileReader; + (globalThis as any).FileReader = FakeFileReader; + + try { + const event = { + target: { files: [new File(["x"], "logo.png", { type: "image/png" })] }, + } as unknown as Event; + + component.onFileChange("mini_logo", event); + await Promise.resolve(); + + expect(component.miniLogoData).toBe(dataUrl); + } finally { + (globalThis as any).FileReader = realFileReader; + } + }); + }); + }); });
