test: add event creation test
All checks were successful
tests / Tests (pull_request) Successful in 3m45s
docker-build / docker (pull_request) Successful in 3m47s
container-scan / Container Scan (pull_request) Successful in 2m38s

This commit is contained in:
Dominik 2025-07-01 08:42:15 +02:00
parent dbf9809c7b
commit 1ec636f3b0
Signed by: dominik
GPG key ID: 06A4003FC5049644
9 changed files with 251 additions and 189 deletions

View file

@ -1,12 +0,0 @@
export default function authUser() {
cy.visit('http://127.0.0.1:3000/login');
cy.getBySel('login-header').should('exist');
cy.getBySel('login-form').should('exist');
cy.getBySel('email-input').should('exist');
cy.getBySel('password-input').should('exist');
cy.getBySel('login-button').should('exist');
cy.getBySel('email-input').type('cypress@example.com');
cy.getBySel('password-input').type('Password123!');
cy.getBySel('login-button').click();
cy.url().should('include', '/home');
}

View file

@ -1,9 +1,40 @@
import authUser from './auth-user';
describe('event creation', () => { describe('event creation', () => {
it('loads', () => { it('loads', () => {
authUser(); cy.login();
// cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests cy.visit('http://127.0.0.1:3000/events/new');
cy.getBySel('event-form').should('exist');
cy.getBySel('event-form').within(() => {
cy.getBySel('event-name-input').should('exist');
cy.getBySel('event-start-time-picker').should('exist');
cy.getBySel('event-end-time-picker').should('exist');
cy.getBySel('event-location-input').should('exist');
cy.getBySel('event-description-input').should('exist');
cy.getBySel('event-save-button').should('exist');
});
});
it('creates an event', () => {
cy.login();
cy.visit(
'http://127.0.0.1:3000/events/new?start=2025-07-01T01:00:00.000Z&end=2025-07-01T04:30:00.000Z',
);
cy.getBySel('event-form').should('exist');
cy.getBySel('event-form').within(() => {
cy.getBySel('event-name-input').type('Cypress Test Event');
cy.getBySel('event-location-input').type('Cypress Park');
cy.getBySel('event-description-input').type(
'This is a test event created by Cypress.',
);
cy.getBySel('event-save-button').click();
});
cy.wait(1000);
cy.visit('http://127.0.0.1:3000/events');
cy.getBySel('event-list-entry').should('exist');
cy.getBySel('event-list-entry')
.contains('Cypress Test Event')
.should('exist');
cy.getBySel('event-list-entry').contains('Cypress Park').should('exist');
}); });
}); });

View file

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-namespace */
/// <reference types="cypress" /> /// <reference types="cypress" />
// *********************************************** // ***********************************************
// This example commands.ts shows you how to // This example commands.ts shows you how to
@ -44,6 +46,22 @@ Cypress.Commands.add('getBySelLike', (selector, ...args) => {
return cy.get(`[data-cy*=${selector}]`, ...args); return cy.get(`[data-cy*=${selector}]`, ...args);
}); });
Cypress.Commands.add('login', () => {
cy.session('auth', () => {
cy.visit('http://127.0.0.1:3000/login');
cy.getBySel('login-header').should('exist');
cy.getBySel('login-form').should('exist');
cy.getBySel('email-input').should('exist');
cy.getBySel('password-input').should('exist');
cy.getBySel('login-button').should('exist');
cy.getBySel('email-input').type('cypress@example.com');
cy.getBySel('password-input').type('Password123!');
cy.getBySel('login-button').click();
cy.url().should('include', '/home');
cy.getBySel('header').should('exist');
});
});
declare global { declare global {
namespace Cypress { namespace Cypress {
interface Chainable { interface Chainable {
@ -55,6 +73,7 @@ declare global {
selector: string, selector: string,
...args: any[] ...args: any[]
): Chainable<JQuery<HTMLElement>>; ): Chainable<JQuery<HTMLElement>>;
login(): Chainable<void>;
} }
} }
} }

View file

