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-5235-251a845f79aac57157859cba6622e277c440bf3e
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 0bfe9055e4285eb336abf7d62c08275fac79d4b7
Author: Matthew B. <[email protected]>
AuthorDate: Tue Jun 2 15:21:21 2026 -0700

    test: unit tests for LocalLoginComponent (#5235)
    
    ### What changes were proposed in this PR?
    - Add `local-login.component.spec.ts` to cover form construction,
    validators, default-user prefill, and the login/register flows that were
    previously untested.
    - Tests assert that `allForms` exposes the five expected controls with
    `required`, `minLength(6)`, and the custom `confirmationValidator`, that
    `confirmationValidator` returns `{ confirm: true }` on mismatch and `{}`
    on match, and that `updateConfirmValidator` schedules
    `updateValueAndValidity` on the confirmation control via `setTimeout`.
    - Tests assert that `ngOnInit` patches `loginUsername`/`loginPassword`
    only when `GuiConfigService.env.defaultLocalUser` is populated, that
    `login` short-circuits via `loginErrorMessage` on validation failure and
    otherwise calls `UserService.login` with the trimmed username and
    navigates to `queryParams.returnUrl` or `DASHBOARD_USER_WORKFLOW`, and
    that error paths surface the error's message (or the fallback
    `"Incorrect username or password"`) through `NotificationService.error`.
    - Tests assert that `register` enforces password length, password match,
    and `UserService.validateUsername`, calls `UserService.register` with
    the trimmed username on success and surfaces the account-created
    notification, and on error notifies with the error's message (or the
    fallback `"Registration failed"`).
    ### Any related issues, documentation, or discussions?
    Closes: #5226
    ### How was this PR tested?
    - `yarn test
    
--include="src/app/hub/component/about/local-login/local-login.component.spec.ts"`,
    22 tests passing.
    - `yarn format:fix`, 506 files unchanged.
    ### Was this PR authored or co-authored using generative AI tooling?
    Co-authored with Claude Opus 4.7 in compliance with ASF
    
    ---------
    
    Co-authored-by: Xiaozhen Liu <[email protected]>
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../local-login/local-login.component.spec.ts      | 355 +++++++++++++++++++++
 1 file changed, 355 insertions(+)

diff --git 
a/frontend/src/app/hub/component/about/local-login/local-login.component.spec.ts
 
