import { describe, it, expect } from 'vitest'; import { generate, buildLoginComponent, buildWiringFile, injectLoginExtensions, LOGIN_IMPORT_LINE, } from '../generate.js'; import { ScaffoldLoginPageInputSchema, type ScaffoldLoginPageInput } from '../types.js'; /** Parse through the schema so defaults (componentName, hrefs, …) apply. */ function spec(overrides: Record = {}): ScaffoldLoginPageInput { return ScaffoldLoginPageInputSchema.parse({ projectPath: '/proj', appCode: 'demo', providers: { local: true }, ...overrides, }); } const LAYOUT = { webDir: 'web/demo-web' }; const CANONICAL_MAIN = `import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { SmartStackProvider } from '@atlashub/smartstack'; import App from './App'; createRoot(document.getElementById('root')!).render( , ); `; // main.tsx already wired for /site-vitrine — login wiring must coexist. const MAIN_WITH_VITRINE = `import { vitrineExtensions } from './extensions/vitrine.generated'; import { SmartStackProvider } from '@atlashub/smartstack'; createRoot(document.getElementById('root')!).render( , ); `; describe('buildLoginComponent — local form', () => { it('imports + invokes useLoginForm and renders the email/password form', () => { const src = buildLoginComponent(spec()); expect(src).toContain(`import { useLoginForm } from '@atlashub/smartstack'`); expect(src).toContain('useLoginForm()'); expect(src).toContain('onSubmit={handleSubmit}'); expect(src).toContain('Se connecter'); expect(src).toContain('PageProps'); }); it('shows the forgot-password link by default, honors the href, and can hide it', () => { expect(buildLoginComponent(spec())).toContain('/forgot-password'); const hidden = buildLoginComponent(spec({ showForgotPassword: false })); expect(hidden).not.toContain('Mot de passe oublié'); }); }); describe('buildLoginComponent — external providers', () => { it('emits only the requested provider buttons with correct targets', () => { const src = buildLoginComponent(spec({ providers: { local: true, microsoft: true, google: true } })); expect(src).toContain("globalThis.location.href = \"/api/auth/microsoft\""); expect(src).toContain("globalThis.location.href = \"/api/auth/google\""); expect(src).not.toContain('/sso'); }); it('entra button targets /sso', () => { const src = buildLoginComponent(spec({ providers: { local: true, entra: true } })); expect(src).toContain("globalThis.location.href = \"/sso\""); }); it('keeps the local form even on an SSO-first page', () => { // A scaffolded page WITHOUT the password form is an application whose administrator cannot // sign in — the initial admin's door, and the only provider depending on no external system. // The schema refuses it (see below); the generator therefore always emits the form. const src = buildLoginComponent(spec({ providers: { entra: true } })); expect(src).toContain('useLoginForm'); expect(src).toContain('handleSubmit'); expect(src).toContain("globalThis.location.href = \"/sso\""); }); }); describe('buildLoginComponent — registration + branding', () => { it('emits the create-account link only when allowRegistration is true', () => { expect(buildLoginComponent(spec({ allowRegistration: true }))).toContain('Créer un compte'); expect(buildLoginComponent(spec({ allowRegistration: false }))).not.toContain('Créer un compte'); }); it('honors a custom registerHref', () => { const src = buildLoginComponent(spec({ allowRegistration: true, registerHref: '/signup' })); expect(src).toContain('"/signup"'); }); it('renders branding (title, subtitle, logo) as safe JSX expressions', () => { const src = buildLoginComponent(spec({ branding: { title: 'Acme', subtitle: 'Bienvenue', logoUrl: 'https://x/logo.png' } })); expect(src).toContain('{"Acme"}'); expect(src).toContain('{"Bienvenue"}'); expect(src).toContain('src={"https://x/logo.png"}'); }); it('uses a custom componentName for the exported function', () => { const src = buildLoginComponent(spec({ componentName: 'BrandLogin' })); expect(src).toContain('export function BrandLogin('); }); }); describe('buildWiringFile', () => { it('registers the component under PAGE_KEYS.LOGIN and imports it', () => { const w = buildWiringFile(spec({ componentName: 'BrandLogin' })); expect(w).toContain("import { PAGE_KEYS } from '@atlashub/smartstack'"); expect(w).toContain("import { BrandLogin } from '../auth/BrandLogin'"); expect(w).toContain('export const loginExtensions: ExtensionConfig = { pages: { [PAGE_KEYS.LOGIN]: BrandLogin } }'); }); }); describe('injectLoginExtensions — main.tsx wiring', () => { it('wires an empty extensions block and adds the import', () => { const { content, status } = injectLoginExtensions(CANONICAL_MAIN); expect(status).toBe('applied'); expect(content).toContain(LOGIN_IMPORT_LINE); expect(content).toContain('extensions: { ...loginExtensions }'); }); it('is idempotent — re-running leaves it unchanged', () => { const once = injectLoginExtensions(CANONICAL_MAIN).content; const twice = injectLoginExtensions(once); expect(twice.status).toBe('unchanged'); expect(twice.content).toBe(once); expect((once.match(/\.\.\.loginExtensions/g) ?? []).length).toBe(1); }); it('coexists with an existing vitrine spread', () => { const { content, status } = injectLoginExtensions(MAIN_WITH_VITRINE); expect(status).toBe('applied'); expect(content).toContain('...loginExtensions'); expect(content).toContain('...vitrineExtensions'); expect(content).toMatch(/extensions:\s*\{\s*\.\.\.loginExtensions,\s*\.\.\.vitrineExtensions\s*\}/); }); it('respects the @customised marker', () => { const src = '// @customised\n' + CANONICAL_MAIN; const { content, status } = injectLoginExtensions(src); expect(status).toBe('skipped-customised'); expect(content).toBe(src); }); it('bails to not-found (no dangling import) when there is no extensions block', () => { const src = 'export const x = 1;\n'; const { content, status } = injectLoginExtensions(src); expect(status).toBe('not-found'); expect(content).toBe(src); }); }); describe('generate — emitted files', () => { it('emits the page (skip-if-exists) and the wiring (overwrite) at the right paths', () => { const files = generate(spec({ componentName: 'CustomLoginPage' }), LAYOUT); expect(files.map((f) => `${f.path} [${f.strategy}]`)).toEqual([ 'web/demo-web/src/auth/CustomLoginPage.tsx [skip-if-exists]', 'web/demo-web/src/extensions/login.generated.ts [overwrite]', ]); }); }); describe('schema refinement', () => { it('rejects omitting the local password form', () => { // Mirrors configure-login's connectionTypes.local: the two CLIs of this skill must not // disagree on an invariant, or the second one hands back the footgun the first refused. const res = ScaffoldLoginPageInputSchema.safeParse({ projectPath: '/p', appCode: 'a', providers: { local: false }, }); expect(res.success).toBe(false); expect(JSON.stringify((res as { error?: unknown }).error)).toContain('cannot be omitted'); }); it('defaults local to true when providers omits it', () => { const res = ScaffoldLoginPageInputSchema.parse({ projectPath: '/p', appCode: 'a', providers: { entra: true }, }); expect(res.providers.local).toBe(true); }); it('rejects a non-PascalCase componentName', () => { const res = ScaffoldLoginPageInputSchema.safeParse({ projectPath: '/p', appCode: 'a', providers: { local: true }, componentName: 'custom-login', }); expect(res.success).toBe(false); }); });