1- import { expect , test } from "../../fixtures" ;
1+ import { expect , test } from "@/test/e2e/fixtures" ;
2+ import {
3+ createLinkFolder ,
4+ createShortLink ,
5+ escapedText ,
6+ idFromPath ,
7+ linkRow ,
8+ openLinkActions ,
9+ scopeSuffix ,
10+ } from "@/test/e2e/utils/dashboard" ;
211
3- async function openLinkActions (
4- page : import ( "@playwright/test" ) . Page ,
5- linkName : string
6- ) : Promise < void > {
7- const rowLink = page . getByRole ( "link" , { name : new RegExp ( linkName ) } ) ;
8- await rowLink . hover ( ) ;
9- await page . getByRole ( "button" , { name : `Actions for ${ linkName } ` } ) . click ( ) ;
10- }
12+ const SHORT_LINK_LABEL_RE = / S h o r t L i n k / ;
13+ const SLUG_CONFLICT_RE = / s l u g .* ( t a k e n | e x i s t s ) / i;
1114
1215test (
13- "creates, updates, opens, and deletes a short link " ,
16+ "creates, filters, updates, opens, and deletes short links " ,
1417 { tag : "@core" } ,
1518 async ( { authenticatedPage, e2eSession } ) => {
16- const suffix = e2eSession . userId . slice ( 0 , 8 ) . toLowerCase ( ) ;
19+ const suffix = scopeSuffix ( e2eSession ) ;
1720 const folderName = `E2E Folder ${ suffix } ` ;
18- const linkName = `E2E Link ${ suffix } ` ;
19- const updatedName = `${ linkName } Updated` ;
20- const slug = `e2e-${ suffix } ` ;
21+ const primaryToken = `primary-${ suffix } ` ;
22+ const primaryName = `E2E Link ${ primaryToken } ` ;
23+ const secondaryName = `E2E Other ${ suffix } ` ;
24+ const updatedName = `${ primaryName } Updated` ;
25+ const primarySlug = `e2e-${ primaryToken } ` ;
26+ const secondarySlug = `e2e-other-${ suffix } ` ;
2127 const targetUrl = `e2e-${ suffix } .local/start` ;
2228 const updatedTargetUrl = `e2e-${ suffix } .local/updated` ;
2329
2430 await authenticatedPage . goto ( "/links" ) ;
2531 await expect (
2632 authenticatedPage . getByRole ( "heading" , { name : "Links" } )
2733 ) . toBeVisible ( ) ;
28- await authenticatedPage . getByRole ( "button" , { name : "Folder" } ) . click ( ) ;
29- await expect (
30- authenticatedPage . getByRole ( "heading" , { name : "Create Folder" } )
31- ) . toBeVisible ( ) ;
32- await authenticatedPage
33- . getByRole ( "textbox" , { name : "Folder Name" } )
34- . fill ( folderName ) ;
35- await authenticatedPage . getByRole ( "button" , { name : "Create Folder" } ) . click ( ) ;
34+
35+ await createLinkFolder ( authenticatedPage , folderName ) ;
3636 await expect (
3737 authenticatedPage . getByRole ( "button" , { name : new RegExp ( folderName ) } )
3838 ) . toBeVisible ( ) ;
3939
40- await authenticatedPage . getByRole ( "button" , { name : "New Link" } ) . click ( ) ;
40+ const primaryRow = await createShortLink ( authenticatedPage , {
41+ folderName,
42+ name : primaryName ,
43+ slug : primarySlug ,
44+ targetUrl,
45+ } ) ;
46+ await expect ( primaryRow ) . toBeVisible ( ) ;
47+ await expect ( authenticatedPage . getByText ( escapedText ( primarySlug ) ) ) . toBeVisible ( ) ;
4148 await expect (
42- authenticatedPage . getByRole ( "heading" , { name : "Create Link" } )
49+ authenticatedPage . getByRole ( "button" , {
50+ name : new RegExp ( `${ folderName } \\s+1` ) ,
51+ } )
4352 ) . toBeVisible ( ) ;
4453
45- await authenticatedPage
46- . getByRole ( "textbox" , { name : "Destination URL" } )
47- . fill ( targetUrl ) ;
48- await authenticatedPage . getByRole ( "textbox" , { name : "Name" } ) . fill ( linkName ) ;
49- await authenticatedPage
50- . getByRole ( "textbox" , { name : / S h o r t L i n k / } )
51- . fill ( slug ) ;
52- const createDialog = authenticatedPage . getByRole ( "dialog" , {
53- name : "Create Link" ,
54+ const secondaryRow = await createShortLink ( authenticatedPage , {
55+ name : secondaryName ,
56+ slug : secondarySlug ,
57+ targetUrl : `other-${ targetUrl } ` ,
5458 } ) ;
55- await createDialog
56- . getByRole ( "button" , { name : "Folder: Unfiled" } )
57- . click ( ) ;
58- await authenticatedPage . getByRole ( "menuitem" , { name : folderName } ) . click ( ) ;
59- await authenticatedPage . getByRole ( "button" , { name : "Create Link" } ) . click ( ) ;
60-
61- const linkRow = authenticatedPage . getByRole ( "link" , {
62- name : new RegExp ( linkName ) ,
63- } ) ;
64- await expect ( linkRow ) . toBeVisible ( ) ;
65- await expect ( authenticatedPage . getByText ( new RegExp ( slug ) ) ) . toBeVisible ( ) ;
59+ await expect ( secondaryRow ) . toBeVisible ( ) ;
6660 await expect (
67- authenticatedPage . getByRole ( "button" , { name : new RegExp ( ` ${ folderName } \\ s+1` ) } )
61+ authenticatedPage . getByRole ( "button" , { name : / U n f i l e d \ s+ 1 / } )
6862 ) . toBeVisible ( ) ;
6963
70- await linkRow . click ( ) ;
64+ await authenticatedPage
65+ . getByRole ( "textbox" , { name : "Search links" } )
66+ . fill ( primaryToken ) ;
67+ await expect ( linkRow ( authenticatedPage , primaryName ) ) . toBeVisible ( ) ;
68+ await expect ( linkRow ( authenticatedPage , secondaryName ) ) . toBeHidden ( ) ;
69+ await authenticatedPage . getByRole ( "button" , { name : "Clear search" } ) . click ( ) ;
70+ await expect ( linkRow ( authenticatedPage , secondaryName ) ) . toBeVisible ( ) ;
71+
72+ await linkRow ( authenticatedPage , primaryName ) . click ( ) ;
7173 await expect ( authenticatedPage ) . toHaveURL ( / \/ l i n k s \/ [ A - Z a - z 0 - 9 _ - ] + / ) ;
72- await expect ( authenticatedPage . getByText ( linkName ) ) . toBeVisible ( ) ;
74+ expect ( idFromPath ( authenticatedPage . url ( ) , "links" ) ) . toBeTruthy ( ) ;
75+ await expect ( authenticatedPage . getByText ( primaryName ) ) . toBeVisible ( ) ;
7376 await expect ( authenticatedPage . getByText ( "Total Clicks" ) ) . toBeVisible ( ) ;
7477
7578 await authenticatedPage . goto ( "/links" ) ;
76- await openLinkActions ( authenticatedPage , linkName ) ;
79+ await openLinkActions ( authenticatedPage , primaryName ) ;
7780 await authenticatedPage . getByRole ( "menuitem" , { name : "Edit" } ) . click ( ) ;
7881 await expect (
7982 authenticatedPage . getByRole ( "heading" , { name : "Edit Link" } )
8891 await expect (
8992 authenticatedPage . getByRole ( "heading" , { name : "Edit Link" } )
9093 ) . toBeHidden ( ) ;
94+ await expect ( linkRow ( authenticatedPage , updatedName ) ) . toBeVisible ( ) ;
9195 await expect (
92- authenticatedPage . getByRole ( "link" , { name : new RegExp ( updatedName ) } )
93- ) . toBeVisible ( ) ;
96+ authenticatedPage . getByText ( primaryName , { exact : true } )
97+ ) . toBeHidden ( ) ;
9498
9599 await openLinkActions ( authenticatedPage , updatedName ) ;
96100 await authenticatedPage . getByRole ( "menuitem" , { name : "Delete" } ) . click ( ) ;
@@ -102,8 +106,59 @@ test(
102106 . getByRole ( "button" , { name : "Delete Link" } )
103107 . click ( ) ;
104108
109+ await expect ( linkRow ( authenticatedPage , updatedName ) ) . toBeHidden ( ) ;
110+ await expect ( linkRow ( authenticatedPage , secondaryName ) ) . toBeVisible ( ) ;
111+ }
112+ ) ;
113+
114+ test (
115+ "validates short link slugs and rejects duplicates" ,
116+ { tag : "@core" } ,
117+ async ( { authenticatedPage, e2eSession } ) => {
118+ const suffix = scopeSuffix ( e2eSession ) ;
119+ const name = `Slug Edge ${ suffix } ` ;
120+ const slug = `slug-edge-${ suffix } ` ;
121+ const targetUrl = `slug-edge-${ suffix } .local/start` ;
122+
123+ await authenticatedPage . goto ( "/links" ) ;
124+ await authenticatedPage . getByRole ( "button" , { name : "New Link" } ) . click ( ) ;
125+ const dialog = authenticatedPage . getByRole ( "dialog" , { name : "Create Link" } ) ;
126+ await dialog . getByRole ( "textbox" , { name : "Destination URL" } ) . fill ( targetUrl ) ;
127+ await dialog . getByRole ( "textbox" , { name : "Name" } ) . fill ( name ) ;
128+
129+ const invalidCases = [
130+ { error : "Slug must be at least 3 characters" , value : "ab" } ,
131+ { error : "Only letters, numbers, hyphens, and underscores" , value : "bad/slug" } ,
132+ ] ;
133+ for ( const { error, value } of invalidCases ) {
134+ await dialog
135+ . getByRole ( "textbox" , { name : SHORT_LINK_LABEL_RE } )
136+ . fill ( value ) ;
137+ await expect ( dialog . getByText ( error ) ) . toBeVisible ( ) ;
138+ await expect ( dialog . getByRole ( "button" , { name : "Create Link" } ) ) . toBeDisabled ( ) ;
139+ }
140+
141+ await dialog . getByRole ( "textbox" , { name : SHORT_LINK_LABEL_RE } ) . fill ( slug ) ;
142+ await dialog . getByRole ( "button" , { name : "Create Link" } ) . click ( ) ;
143+ await expect ( linkRow ( authenticatedPage , name ) ) . toBeVisible ( ) ;
144+
145+ await authenticatedPage . getByRole ( "button" , { name : "New Link" } ) . click ( ) ;
146+ const duplicateDialog = authenticatedPage . getByRole ( "dialog" , {
147+ name : "Create Link" ,
148+ } ) ;
149+ await duplicateDialog
150+ . getByRole ( "textbox" , { name : "Destination URL" } )
151+ . fill ( `duplicate-${ targetUrl } ` ) ;
152+ await duplicateDialog
153+ . getByRole ( "textbox" , { name : "Name" } )
154+ . fill ( `${ name } duplicate` ) ;
155+ await duplicateDialog
156+ . getByRole ( "textbox" , { name : SHORT_LINK_LABEL_RE } )
157+ . fill ( slug ) ;
158+ await duplicateDialog . getByRole ( "button" , { name : "Create Link" } ) . click ( ) ;
105159 await expect (
106- authenticatedPage . getByRole ( "link" , { name : new RegExp ( updatedName ) } )
107- ) . toBeHidden ( ) ;
160+ authenticatedPage . getByText ( SLUG_CONFLICT_RE ) . first ( )
161+ ) . toBeVisible ( ) ;
162+ await expect ( linkRow ( authenticatedPage , `${ name } duplicate` ) ) . toBeHidden ( ) ;
108163 }
109164) ;
0 commit comments