b/frontend/src/app/hub/component/about/local-login/local-login.component.spec.ts
new file mode 100644
index 0000000000..f969f117a3
--- /dev/null
+++ 
b/frontend/src/app/hub/component/about/local-login/local-login.component.spec.ts
@@ -0,0 +1,355 @@
+/**
+ * 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormControl } from "@angular/forms";
+import { ActivatedRoute, ActivatedRouteSnapshot, Router } from 
"@angular/router";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { of, throwError } from "rxjs";
+
+import { LocalLoginComponent } from "./local-login.component";
+import { UserService } from "../../../../common/service/user/user.service";
+import { NotificationService } from 
"../../../../common/service/notification/notification.service";
+import { GuiConfigService } from 
"../../../../common/service/gui-config.service";
+import { MockGuiConfigService } from 
"../../../../common/service/gui-config.service.mock";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { USER_WORKFLOW } from "../../../../app-routing.constant";
+
+describe("LocalLoginComponent", () => {
+  let component: LocalLoginComponent;
+  let fixture: ComponentFixture<LocalLoginComponent>;
+
+  let userServiceMock: Partial<UserService>;
+  let notificationServiceMock: Partial<NotificationService>;
+  let routerMock: Partial<Router>;
+  let activatedRouteMock: { snapshot: Partial<ActivatedRouteSnapshot> };
+
+  const createComponent = async (queryParams: Record<string, any> = {}) => {
+    TestBed.resetTestingModule();
+    userServiceMock = {
+      login: vi.fn().mockReturnValue(of(undefined)),
+      register: vi.fn().mockReturnValue(of(undefined)),
+    };
+    notificationServiceMock = {
+      error: vi.fn(),
+      success: vi.fn(),
+    };
+    routerMock = {
+      navigateByUrl: vi.fn(),
+    };
+    activatedRouteMock = {
+      snapshot: { queryParams } as Partial<ActivatedRouteSnapshot>,
+    };
+
+    await TestBed.configureTestingModule({
+      imports: [LocalLoginComponent, HttpClientTestingModule],
+      providers: [
+        { provide: UserService, useValue: userServiceMock },
+        { provide: NotificationService, useValue: notificationServiceMock },
+        { provide: Router, useValue: routerMock },
+        { provide: ActivatedRoute, useValue: activatedRouteMock },
+        ...commonTestProviders,
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(LocalLoginComponent);
+    component = fixture.componentInstance;
+  };
+
+  beforeEach(async () => {
+    await createComponent();
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("should create the component", () => {
+    fixture.detectChanges();
+    expect(component).toBeTruthy();
+  });
+
+  describe("form construction", () => {
+    it("builds allForms with the expected controls", () => {
+      const controls = component.allForms.controls;
+      expect(Object.keys(controls).sort()).toEqual(
+        [
+          "loginPassword",
+          "loginUsername",
+          "registerConfirmationPassword",
+          "registerPassword",
+          "registerUsername",
+        ].sort()
+      );
+    });
+
+    it("requires loginUsername and registerUsername", () => {
+      const loginUsername = component.allForms.get("loginUsername")!;
+      const registerUsername = component.allForms.get("registerUsername")!;
+      loginUsername.setValue("");
+      registerUsername.setValue("");
+      expect(loginUsername.hasError("required")).toBe(true);
+      expect(registerUsername.hasError("required")).toBe(true);
+    });
+
+    it("requires passwords and enforces minLength(6)", () => {
+      const loginPassword = component.allForms.get("loginPassword")!;
+      const registerPassword = component.allForms.get("registerPassword")!;
+      loginPassword.setValue("");
+      registerPassword.setValue("");
+      expect(loginPassword.hasError("required")).toBe(true);
+      expect(registerPassword.hasError("required")).toBe(true);
+
+      loginPassword.setValue("12345");
+      registerPassword.setValue("12345");
+      expect(loginPassword.hasError("minlength")).toBe(true);
+      expect(registerPassword.hasError("minlength")).toBe(true);
+
+      loginPassword.setValue("123456");
+      registerPassword.setValue("123456");
+      expect(loginPassword.valid).toBe(true);
+      expect(registerPassword.valid).toBe(true);
+    });
+
+    it("wires the confirmationValidator on registerConfirmationPassword", () 
=> {
+      const registerPassword = component.allForms.get("registerPassword")!;
+      const registerConfirmationPassword = 
component.allForms.get("registerConfirmationPassword")!;
+      registerPassword.setValue("abcdef");
+      registerConfirmationPassword.setValue("zzzzzz");
+      registerConfirmationPassword.updateValueAndValidity();
+      expect(registerConfirmationPassword.hasError("confirm")).toBe(true);
+
+      registerConfirmationPassword.setValue("abcdef");
+      registerConfirmationPassword.updateValueAndValidity();
+      expect(registerConfirmationPassword.hasError("confirm")).toBe(false);
+    });
+
+    it("requires registerConfirmationPassword to be non-empty", () => {
+      const registerConfirmationPassword = 
component.allForms.get("registerConfirmationPassword")!;
+      registerConfirmationPassword.setValue("");
+      expect(registerConfirmationPassword.hasError("required")).toBe(true);
+    });
+  });
+
+  describe("confirmationValidator", () => {
+    it("returns { confirm: true } when the value does not match 
registerPassword", () => {
+      component.allForms.get("registerPassword")!.setValue("password1");
+      const control = new FormControl("password2");
+      expect(component.confirmationValidator(control as 
FormControl)).toEqual({ confirm: true });
+    });
+
+    it("returns {} when the value matches registerPassword", () => {
+      component.allForms.get("registerPassword")!.setValue("password1");
+      const control = new FormControl("password1");
+      expect(component.confirmationValidator(control as 
FormControl)).toEqual({});
+    });
+  });
+
+  describe("updateConfirmValidator", () => {
+    it("schedules updateValueAndValidity on registerConfirmationPassword via 
setTimeout", () => {
+      vi.useFakeTimers();
+      try {
+        const control = 
component.allForms.controls.registerConfirmationPassword;
+        const updateSpy = vi.spyOn(control, "updateValueAndValidity");
+        component.updateConfirmValidator();
+        expect(updateSpy).not.toHaveBeenCalled();
+        vi.runAllTimers();
+        expect(updateSpy).toHaveBeenCalledTimes(1);
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+  });
+
+  describe("ngOnInit", () => {
+    it("patches loginUsername and loginPassword from defaultLocalUser when 
populated", () => {
+      const config = TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService;
+      config.setConfig({ defaultLocalUser: { username: "preset-user", 
password: "preset-pass" } });
+
+      component.ngOnInit();
+
+      
expect(component.allForms.get("loginUsername")!.value).toBe("preset-user");
+      
expect(component.allForms.get("loginPassword")!.value).toBe("preset-pass");
+    });
+
+    it("does not patch login fields when defaultLocalUser is empty", () => {
+      const config = TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService;
+      config.setConfig({ defaultLocalUser: {} });
+
+      component.ngOnInit();
+
+      expect(component.allForms.get("loginUsername")!.value).toBe("");
+      expect(component.allForms.get("loginPassword")!.value).toBe("");
+    });
+  });
+
+  describe("login", () => {
+    it("sets loginErrorMessage and short-circuits when validateUsername 
fails", () => {
+      const validateSpy = vi.spyOn(UserService, 
"validateUsername").mockReturnValue({
+        result: false,
+        message: "Username should not be empty.",
+      });
+      component.allForms.patchValue({ loginUsername: "", loginPassword: 
"123456" });
+
+      component.login();
+
+      expect(validateSpy).toHaveBeenCalledWith("");
+      expect(component.loginErrorMessage).toBe("Username should not be 
empty.");
+      expect(userServiceMock.login).not.toHaveBeenCalled();
+      expect(routerMock.navigateByUrl).not.toHaveBeenCalled();
+      validateSpy.mockRestore();
+    });
+
+    it("calls UserService.login with trimmed username and navigates to 
USER_WORKFLOW on success", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      component.allForms.patchValue({ loginUsername: "  alice  ", 
loginPassword: "secret" });
+
+      component.login();
+
+      expect(userServiceMock.login).toHaveBeenCalledWith("alice", "secret");
+      expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW);
+      expect(component.loginErrorMessage).toBeUndefined();
+    });
+
+    it("navigates to queryParams.returnUrl when present", async () => {
+      await createComponent({ returnUrl: "/custom/return" });
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      component.allForms.patchValue({ loginUsername: "alice", loginPassword: 
"secret" });
+
+      component.login();
+
+      expect(routerMock.navigateByUrl).toHaveBeenCalledWith("/custom/return");
+    });
+
+    it("surfaces the error's message via NotificationService.error on 
failure", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      vi.mocked(userServiceMock.login!).mockReturnValueOnce(throwError(() => 
new Error("boom")));
+      component.allForms.patchValue({ loginUsername: "alice", loginPassword: 
"secret" });
+
+      component.login();
+
+      expect(notificationServiceMock.error).toHaveBeenCalledWith("boom");
+      expect(routerMock.navigateByUrl).not.toHaveBeenCalled();
+    });
+
+    it("falls back to 'Incorrect username or password' when the error has no 
message", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      vi.mocked(userServiceMock.login!).mockReturnValueOnce(throwError(() => 
({})));
+      component.allForms.patchValue({ loginUsername: "alice", loginPassword: 
"secret" });
+
+      component.login();
+
+      expect(notificationServiceMock.error).toHaveBeenCalledWith("Incorrect 
username or password");
+    });
+  });
+
+  describe("register", () => {
+    it("sets registerErrorMessage when the password is shorter than 6 
characters", () => {
+      const validateSpy = vi.spyOn(UserService, 
"validateUsername").mockReturnValue({ result: true, message: "ok" });
+      component.allForms.patchValue({
+        registerUsername: "alice",
+        registerPassword: "abc",
+        registerConfirmationPassword: "abc",
+      });
+
+      component.register();
+
+      expect(component.registerErrorMessage).toBe("Password length should be 
greater than 5");
+      expect(userServiceMock.register).not.toHaveBeenCalled();
+      validateSpy.mockRestore();
+    });
+
+    it("sets registerErrorMessage when passwords do not match", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      component.allForms.patchValue({
+        registerUsername: "alice",
+        registerPassword: "abcdef",
+        registerConfirmationPassword: "ghijkl",
+      });
+
+      component.register();
+
+      expect(component.registerErrorMessage).toBe("Passwords do not match");
+      expect(userServiceMock.register).not.toHaveBeenCalled();
+    });
+
+    it("sets registerErrorMessage when validateUsername fails", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({
+        result: false,
+        message: "Username should not be empty.",
+      });
+      component.allForms.patchValue({
+        registerUsername: "",
+        registerPassword: "abcdef",
+        registerConfirmationPassword: "abcdef",
+      });
+
+      component.register();
+
+      expect(component.registerErrorMessage).toBe("Username should not be 
empty.");
+      expect(userServiceMock.register).not.toHaveBeenCalled();
+    });
+
+    it("calls UserService.register with the trimmed username and surfaces a 
success notification", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      component.allForms.patchValue({
+        registerUsername: "  alice  ",
+        registerPassword: "abcdef",
+        registerConfirmationPassword: "abcdef",
+      });
+
+      component.register();
+
+      expect(userServiceMock.register).toHaveBeenCalledWith("alice", "abcdef");
+      expect(notificationServiceMock.success).toHaveBeenCalledWith(
+        "Your account has been created. Please contact the Texera 
administrator to activate your account."
+      );
+      expect(component.registerErrorMessage).toBeUndefined();
+    });
+
+    it("surfaces the error's message via NotificationService.error on 
failure", () => {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      vi.mocked(userServiceMock.register!).mockReturnValueOnce(throwError(() 
=> new Error("nope")));
+      component.allForms.patchValue({
+        registerUsername: "alice",
+        registerPassword: "abcdef",
+        registerConfirmationPassword: "abcdef",
+      });
+
+      component.register();
+
+      expect(notificationServiceMock.error).toHaveBeenCalledWith("nope");
+      expect(notificationServiceMock.success).not.toHaveBeenCalled();
+    });
+
+    it("falls back to 'Registration failed' when the error has no message", () 
=> {
+      vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: 
true, message: "ok" });
+      vi.mocked(userServiceMock.register!).mockReturnValueOnce(throwError(() 
=> ({})));
+      component.allForms.patchValue({
+        registerUsername: "alice",
+        registerPassword: "abcdef",
+        registerConfirmationPassword: "abcdef",
+      });
+
+      component.register();
+
+      expect(notificationServiceMock.error).toHaveBeenCalledWith("Registration 
failed");
+    });
+  });
+});

Reply via email to