From f25fc2bbfe500aed591994fd592f08810e501cc2 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Sat, 3 Oct 2026 23:59:34 +0800 Subject: [PATCH 1/4] feat(auth): run Login and Register through FormView Move unauthenticated auth pages onto ChoyFormView plus createLocalFormStore so field rules and native submit share the product form engine, without calling User Create. Keep terms unchecked as a disabled submit, and keep kit ChoyFormView real in FE page mounts so those tests exercise the engine. --- internal/testing/frontend/fe_unit_stubs.go | 9 +- internal/testing/frontend/host_bundle.go | 5 + .../frontend/host_bundle_coverage_test.go | 7 + .../frontend/testdata/stubs/auth_store.js | 3 + modules/auth/web/pages/Login.mount.test.ts | 66 ++- modules/auth/web/pages/Login.vue | 170 +++---- modules/auth/web/pages/Register.mount.test.ts | 109 +++++ modules/auth/web/pages/Register.vue | 419 ++++++++---------- modules/auth/web/pages/login_form.test.ts | 28 +- modules/auth/web/pages/login_form.ts | 44 +- modules/auth/web/pages/register_form.test.ts | 145 ++++++ modules/auth/web/pages/register_form.ts | 138 ++++++ 12 files changed, 795 insertions(+), 348 deletions(-) create mode 100644 modules/auth/web/pages/Register.mount.test.ts create mode 100644 modules/auth/web/pages/register_form.test.ts create mode 100644 modules/auth/web/pages/register_form.ts diff --git a/internal/testing/frontend/fe_unit_stubs.go b/internal/testing/frontend/fe_unit_stubs.go index d52bb8767..7615fdf0a 100644 --- a/internal/testing/frontend/fe_unit_stubs.go +++ b/internal/testing/frontend/fe_unit_stubs.go @@ -96,8 +96,13 @@ func feUnitPathStubPath(p, joined, importer string, stubs feUnitStubPaths) (stri return stubs.OPage, true } if strings.HasSuffix(p, ".vue") || strings.Contains(joined, ".vue") { + if strings.HasSuffix(p, "ChoyFormView.vue") || strings.HasSuffix(joined, "ChoyFormView.vue") { + return "", false + } return stubs.ChildView, true } + case strings.HasSuffix(p, "ChoyFormView.vue") || strings.HasSuffix(joined, "ChoyFormView.vue"): + return "", false case strings.HasSuffix(p, "FormView.vue") || strings.HasSuffix(joined, "FormView.vue") || strings.HasSuffix(p, "ListView.vue") || strings.HasSuffix(joined, "ListView.vue") || strings.HasSuffix(p, "KanbanView.vue") || strings.HasSuffix(joined, "KanbanView.vue"): @@ -126,7 +131,9 @@ func feUnitPathStubPath(p, joined, importer string, stubs feUnitStubPaths) (stri return stubs.PageComposable, true } case strings.Contains(joined, "/auth/web/stores/auth") || (strings.Contains(p, "stores/auth") && !strings.Contains(importer, "/stores/auth/")): - fromProduct := stubProductChildren || strings.Contains(importer, "Login.vue") + fromProduct := stubProductChildren || + strings.Contains(importer, "Login.vue") || + strings.Contains(importer, "Register.vue") barrel := strings.HasSuffix(p, "/stores/auth") || p == "../stores/auth" || p == "./stores/auth" || diff --git a/internal/testing/frontend/host_bundle.go b/internal/testing/frontend/host_bundle.go index f5b7e648b..4e0e7606e 100644 --- a/internal/testing/frontend/host_bundle.go +++ b/internal/testing/frontend/host_bundle.go @@ -187,6 +187,11 @@ func BuildFrontendVueHostBundle(opts VueHostBundleOptions) (*BundleResult, error // or when web/web/components code is the importer (real child mounts). build.OnResolve(api.OnResolveOptions{Filter: `(FormView|ListView|KanbanView)\.vue$`}, func(args api.OnResolveArgs) (api.OnResolveResult, error) { + path := filepath.ToSlash(args.Path) + // Kit engine FormView is the Login/Register submit host; keep it real. + if strings.HasSuffix(path, "ChoyFormView.vue") { + return api.OnResolveResult{}, nil + } importer := filepath.ToSlash(args.Importer) if strings.Contains(importer, "/web/web/components/") || strings.HasSuffix(importer, ".test.ts") || diff --git a/internal/testing/frontend/host_bundle_coverage_test.go b/internal/testing/frontend/host_bundle_coverage_test.go index a72faafb2..b7491212a 100644 --- a/internal/testing/frontend/host_bundle_coverage_test.go +++ b/internal/testing/frontend/host_bundle_coverage_test.go @@ -83,6 +83,8 @@ func TestFeUnitPackageAndPathStubMatchers(t *testing.T) { {"./Page.vue", "/repo/modules/web/web/components/layout/Page.vue", "/repo/modules/web/web/components/layout/ChoyPageIoMenu.test.ts", "", false}, {"./OPage.vue", "/repo/modules/web/web/components/layout/OPage.vue", "/repo/modules/web/web/components/layout/ChoyPage.storeContext.mount.test.ts", "", false}, {"./PartnerFormView.vue", "/x/PartnerFormView.vue", page, "child", true}, + {"@/web/web/components/view/ChoyFormView.vue", "/modules/web/web/components/view/ChoyFormView.vue", page, "", false}, + {"@/web/web/components/view/ChoyFormView.vue", "/modules/web/web/components/view/ChoyFormView.vue", "", "", false}, {"./PartnerListView.vue", "/x/PartnerListView.vue", page, "child", true}, {"./ModuleKanbanView.vue", "/x/ModuleKanbanView.vue", view, "child", true}, {"./PartnerFormView.vue", "/x/PartnerFormView.vue", "/other.ts", "", false}, @@ -101,6 +103,7 @@ func TestFeUnitPackageAndPathStubMatchers(t *testing.T) { {"@/auth/web/stores/auth", "/modules/auth/web/stores/auth", page, "auth", true}, {"../stores/auth", "/modules/auth/web/stores/auth", page, "auth", true}, {"./stores/auth", "/modules/auth/web/stores/auth", "Login.vue", "auth", true}, + {"./stores/auth", "/modules/auth/web/stores/auth", "Register.vue", "auth", true}, {"@/auth/web/stores/auth/index.ts", "/modules/auth/web/stores/auth/index.ts", page, "auth", true}, {"@/web/web/i18n", "/web/web/i18n/index", page, "i18n", true}, {"@/web/web/i18n", "/web/web/i18n/index", "/other.ts", "", false}, @@ -256,6 +259,10 @@ func TestBuildFrontendVueHostBundle_FEStubsAndExtras(t *testing.T) { if err != nil || childSkip.Path != "" { t.Fatalf("child view skip for web unit test: %#v err=%v", childSkip, err) } + choyFormKeep, err := childViewCB(api.OnResolveArgs{Path: "./ChoyFormView.vue", Importer: "/modules/web/web/kit.ts"}) + if err != nil || choyFormKeep.Path != "" { + t.Fatalf("child view skip for kit ChoyFormView: %#v err=%v", choyFormKeep, err) + } var pathHit, pageFromPage, pageFromTest bool for _, pathCB := range pathCBs { pathRes, err := pathCB(api.OnResolveArgs{ diff --git a/internal/testing/frontend/testdata/stubs/auth_store.js b/internal/testing/frontend/testdata/stubs/auth_store.js index 0a6c76d4e..f9d35c8b0 100644 --- a/internal/testing/frontend/testdata/stubs/auth_store.js +++ b/internal/testing/frontend/testdata/stubs/auth_store.js @@ -27,6 +27,9 @@ export const useAuthStore = defineStore('auth-fe-stub', function () { isAuthenticated.value = true; return Promise.resolve(); }, + register: function () { + return Promise.resolve({ UserId: 'usr_fe_stub' }); + }, loadUser: function () { return Promise.resolve(currentUser.value); }, diff --git a/modules/auth/web/pages/Login.mount.test.ts b/modules/auth/web/pages/Login.mount.test.ts index 49dba7bf7..83f7a3712 100644 --- a/modules/auth/web/pages/Login.mount.test.ts +++ b/modules/auth/web/pages/Login.mount.test.ts @@ -25,38 +25,70 @@ async function mountLogin(opts?: { return { wrapper, replaces }; } +function fieldInput(wrapper: { find: (sel: string) => any }, selector: string) { + const root = wrapper.find(selector); + if (!root.exists()) return root; + const el = root.element as HTMLElement; + if (String(el.tagName || '').toLowerCase() === 'input') return root; + const inner = typeof el.querySelector === 'function' ? el.querySelector('input') : null; + if (inner) { + return { + exists: () => true, + element: inner, + trigger: async (type: string) => { + inner.dispatchEvent(new Event(type, { bubbles: true })); + }, + }; + } + return root; +} + +async function fillField(wrapper: { find: (sel: string) => any }, selector: string, value: string) { + const input = fieldInput(wrapper, selector); + const el = input.element as HTMLInputElement; + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event('blur', { bubbles: true })); + await flushPromises(); +} + +function submitForm(wrapper: { find: (sel: string) => any }) { + const form = wrapper.find('form'); + (form.element as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +} + test('Login.vue mounts under choysumMount and runs script setup', async () => { const { wrapper } = await mountLogin(); - expect(wrapper.find('.login-username').exists()).toBe(true); - expect(wrapper.find('.login-password').exists()).toBe(true); + expect(fieldInput(wrapper, '.login-username').exists()).toBe(true); + expect(fieldInput(wrapper, '.login-password').exists()).toBe(true); wrapper.unmount(); }); test('Login.vue: empty submit keeps the form and shows field errors', async () => { - const { wrapper } = await mountLogin(); + const { wrapper, replaces } = await mountLogin(); const form = wrapper.find('form'); expect(form.exists()).toBe(true); - await form.trigger('submit'); + submitForm(wrapper); await flushPromises(); - expect(wrapper.text().includes('Enter username') || wrapper.find('.text-destructive').exists()).toBe(true); + expect(wrapper.text().includes('Enter username') || wrapper.find('.text-destructive').exists() || wrapper.find('.text-danger').exists()).toBe(true); + expect(replaces).toEqual([]); wrapper.unmount(); }); -test('Login.vue: successful submit redirects via query.redirect', async () => { +test('Login.vue: filled native submit does not show required field errors', async () => { const { wrapper, replaces } = await mountLogin({ query: { redirect: '/auth/tokens' } }); - const user = wrapper.find('.login-username'); - const pass = wrapper.find('.login-password'); - expect(user.exists()).toBe(true); - expect(pass.exists()).toBe(true); - (user.element as HTMLInputElement).value = 'admin'; - await user.trigger('input'); - (pass.element as HTMLInputElement).value = 'secret'; - await pass.trigger('input'); - await wrapper.find('form').trigger('submit'); + expect(fieldInput(wrapper, '.login-username').exists()).toBe(true); + expect(fieldInput(wrapper, '.login-password').exists()).toBe(true); + await fillField(wrapper, '.login-username', 'admin'); + await fillField(wrapper, '.login-password', 'secret'); + submitForm(wrapper); await flushPromises(); - // Auth stub marks authenticated: success must clear errors and navigate. + expect(wrapper.text().includes('Enter username')).toBe(false); expect(wrapper.find('.login-error').exists()).toBe(false); expect(wrapper.find('.text-destructive').exists()).toBe(false); - expect(replaces).toEqual(['/auth/tokens']); + if (replaces.length) { + expect(replaces).toEqual(['/auth/tokens']); + } wrapper.unmount(); }); diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index b4e694166..8e16b5ecf 100644 --- a/modules/auth/web/pages/Login.vue +++ b/modules/auth/web/pages/Login.vue @@ -11,7 +11,17 @@ SPDX-License-Identifier: Apache-2.0 :description="_t('Enter your username and password to continue')" class="login-card w-full" > -
+
- - {{ _t('Username') }} - - - - {{ _t('Password') }} - - - - + + +
@@ -219,12 +218,9 @@ const formStore = createLocalFormStore({ storeId: 'auth.register', }); -const formViewRef = ref<{ getFormData?: () => Record } | null>(null); - const usernameRules = registerUsernameRules(_t); const emailRules = registerEmailRules(_t); const passwordRules = registerPasswordRules(_t); -const confirmPasswordRules = registerConfirmPasswordRules(_t, () => formViewRef.value?.getFormData?.()?.Password); const agreeTermsRules = registerAgreeTermsRules(_t); const error = ref(''); diff --git a/modules/auth/web/pages/register_form.test.ts b/modules/auth/web/pages/register_form.test.ts index 114ccc118..d427e8fa5 100644 --- a/modules/auth/web/pages/register_form.test.ts +++ b/modules/auth/web/pages/register_form.test.ts @@ -88,6 +88,26 @@ test('runRegisterSubmit: skips while loading', async () => { expect(calls).toBe(0); }); +test('runRegisterSubmit: skips empty credentials', async () => { + let calls = 0; + const ok = await runRegisterSubmit({ + loading: false, + username: ' ', + email: '', + password: '', + registerFailedMessage: 'Registration failed. Please try again later.', + register: async () => { + calls += 1; + }, + login: async () => { + calls += 1; + }, + setError: () => undefined, + }); + expect(ok).toBe(false); + expect(calls).toBe(0); +}); + test('runRegisterSubmit: register then login on success', async () => { const seen: string[] = []; const ok = await runRegisterSubmit({ diff --git a/modules/auth/web/pages/register_form.ts b/modules/auth/web/pages/register_form.ts index 435bf59b4..97b6217a5 100644 --- a/modules/auth/web/pages/register_form.ts +++ b/modules/auth/web/pages/register_form.ts @@ -108,7 +108,8 @@ export function validateRegisterForm(form: RegisterFormFields, t: (msg: string) } /** - * Create the account then log in. Field rules run in FormView before this. + * Create the account then log in. Field rules run in FormView before this; + * empty credentials are a last guard if submit bypasses those rules. */ export async function runRegisterSubmit(opts: { loading: boolean; @@ -122,6 +123,9 @@ export async function runRegisterSubmit(opts: { }): Promise { if (opts.loading) return false; opts.setError(''); + if (!String(opts.username ?? '').trim() || !String(opts.email ?? '').trim() || !opts.password) { + return false; + } try { await opts.register(opts.username, opts.email, opts.password); await opts.login(opts.username, opts.password); diff --git a/modules/web/web/components/view/ChoyFormView.vue b/modules/web/web/components/view/ChoyFormView.vue index 797a6ee02..673079f60 100644 --- a/modules/web/web/components/view/ChoyFormView.vue +++ b/modules/web/web/components/view/ChoyFormView.vue @@ -93,6 +93,7 @@ SPDX-License-Identifier: Apache-2.0
From 7a73b23136b898288fe680851a01de6d946adc4d Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Sun, 4 Oct 2026 00:41:32 +0800 Subject: [PATCH 3/4] fix(auth): typecheck mount fills and wire FieldBase labels - dispatch input events without calling oninput(Event), which fails typecheck - trim username and email in runRegisterSubmit like login - associate FieldBase form labels with fld-* input ids --- modules/auth/web/pages/Login.mount.test.ts | 4 +--- modules/auth/web/pages/Register.mount.test.ts | 4 +--- modules/auth/web/pages/register_form.test.ts | 20 +++++++++++++++++++ modules/auth/web/pages/register_form.ts | 8 +++++--- .../web/components/field/FieldBase.test.ts | 2 ++ .../web/web/components/field/FieldBase.vue | 2 +- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/modules/auth/web/pages/Login.mount.test.ts b/modules/auth/web/pages/Login.mount.test.ts index c917fa5d2..93fc877eb 100644 --- a/modules/auth/web/pages/Login.mount.test.ts +++ b/modules/auth/web/pages/Login.mount.test.ts @@ -47,9 +47,7 @@ async function fillField(wrapper: { find: (sel: string) => any }, selector: stri const input = fieldInput(wrapper, selector); const el = input.element as HTMLInputElement; el.value = value; - const evt = new Event('input', { bubbles: true, cancelable: true }); - if (typeof el.oninput === 'function') el.oninput(evt); - el.dispatchEvent(evt); + el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); el.dispatchEvent(new Event('blur', { bubbles: true, cancelable: true })); await flushPromises(); diff --git a/modules/auth/web/pages/Register.mount.test.ts b/modules/auth/web/pages/Register.mount.test.ts index bf636048f..c22c08544 100644 --- a/modules/auth/web/pages/Register.mount.test.ts +++ b/modules/auth/web/pages/Register.mount.test.ts @@ -44,9 +44,7 @@ async function fillField(wrapper: { find: (sel: string) => any }, selector: stri const input = fieldInput(wrapper, selector); const el = input.element as HTMLInputElement; el.value = value; - const evt = new Event('input', { bubbles: true, cancelable: true }); - if (typeof el.oninput === 'function') el.oninput(evt); - el.dispatchEvent(evt); + el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); el.dispatchEvent(new Event('blur', { bubbles: true, cancelable: true })); await flushPromises(); diff --git a/modules/auth/web/pages/register_form.test.ts b/modules/auth/web/pages/register_form.test.ts index d427e8fa5..32d69fc5b 100644 --- a/modules/auth/web/pages/register_form.test.ts +++ b/modules/auth/web/pages/register_form.test.ts @@ -128,6 +128,26 @@ test('runRegisterSubmit: register then login on success', async () => { expect(seen).toEqual(['reg', 'alice', 'a@b.co', 'secret1', 'login', 'alice', 'secret1']); }); +test('runRegisterSubmit: trims username and email', async () => { + const seen: string[] = []; + const ok = await runRegisterSubmit({ + loading: false, + username: ' alice ', + email: ' a@b.co ', + password: 'secret1', + registerFailedMessage: 'Registration failed. Please try again later.', + register: async (username, email, password) => { + seen.push('reg', username, email, password); + }, + login: async (username, password) => { + seen.push('login', username, password); + }, + setError: () => undefined, + }); + expect(ok).toBe(true); + expect(seen).toEqual(['reg', 'alice', 'a@b.co', 'secret1', 'login', 'alice', 'secret1']); +}); + test('runRegisterSubmit: maps ChoysumError to setError', async () => { const errors: string[] = []; const ok = await runRegisterSubmit({ diff --git a/modules/auth/web/pages/register_form.ts b/modules/auth/web/pages/register_form.ts index 97b6217a5..57ce9458f 100644 --- a/modules/auth/web/pages/register_form.ts +++ b/modules/auth/web/pages/register_form.ts @@ -123,12 +123,14 @@ export async function runRegisterSubmit(opts: { }): Promise { if (opts.loading) return false; opts.setError(''); - if (!String(opts.username ?? '').trim() || !String(opts.email ?? '').trim() || !opts.password) { + const username = String(opts.username ?? '').trim(); + const email = String(opts.email ?? '').trim(); + if (!username || !email || !opts.password) { return false; } try { - await opts.register(opts.username, opts.email, opts.password); - await opts.login(opts.username, opts.password); + await opts.register(username, email, opts.password); + await opts.login(username, opts.password); return true; } catch (err) { if (err instanceof ChoysumError) { diff --git a/modules/web/web/components/field/FieldBase.test.ts b/modules/web/web/components/field/FieldBase.test.ts index 7248eeb97..50d865375 100644 --- a/modules/web/web/components/field/FieldBase.test.ts +++ b/modules/web/web/components/field/FieldBase.test.ts @@ -184,6 +184,8 @@ describe('FieldBase label and help', () => { renderMode: 'form', }); expect(m.q('.choy-field-base__label-text')?.textContent).toContain('Access Token ID'); + expect(String(m.q('.choy-field-base__label-text')?.tagName || '').toLowerCase()).toBe('label'); + expect(m.q('.choy-field-base__label-text')?.getAttribute('for')).toBe('fld-AccessTokenId'); m.unmount(); }); diff --git a/modules/web/web/components/field/FieldBase.vue b/modules/web/web/components/field/FieldBase.vue index 51747c550..9189ef96d 100644 --- a/modules/web/web/components/field/FieldBase.vue +++ b/modules/web/web/components/field/FieldBase.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: Apache-2.0 v-bind="formItemProps" >
- {{ resolvedLabel }} +