11import { test , expect } from '@playwright/test' ;
22import { signin , signupViaAPI } from '../../../lib/helpers/e2e/auth.js' ;
33import { authenticatedContext , createOrgViaAPI , API } from '../../../lib/helpers/e2e/api.js' ;
4+ import { API_URL , COOKIE_PREFIX } from '../../../lib/helpers/e2e/config.js' ;
5+
6+ /**
7+ * @desc Patterns that identify a backend-unreachable / transport-level failure.
8+ * Used to scope `test.skip` so real backend regressions (4xx/5xx, schema drift,
9+ * etc.) surface as failures instead of silent skips.
10+ * @type {RegExp }
11+ */
12+ const CONNECTIVITY_ERROR_RE = / E C O N N R E F U S E D | E N O T F O U N D | E T I M E D O U T | E A I _ A G A I N | f e t c h f a i l e d | s o c k e t h a n g u p | n e t w o r k e r r o r / i;
13+
14+ /**
15+ * @desc Safely extract a string message from an unknown thrown value.
16+ * Avoids crashing when something throws `null` / `undefined` / a non-Error.
17+ * @param {unknown } err
18+ * @returns {string }
19+ */
20+ function errorMessage ( err ) {
21+ if ( err instanceof Error ) return err . message ;
22+ if ( err == null ) return String ( err ) ;
23+ return typeof err === 'string' ? err : String ( err ) ;
24+ }
425
526const timestamp = Date . now ( ) ;
627const domain = `domain${ timestamp } .com` ;
@@ -10,27 +31,92 @@ const password = 'E2eTestPass99xyz';
1031
1132let orgId ;
1233
34+ /**
35+ * @desc Check whether the Node API backend is reachable at the transport level.
36+ * Reachability = a response came back at all (any HTTP status). A 4xx/5xx means
37+ * the backend IS running and answered — so we should NOT skip the suite; we
38+ * should let the test exercise the real code path. Only network/connection
39+ * errors (request.get throws) indicate the backend is genuinely unreachable.
40+ * @param {import('@playwright/test').APIRequestContext } request
41+ * @returns {Promise<boolean> }
42+ */
43+ async function isApiAvailable ( request ) {
44+ try {
45+ // Use the fully-configured API URL (honors api.protocol/host/port/base) so
46+ // we don't false-negative on projects that customize `config.api.base`.
47+ await request . get ( API_URL ) ;
48+ return true ;
49+ } catch {
50+ return false ;
51+ }
52+ }
53+
54+ /**
55+ * @desc Predicate for `page.waitForURL` — true once the SPA has left the auth /
56+ * onboarding limbo (signup wizard, signin, or organization-required). Used by
57+ * every post-auth navigation wait in this suite; the post-auth destination
58+ * varies by env (test=`/`, dev=`/tasks`) so we assert by EXCLUSION instead.
59+ * @param {URL } url
60+ * @returns {boolean }
61+ */
62+ function isPostSignupRoute ( url ) {
63+ const p = url . pathname ;
64+ return ! p . includes ( '/signup' ) && ! p . includes ( '/signin' ) && ! p . includes ( '/organization-required' ) ;
65+ }
66+
67+ /**
68+ * @desc Seed the `suggestedJoin` localStorage entry on every page-load in a
69+ * Playwright context. Used to replicate what the signup flow stores so the
70+ * banner renders deterministically on subsequent signin tests.
71+ * @param {[string, string, string] } args - [storageKey, orgId, orgName]
72+ * @returns {void }
73+ */
74+ function seedSuggestedJoinLocalStorage ( [ key , id , name ] ) {
75+ window . localStorage . setItem ( key , JSON . stringify ( { orgId : id , orgName : name } ) ) ;
76+ }
77+
1378/**
1479 * End-to-end suite for organization domain-based join flow.
80+ *
81+ * Node #3680 (D5 always-create): domain-match signup no longer creates a pending
82+ * join request. Every user always gets their own active workspace. A `suggestedJoin`
83+ * hint ({ orgId, orgName }) is returned alongside the new workspace when the email
84+ * domain matches an existing org. The Vue app surfaces it as a dismissible snackbar
85+ * banner ("There may already be a workspace for X. Request access?").
1586 * @returns {void }
1687 */
1788test . describe ( 'Organization Domain Join E2E' , ( ) => {
1889 test . describe . configure ( { mode : 'serial' } ) ;
1990
20- // ── Phase 1: Signup & pending join ─────────── ─────────────────────
91+ // ── Phase 1: Signup & always-create workspace ─────────────────────
2192
2293 /**
2394 * Creates owner account and organization with domain matching.
2495 * @param {{ playwright: import('@playwright/test').Playwright, request: import('@playwright/test').APIRequestContext } } fixtures - Playwright fixtures
2596 * @returns {Promise<void> }
2697 */
2798 test ( 'owner signs up via API and creates org' , async ( { playwright, request } ) => {
28- const res = await signupViaAPI ( request , {
29- email : ownerEmail ,
30- password,
31- firstName : 'DJOwner' ,
32- lastName : 'Test' ,
33- } ) ;
99+ const apiUp = await isApiAvailable ( request ) ;
100+ test . skip ( ! apiUp , 'Node API backend not running' ) ;
101+
102+ let res ;
103+ try {
104+ res = await signupViaAPI ( request , {
105+ email : ownerEmail ,
106+ password,
107+ firstName : 'DJOwner' ,
108+ lastName : 'Test' ,
109+ } ) ;
110+ } catch ( err ) {
111+ // Skip ONLY on infra-level connectivity failures so real backend
112+ // regressions (4xx/5xx, schema drift) surface as failures, not silent skips.
113+ const msg = errorMessage ( err ) ;
114+ if ( CONNECTIVITY_ERROR_RE . test ( msg ) ) {
115+ test . skip ( true , `API connection failed during signup: ${ msg } ` ) ;
116+ return ;
117+ }
118+ throw err ;
119+ }
34120 expect ( res . user ) . toBeTruthy ( ) ;
35121
36122 // Create org via authenticated API (domain matching uses the email domain)
@@ -43,69 +129,142 @@ test.describe('Organization Domain Join E2E', () => {
43129 } ) ;
44130
45131 /**
46- * Signs up a member with matching domain and verifies pending join message.
132+ * Spec D5: member signs up with a matching domain.
133+ * Always-create: member lands in the organizationWelcome step ("Welcome! Your
134+ * organization X has been created.") — NOT a "request to join" pending message.
135+ * Sidenav is still absent during the signup wizard.
47136 * @param {{ page: import('@playwright/test').Page } } fixtures - Playwright fixtures
48137 * @returns {Promise<void> }
49138 */
50- test ( 'member signs up via UI — sees pending message, no sidenav' , async ( { page } ) => {
139+ test ( 'member signs up via UI — lands in own workspace, sees welcome step (no "request to join")' , async ( { page } ) => {
140+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
51141 await page . goto ( '/signup' ) ;
52142 await page . getByPlaceholder ( 'name@example.com' ) . first ( ) . waitFor ( { state : 'visible' , timeout : 10000 } ) ;
53143
54144 await page . getByPlaceholder ( 'name@example.com' ) . first ( ) . fill ( memberEmail ) ;
55145 await page . getByPlaceholder ( 'Create a password' ) . first ( ) . fill ( password ) ;
56146 await page . getByRole ( 'button' , { name : 'Continue' , exact : true } ) . click ( ) ;
57147
58- // Wait for the pending join message
148+ // D5: member gets their own workspace — "Welcome!" message appears, NOT "request to join"
149+ const welcomeText = page . locator ( 'text=Welcome!' ) . first ( ) ;
150+ await expect ( welcomeText ) . toBeVisible ( { timeout : 15000 } ) ;
151+
152+ // The old "request to join" pending message must NOT appear
59153 const pendingText = page . locator ( 'text=request to join' ) . first ( ) ;
60- await expect ( pendingText ) . toBeVisible ( { timeout : 10000 } ) ;
154+ await expect ( pendingText ) . not . toBeVisible ( { timeout : 3000 } ) ;
155+
156+ // NOTE: Unlike the old pendingJoin flow, the sidenav IS present during the
157+ // organizationWelcome step because the user has an active workspace (always-create).
158+ // The old "no sidenav" invariant no longer applies.
61159
62- // CRITICAL: Sidenav must NOT be visible during signup
63- const sidenav = page . locator ( 'nav.v-navigation-drawer, .v-navigation-drawer' ) ;
64- await expect ( sidenav ) . toHaveCount ( 0 , { timeout : 3000 } ) ;
160+ // Proceed to the app to complete signup (required so member has a session for later tests)
161+ await page . getByRole ( 'button' , { name : 'Get Started' , exact : true } ) . click ( ) ;
162+ // Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
163+ // env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
164+ // by exclusion (left signup/signin/organization-required).
165+ await page . waitForURL ( isPostSignupRoute , { timeout : 15000 } ) ;
65166 } ) ;
66167
67168 /**
68- * Verifies pending members are redirected to organization-required on sign-in.
169+ * Spec D5: member signs in — lands on the main app (has currentOrganization).
170+ * Router no longer redirects to /organization-required.
69171 * @param {{ page: import('@playwright/test').Page } } fixtures - Playwright fixtures
70172 * @returns {Promise<void> }
71173 */
72- test ( 'member signs in — lands on organization-required' , async ( { page } ) => {
174+ test ( 'member signs in — lands on main app (not organization-required)' , async ( { page } ) => {
175+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
73176 await signin ( page , memberEmail , password ) ;
74- await page . waitForURL ( ( url ) => url . pathname . includes ( '/organization-required' ) , { timeout : 15000 } ) ;
75-
76- // Should land on organization-required page (pending member has no active org)
77- expect ( page . url ( ) ) . toContain ( '/organization-required' ) ;
78177
79- // Should see the pending request banner
80- await expect ( page . getByRole ( 'alert' ) . getByText ( 'pending approval' ) ) . toBeVisible ( { timeout : 10000 } ) ;
178+ // D5: member has currentOrganization → router sends to /tasks, NOT /organization-required
179+ // Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
180+ // env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
181+ // by exclusion (left signup/signin/organization-required).
182+ await page . waitForURL ( isPostSignupRoute , { timeout : 15000 } ) ;
183+ // Post-auth route varies by env (test='/' vs dev='/tasks'); assert by exclusion.
184+ expect ( page . url ( ) ) . not . toContain ( '/signup' ) ;
185+ expect ( page . url ( ) ) . not . toContain ( '/signin' ) ;
186+ expect ( page . url ( ) ) . not . toContain ( '/organization-required' ) ;
81187 } ) ;
82188
83189 /**
84- * Verifies logged-in member visiting signup is redirected to organization-required.
190+ * Spec D5: member who is already logged in visiting /signup is redirected to the app.
191+ * With always-create (currentOrganization is set), the router sends to /tasks.
85192 * @param {{ page: import('@playwright/test').Page } } fixtures - Playwright fixtures
86193 * @returns {Promise<void> }
87194 */
88- test ( 'member refresh on signup — redirects to organization-required' , async ( { page } ) => {
195+ test ( 'member refresh on signup — redirects to main app (not organization-required)' , async ( { page } ) => {
196+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
89197 await signin ( page , memberEmail , password ) ;
90198 await page . waitForLoadState ( 'domcontentloaded' ) ;
91199
92200 // Navigate to signup (simulating refresh — user is already logged in)
93201 await page . goto ( '/signup' ) ;
94- // Wait for Vue app to detect logged-in user and redirect to organization-required
95- await page . waitForURL ( ( url ) => url . pathname . includes ( '/organization-required' ) , { timeout : 15000 } ) ;
202+ // D5: member has currentOrganization → redirected to /tasks, NOT /organization-required
203+ // Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
204+ // env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
205+ // by exclusion (left signup/signin/organization-required).
206+ await page . waitForURL ( isPostSignupRoute , { timeout : 15000 } ) ;
96207
97- // Should redirect to organization-required (not show signup form again)
98- expect ( page . url ( ) ) . toContain ( '/organization-required' ) ;
208+ // Post-auth route varies by env (test='/' vs dev='/tasks'); assert by exclusion.
209+ expect ( page . url ( ) ) . not . toContain ( '/signup' ) ;
210+ expect ( page . url ( ) ) . not . toContain ( '/signin' ) ;
211+ expect ( page . url ( ) ) . not . toContain ( '/organization-required' ) ;
99212 } ) ;
100213
101- // ── Phase 2: Owner approves, then member access control ───────────
214+ // ── Phase 2: suggestedJoin banner CTA → join request + access control ─
215+
216+ /**
217+ * Member clicks 'Request access' on the suggestedJoin banner.
218+ * Pre-populates localStorage with the suggestedJoin hint (simulating what the signup
219+ * flow stored) so the banner renders on the next page load.
220+ * A pending join request is created on the domain-matched owner org.
221+ * @param {{ page: import('@playwright/test').Page, playwright: import('@playwright/test').Playwright } } fixtures - Playwright fixtures
222+ * @returns {Promise<void> }
223+ */
224+ test ( 'member clicks Request access on suggestedJoin banner — join request created' , async ( { page, playwright } ) => {
225+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
226+
227+ // Inject suggestedJoin into localStorage before every page load via addInitScript.
228+ // This ensures initFromStorage() reads the key on the /signin page mount,
229+ // before the user credentials are submitted and the SPA navigates to the home.
230+ // Replicates what signup stores: key = `${config.cookie.prefix}SuggestedJoin`.
231+ // COOKIE_PREFIX is sourced from the same config the SPA reads, so the key matches
232+ // even if a project overrides `config.cookie.prefix`.
233+ const storageKey = `${ COOKIE_PREFIX } SuggestedJoin` ;
234+ await page . context ( ) . addInitScript ( seedSuggestedJoinLocalStorage , [ storageKey , orgId , `DomainOrg${ timestamp } ` ] ) ;
235+
236+ await signin ( page , memberEmail , password ) ;
237+ // Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
238+ // env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
239+ // by exclusion (left signup/signin/organization-required).
240+ await page . waitForURL ( isPostSignupRoute , { timeout : 15000 } ) ;
241+
242+ // suggestedJoin snackbar banner must be visible (top-right v-snackbar)
243+ // Banner text: "There may already be a workspace for {orgName}. Request access?"
244+ const requestBtn = page . getByRole ( 'button' , { name : 'Request access' } ) . first ( ) ;
245+ await expect ( requestBtn ) . toBeVisible ( { timeout : 10000 } ) ;
246+ await requestBtn . click ( ) ;
247+
248+ // After sending, the banner shows a success feedback snackbar and dismisses
249+ const feedbackText = page . locator ( 'text=Request sent' ) . first ( ) ;
250+ await expect ( feedbackText ) . toBeVisible ( { timeout : 10000 } ) ;
251+
252+ // Verify the join request was created on the backend
253+ const ctx = await authenticatedContext ( playwright , ownerEmail , password ) ;
254+ const reqRes = await ctx . get ( `${ API } /organizations/${ orgId } /requests` ) ;
255+ const reqBody = await reqRes . json ( ) ;
256+ const requests = reqBody . data || [ ] ;
257+ expect ( requests . length ) . toBeGreaterThan ( 0 ) ;
258+ await ctx . dispose ( ) ;
259+ } ) ;
102260
103261 /**
104262 * Owner approves the pending join request via API.
105263 * @param {{ playwright: import('@playwright/test').Playwright } } fixtures - Playwright fixtures
106264 * @returns {Promise<void> }
107265 */
108266 test ( 'owner approves member join request' , async ( { playwright } ) => {
267+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
109268 // Approve via authenticated API context — the pending requests section in the
110269 // detail component relies on loadMembership middleware timing which is not
111270 // deterministic in CI. The banner proves the API works; use it directly.
@@ -129,23 +288,29 @@ test.describe('Organization Domain Join E2E', () => {
129288 } ) ;
130289
131290 /**
132- * Verifies approved member cannot see the manage chevron on account page.
291+ * Verifies approved member cannot navigate into the domain-org (no manage chevron).
292+ * With always-create (Node #3680), the member owns their own auto-created workspace
293+ * (chevron IS present for it), but has role 'member' on DomainOrg (no chevron there).
133294 * @param {{ page: import('@playwright/test').Page } } fixtures - Playwright fixtures
134295 * @returns {Promise<void> }
135296 */
136297 test ( 'approved member — no Manage button on account page' , async ( { page } ) => {
298+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
137299 await signin ( page , memberEmail , password ) ;
138300 await page . goto ( '/users' ) ;
139301 await page . waitForLoadState ( 'domcontentloaded' ) ;
140302
141303 // Click the Organizations tab
142304 const orgTab = page . getByRole ( 'tab' , { name : / o r g a n i z a t i o n s / i } ) ;
143305 await orgTab . click ( { timeout : 10000 } ) ;
144- await expect ( page . getByRole ( 'main' ) . getByText ( `DomainOrg${ timestamp } ` , { exact : true } ) ) . toBeVisible ( { timeout : 10000 } ) ;
145306
146- // Members should NOT see the chevron (manage) icon on their org item
147- const chevron = page . locator ( '.v-list-item .fa-chevron-right' ) ;
148- await expect ( chevron ) . toHaveCount ( 0 , { timeout : 5000 } ) ;
307+ // Wait for the domain org list item to appear
308+ const domainOrgItem = page . locator ( '.v-list-item' , { hasText : `DomainOrg${ timestamp } ` } ) ;
309+ await expect ( domainOrgItem ) . toBeVisible ( { timeout : 10000 } ) ;
310+
311+ // The domain org list item should NOT have a chevron (member role, not owner/admin)
312+ const chevronInDomainOrg = domainOrgItem . locator ( '.fa-chevron-right' ) ;
313+ await expect ( chevronInDomainOrg ) . toHaveCount ( 0 , { timeout : 5000 } ) ;
149314 } ) ;
150315
151316 /**
@@ -154,6 +319,7 @@ test.describe('Organization Domain Join E2E', () => {
154319 * @returns {Promise<void> }
155320 */
156321 test ( 'approved member — no management controls on org page' , async ( { page } ) => {
322+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
157323 await signin ( page , memberEmail , password ) ;
158324 await page . goto ( `/users/organizations/${ orgId } ` ) ;
159325 await expect ( page . getByRole ( 'heading' , { name : `DomainOrg${ timestamp } ` } ) ) . toBeVisible ( { timeout : 10000 } ) ;
@@ -181,6 +347,7 @@ test.describe('Organization Domain Join E2E', () => {
181347 * @returns {Promise<void> }
182348 */
183349 test ( 'owner sees full management controls' , async ( { page } ) => {
350+ test . skip ( ! orgId , 'Setup was skipped — no org created' ) ;
184351 await signin ( page , ownerEmail , password ) ;
185352 await page . goto ( `/users/organizations/${ orgId } ` ) ;
186353 await page . waitForLoadState ( 'domcontentloaded' ) ;
0 commit comments