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..d148223a2 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) + // Keep the kit FormView SFC so native submit and field rules run. + 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..35c3c33dd 100644 --- a/internal/testing/frontend/host_bundle_coverage_test.go +++ b/internal/testing/frontend/host_bundle_coverage_test.go @@ -83,6 +83,10 @@ 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}, + {"./ChoyFormView.vue", "/tmp/ChoyFormView.vue", page, "", false}, + {"./ChoyFormView.vue", "/tmp/ChoyFormView.vue", "/other.ts", "", 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 +105,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 +261,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..93fc877eb 100644 --- a/modules/auth/web/pages/Login.mount.test.ts +++ b/modules/auth/web/pages/Login.mount.test.ts @@ -25,36 +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, cancelable: true })); + el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); + el.dispatchEvent(new Event('blur', { bubbles: true, cancelable: 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 })); +} + +async function afterSubmit() { + for (let i = 0; i < 8; i++) await flushPromises(); +} + 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'); - await flushPromises(); - expect(wrapper.text().includes('Enter username') || wrapper.find('.text-destructive').exists()).toBe(true); + submitForm(wrapper); + await afterSubmit(); + 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'); - await flushPromises(); - // Auth stub marks authenticated: success must clear errors and navigate. + 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 afterSubmit(); + 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']); diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index b4e694166..797a964ef 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') }} - - - - + + +