@ -17,7 +17,10 @@ export default function Events() {
const events = eventsData?.data?.events || []; const events = eventsData?.data?.events || [];
return ( return (
<div className='relative h-full flex flex-col items-center'> <div
className='relative h-full flex flex-col items-center'
data-cy='events-page'
>
{/* Heading */} {/* Heading */}
<h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'> <h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'>
My Events My Events

View file

@ -43,7 +43,10 @@ export default function EventListEntry({
return ( return (
<Link href={`/events/${id}`} className='block'> <Link href={`/events/${id}`} className='block'>
<Card className='w-full'> <Card className='w-full'>
<div className='grid grid-cols-1 gap-2 mx-auto md:mx-4 md:grid-cols-[80px_1fr_250px]'> <div
className='grid grid-cols-1 gap-2 mx-auto md:mx-4 md:grid-cols-[80px_1fr_250px]'
data-cy='event-list-entry'
>
<div className='w-full items-center justify-center grid'> <div className='w-full items-center justify-center grid'>
<Logo colorType='monochrome' logoType='submark' width={50} /> <Logo colorType='monochrome' logoType='submark' width={50} />
</div> </div>

View file

@ -17,6 +17,7 @@ export default function LabeledInput({
variantSize = 'default', variantSize = 'default',
autocomplete, autocomplete,
error, error,
'data-cy': dataCy,
...rest ...rest
}: { }: {
label: string; label: string;
@ -30,6 +31,7 @@ export default function LabeledInput({
variantSize?: 'default' | 'big' | 'textarea'; variantSize?: 'default' | 'big' | 'textarea';
autocomplete?: string; autocomplete?: string;
error?: string; error?: string;
'data-cy'?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) { } & React.InputHTMLAttributes<HTMLInputElement>) {
const [passwordVisible, setPasswordVisible] = React.useState(false); const [passwordVisible, setPasswordVisible] = React.useState(false);
const [inputValue, setInputValue] = React.useState( const [inputValue, setInputValue] = React.useState(
@ -58,6 +60,7 @@ export default function LabeledInput({
id={name} id={name}
name={name} name={name}
rows={3} rows={3}
data-cy={dataCy}
/> />
) : ( ) : (
<span className='relative'> <span className='relative'>
@ -76,6 +79,7 @@ export default function LabeledInput({
id={name} id={name}
name={name} name={name}
autoComplete={autocomplete} autoComplete={autocomplete}
data-cy={dataCy}
{...rest} {...rest}
onChange={handleInputChange} onChange={handleInputChange}
/> />

View file

@ -191,11 +191,6 @@ const EventForm: React.FC<EventFormProps> = (props) => {
router.back(); router.back();
} }
// Calculate values for organiser, created, and updated
const organiserValue = isLoading
? 'Loading...'
: data?.data.user?.name || 'Unknown User';
// Use DB values for created_at/updated_at in edit mode // Use DB values for created_at/updated_at in edit mode
const createdAtValue = const createdAtValue =
props.type === 'edit' && event?.created_at props.type === 'edit' && event?.created_at
@ -210,188 +205,203 @@ const EventForm: React.FC<EventFormProps> = (props) => {
const createdAtDisplay = new Date(createdAtValue).toLocaleDateString(); const createdAtDisplay = new Date(createdAtValue).toLocaleDateString();
const updatedAtDisplay = new Date(updatedAtValue).toLocaleDateString(); const updatedAtDisplay = new Date(updatedAtValue).toLocaleDateString();
const [isClient, setIsClient] = React.useState(false);
React.useEffect(() => {
setIsClient(true);
}, []);
if (props.type === 'edit' && isLoading) return <div>Loading...</div>; if (props.type === 'edit' && isLoading) return <div>Loading...</div>;
if (props.type === 'edit' && fetchError) if (props.type === 'edit' && fetchError)
return <div>Error loading event.</div>; return <div>Error loading event.</div>;
return ( return (
<> <Dialog open={calendarOpen} onOpenChange={setCalendarOpen}>
<Dialog open={calendarOpen} onOpenChange={setCalendarOpen}> <form
<form className='flex flex-col gap-5 w-full' onSubmit={handleSubmit}> className='flex flex-col gap-5 w-full'
<div className='grid grid-row-start:auto gap-4 sm:gap-8 w-full'> onSubmit={handleSubmit}
<div className='h-full w-full mt-0 ml-2 mb-16 flex items-center max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'> data-cy='event-form'
<div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'> >
<Logo colorType='monochrome' logoType='submark' width={50} /> <div className='grid grid-row-start:auto gap-4 sm:gap-8 w-full'>
</div> <div className='h-full w-full mt-0 ml-2 mb-16 flex items-center max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full'> <div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<LabeledInput <Logo colorType='monochrome' logoType='submark' width={50} />
type='text'
label='Event Name'
placeholder={
props.type === 'create' ? 'New Event' : 'Event Name'
}
name='eventName'
variantSize='big'
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className='w-0 sm:w-[50px]'></div>
</div> </div>
<div className='grid grid-cols-4 gap-4 h-full w-full max-lg:grid-cols-2 max-sm:grid-cols-1'> <div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full'>
<div> <LabeledInput
<TimePicker type='text'
dateLabel='start Time' label='Event Name'
timeLabel='&nbsp;' placeholder={
date={startDate} props.type === 'create' ? 'New Event' : 'Event Name'
setDate={setStartDate} }
time={startTime} name='eventName'
setTime={setStartTime} variantSize='big'
/> value={title}
onChange={(e) => setTitle(e.target.value)}
data-cy='event-name-input'
/>
</div>
<div className='w-0 sm:w-[50px]'></div>
</div>
<div className='grid grid-cols-4 gap-4 h-full w-full max-lg:grid-cols-2 max-sm:grid-cols-1'>
<div>
<TimePicker
dateLabel='start Time'
timeLabel='&nbsp;'
date={startDate}
setDate={setStartDate}
time={startTime}
setTime={setStartTime}
data-cy='event-start-time-picker'
/>
</div>
<div>
<TimePicker
dateLabel='end Time'
timeLabel='&nbsp;'
date={endDate}
setDate={setEndDate}
time={endTime}
setTime={setEndTime}
data-cy='event-end-time-picker'
/>
</div>
<div className='w-54'>
<LabeledInput
type='text'
label='Location'
placeholder='where is the event?'
name='eventLocation'
value={location}
onChange={(e) => setLocation(e.target.value)}
data-cy='event-location-input'
/>
</div>
<div className='flex flex-col gap-4'>
<div className='flex flex-row gap-2'>
<Label className='w-[70px]'>created:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{createdAtDisplay}
</Label>
</div> </div>
<div> <div className='flex flex-row gap-2'>
<TimePicker <Label className='w-[70px]'>updated:</Label>
dateLabel='end Time' <p className='text-[var(--color-neutral-300)]'>
timeLabel='&nbsp;' {updatedAtDisplay}
date={endDate} </p>
setDate={setEndDate}
time={endTime}
setTime={setEndTime}
/>
</div> </div>
<div className='w-54'> </div>
<LabeledInput </div>
type='text' <div className='h-full w-full grid grid-cols-2 gap-4 max-sm:grid-cols-1'>
label='Location' <div className='h-full w-full grid grid-flow-row gap-4'>
placeholder='where is the event?' <div className='h-full w-full'>
name='eventLocation'
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className='flex flex-col gap-4'>
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Label className='w-[70px]'>created:</Label> <Label>Organiser:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{createdAtDisplay}
</Label>
</div>
<div className='flex flex-row gap-2'>
<Label className='w-[70px]'>updated:</Label>
<p className='text-[var(--color-neutral-300)]'> <p className='text-[var(--color-neutral-300)]'>
{updatedAtDisplay} {!isClient || isLoading
? 'Loading...'
: data?.data.user?.name || 'Unknown User'}
</p> </p>
</div> </div>
</div> </div>
</div>
<div className='h-full w-full grid grid-cols-2 gap-4 max-sm:grid-cols-1'>
<div className='h-full w-full grid grid-flow-row gap-4'>
<div className='h-full w-full'>
<div className='flex flex-row gap-2'>
<Label>Organiser:</Label>
<Label className='text-[var(--color-neutral-300)]'>
{organiserValue}
</Label>
</div>
</div>
<div className='h-full w-full'>
<LabeledInput
type='text'
label='Event Description'
placeholder='What is the event about?'
name='eventDescription'
variantSize='textarea'
value={description}
onChange={(e) => setDescription(e.target.value)}
></LabeledInput>
</div>
</div>
<div className='h-full w-full'> <div className='h-full w-full'>
<Label>Participants</Label> <LabeledInput
<UserSearchInput type='text'
selectedUsers={selectedParticipants} label='Event Description'
addUserAction={(user) => { placeholder='What is the event about?'
setSelectedParticipants((current) => name='eventDescription'
current.find((u) => u.id === user.id) variantSize='textarea'
? current value={description}
: [...current, user], onChange={(e) => setDescription(e.target.value)}
); data-cy='event-description-input'
}} ></LabeledInput>
removeUserAction={(user) => {
setSelectedParticipants((current) =>
current.filter((u) => u.id !== user.id),
);
}}
/>
<DialogTrigger asChild>
<Button variant='primary'>Calendar</Button>
</DialogTrigger>
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{selectedParticipants.map((user) => (
<ParticipantListEntry
key={user.id}
user={user}
status='PENDING'
/>
))}
</div>
</div> </div>
</div> </div>
<div className='h-full w-full'>
<div className='flex flex-row gap-2 justify-end mt-4 mb-6'> <Label>Participants</Label>
<div className='w-[20%] grid max-sm:w-[40%]'> <UserSearchInput
<Button selectedUsers={selectedParticipants}
type='button' addUserAction={(user) => {
variant='secondary' setSelectedParticipants((current) =>
onClick={() => { current.find((u) => u.id === user.id)
router.back(); ? current
console.log('user aborted - no change in database'); : [...current, user],
}} );
> }}
cancel removeUserAction={(user) => {
</Button> setSelectedParticipants((current) =>
</div> current.filter((u) => u.id !== user.id),
<div className='w-[20%] grid max-sm:w-[40%]'> );
<Button }}
type='submit' />
variant='primary' <DialogTrigger asChild>
disabled={status === 'pending'} <Button variant='primary'>Calendar</Button>
> </DialogTrigger>
{status === 'pending' ? 'Saving...' : 'save event'} <div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
</Button> {selectedParticipants.map((user) => (
<ParticipantListEntry
key={user.id}
user={user}
status='PENDING'
/>
))}
</div> </div>
</div> </div>
{isSuccess && <p>Event created!</p>}
{error && <p className='text-red-500'>Error: {error.message}</p>}
</div> </div>
</form>
<DialogContent className='sm:max-w-[750px]'> <div className='flex flex-row gap-2 justify-end mt-4 mb-6'>
<DialogHeader> <div className='w-[20%] grid max-sm:w-[40%]'>
<DialogTitle>Calendar</DialogTitle> <Button
<DialogDescription> type='button'
Calendar for selected participants variant='secondary'
</DialogDescription> onClick={() => {
</DialogHeader> router.back();
<DialogFooter className='max-w-[calc(100svw-70px)]'> console.log('user aborted - no change in database');
<Calendar }}
userId={selectedParticipants.map((u) => u.id)} >
additionalEvents={[ cancel
{ </Button>
id: 'temp-event', </div>
title: title || 'New Event', <div className='w-[20%] grid max-sm:w-[40%]'>
start: startDate ? new Date(startDate) : new Date(), <Button
end: endDate ? new Date(endDate) : new Date(), type='submit'
type: 'event', variant='primary'
userId: 'create-event', disabled={status === 'pending'}
colorOverride: '#ff9800', data-cy='event-save-button'
}, >
]} {status === 'pending' ? 'Saving...' : 'save event'}
height='600px' </Button>
/> </div>
</DialogFooter> </div>
</DialogContent> {isSuccess && <p>Event created!</p>}
</Dialog> {error && <p className='text-red-500'>Error: {error.message}</p>}
</> </div>
</form>
<DialogContent className='sm:max-w-[750px]'>
<DialogHeader>
<DialogTitle>Calendar</DialogTitle>
<DialogDescription>
Calendar for selected participants
</DialogDescription>
</DialogHeader>
<DialogFooter className='max-w-[calc(100svw-70px)]'>
<Calendar
userId={selectedParticipants.map((u) => u.id)}
additionalEvents={[
{
id: 'temp-event',
title: title || 'New Event',
start: startDate ? new Date(startDate) : new Date(),
end: endDate ? new Date(endDate) : new Date(),
type: 'event',
userId: 'create-event',
colorOverride: '#ff9800',
},
]}
height='600px'
/>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };

View file

@ -25,7 +25,10 @@ export default function Header({
}>) { }>) {
return ( return (
<div className='w-full grid grid-rows-[50px_1fr] h-screen'> <div className='w-full grid grid-rows-[50px_1fr] h-screen'>
<header className='border-b-1 grid-cols-[1fr_3fr_1fr] grid items-center px-2 shadow-md'> <header
className='border-b-1 grid-cols-[1fr_3fr_1fr] grid items-center px-2 shadow-md'
data-cy='header'
>
<span className='flex justify-start'> <span className='flex justify-start'>
<SidebarTrigger variant='outline_primary' size='icon' /> <SidebarTrigger variant='outline_primary' size='icon' />
</span> </span>

View file

@ -20,6 +20,7 @@ export default function TimePicker({
setDate, setDate,
time, time,
setTime, setTime,
...props
}: { }: {
dateLabel?: string; dateLabel?: string;
timeLabel?: string; timeLabel?: string;
@ -27,11 +28,11 @@ export default function TimePicker({
setDate?: (date: Date | undefined) => void; setDate?: (date: Date | undefined) => void;
time?: string; time?: string;
setTime?: (time: string) => void; setTime?: (time: string) => void;
}) { } & React.HTMLAttributes<HTMLDivElement>) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
return ( return (
<div className='flex gap-4'> <div className='flex gap-4' {...props}>
<div className='flex flex-col gap-3'> <div className='flex flex-col gap-3'>
<Label htmlFor='date' className='px-1'> <Label htmlFor='date' className='px-1'>
{dateLabel} {dateLabel}