update cypress tests, update zod validation error messaging to UI

This commit is contained in:
Francis Cao 2025-03-07 13:06:38 -08:00
parent 72ac97c5d9
commit b1901c7278
18 changed files with 221 additions and 41 deletions

View file

@ -3,6 +3,7 @@
"browser": true, "browser": true,
"es2020": true, "es2020": true,
"node": true, "node": true,
"jquery": true,
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
@ -14,6 +15,7 @@
"sourceType": "module" "sourceType": "module"
}, },
"extends": [ "extends": [
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"eslint:recommended", "eslint:recommended",
"plugin:prettier/recommended", "plugin:prettier/recommended",
@ -39,7 +41,8 @@
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }] "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }],
"@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }]
}, },
"globals": { "globals": {
"React": "writable" "React": "writable"

29
cypress/e2e/api.cy.ts Normal file
View file

@ -0,0 +1,29 @@
describe('Website tests', () => {
Cypress.session.clearAllSavedSessions();
beforeEach(() => {
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
});
//let userId;
it('creates a user.', () => {
cy.fixture('users').then(data => {
const userPost = data.userPost;
cy.request({
method: 'POST',
url: '/api/users',
headers: {
'Content-Type': 'application/json',
Authorization: Cypress.env('authorization'),
},
body: userPost,
}).then(response => {
//userId = response.body.id;
expect(response.status).to.eq(200);
expect(response.body).to.have.property('username', 'cypress1');
expect(response.body).to.have.property('role', 'User');
});
});
});
});

View file

@ -1,22 +1,36 @@
describe('Login tests', () => { describe('Login tests', () => {
beforeEach(() => {
cy.visit('/login');
});
it( it(
'logs user in with correct credentials and logs user out', 'logs user in with correct credentials and logs user out',
{ {
defaultCommandTimeout: 10000, defaultCommandTimeout: 10000,
}, },
() => { () => {
cy.visit('/login'); cy.getDataTest('input-username').find('input').as('inputUsername').click();
cy.getDataTest('input-username').find('input').click(); cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 }); cy.get('@inputUsername').click();
cy.getDataTest('input-password').find('input').click();
cy.getDataTest('input-password') cy.getDataTest('input-password')
.find('input') .find('input')
.type(Cypress.env('umami_password'), { delay: 50 }); .type(Cypress.env('umami_password'), { delay: 0 });
cy.getDataTest('button-submit').click(); cy.getDataTest('button-submit').click();
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
cy.getDataTest('button-profile').click(); cy.logout();
cy.getDataTest('item-logout').click();
cy.url().should('eq', Cypress.config().baseUrl + '/login');
}, },
); );
it('login with blank inputs or incorrect credentials', () => {
cy.getDataTest('button-submit').click();
cy.contains(/Required/i).should('be.visible');
cy.getDataTest('input-username').find('input').as('inputUsername');
cy.get('@inputUsername').click();
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
cy.get('@inputUsername').click();
cy.getDataTest('input-password').find('input').type('wrongpassword', { delay: 0 });
cy.getDataTest('button-submit').click();
cy.contains(/Incorrect username and\/or password./i).should('be.visible');
});
}); });

65
cypress/e2e/user.cy.ts Normal file
View file

@ -0,0 +1,65 @@
describe('Website tests', () => {
Cypress.session.clearAllSavedSessions();
beforeEach(() => {
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
cy.visit('/settings/users');
});
it('Add a User', () => {
// add user
cy.contains(/Create user/i).should('be.visible');
cy.getDataTest('button-create-user').click();
cy.getDataTest('input-username').find('input').as('inputName').click();
cy.get('@inputName').type('Test-user', { delay: 0 });
cy.getDataTest('input-password').find('input').as('inputPassword').click();
cy.get('@inputPassword').type('testPasswordCypress', { delay: 0 });
cy.getDataTest('dropdown-role').click();
cy.getDataTest('dropdown-item-user').click();
cy.getDataTest('button-submit').click();
cy.get('td[label="Username"]').should('contain.text', 'Test-user');
cy.get('td[label="Role"]').should('contain.text', 'User');
});
it('Edit a User role and password', () => {
// edit user
cy.get('table tbody tr')
.contains('td', /Test-user/i)
.parent()
.within(() => {
cy.getDataTest('link-button-edit').click(); // Clicks the button inside the row
});
cy.getDataTest('input-password').find('input').as('inputPassword').click();
cy.get('@inputPassword').type('newPassword', { delay: 0 });
cy.getDataTest('dropdown-role').click();
cy.getDataTest('dropdown-item-viewOnly').click();
cy.getDataTest('button-submit').click();
cy.visit('/settings/users');
cy.get('table tbody tr')
.contains('td', /Test-user/i)
.parent()
.should('contain.text', 'View only');
cy.logout();
cy.url().should('eq', Cypress.config().baseUrl + '/login');
cy.getDataTest('input-username').find('input').as('inputUsername').click();
cy.get('@inputUsername').type('Test-user', { delay: 0 });
cy.get('@inputUsername').click();
cy.getDataTest('input-password').find('input').type('newPassword', { delay: 0 });
cy.getDataTest('button-submit').click();
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
});
it('Delete a website', () => {
// delete user
cy.get('table tbody tr')
.contains('td', /Test-user/i)
.parent()
.within(() => {
cy.getDataTest('button-delete').click(); // Clicks the button inside the row
});
cy.contains(/Are you sure you want to delete Test-user?/i).should('be.visible');
cy.getDataTest('button-confirm').click();
});
});

View file

@ -10,10 +10,10 @@ describe('Website tests', () => {
cy.visit('/settings/websites'); cy.visit('/settings/websites');
cy.getDataTest('button-website-add').click(); cy.getDataTest('button-website-add').click();
cy.contains(/Add website/i).should('be.visible'); cy.contains(/Add website/i).should('be.visible');
cy.getDataTest('input-name').find('input').click(); cy.getDataTest('input-name').find('input').as('inputUsername').click();
cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 }); cy.getDataTest('input-name').find('input').type('Add test', { delay: 0 });
cy.getDataTest('input-domain').find('input').click(); cy.getDataTest('input-domain').find('input').click();
cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 50 }); cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 0 });
cy.getDataTest('button-submit').click(); cy.getDataTest('button-submit').click();
cy.get('td[label="Name"]').should('contain.text', 'Add test'); cy.get('td[label="Name"]').should('contain.text', 'Add test');
cy.get('td[label="Domain"]').should('contain.text', 'addtest.com'); cy.get('td[label="Domain"]').should('contain.text', 'addtest.com');
@ -41,10 +41,10 @@ describe('Website tests', () => {
cy.contains(/Details/i).should('be.visible'); cy.contains(/Details/i).should('be.visible');
cy.getDataTest('input-name').find('input').click(); cy.getDataTest('input-name').find('input').click();
cy.getDataTest('input-name').find('input').clear(); cy.getDataTest('input-name').find('input').clear();
cy.getDataTest('input-name').find('input').type('Updated website', { delay: 50 }); cy.getDataTest('input-name').find('input').type('Updated website', { delay: 0 });
cy.getDataTest('input-domain').find('input').click(); cy.getDataTest('input-domain').find('input').click();
cy.getDataTest('input-domain').find('input').clear(); cy.getDataTest('input-domain').find('input').clear();
cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 50 }); cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 0 });
cy.getDataTest('button-submit').click({ force: true }); cy.getDataTest('button-submit').click({ force: true });
cy.getDataTest('input-name').find('input').should('have.value', 'Updated website'); cy.getDataTest('input-name').find('input').should('have.value', 'Updated website');
cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com'); cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com');

View file

@ -0,0 +1,17 @@
{
"userGet": {
"name": "cypress",
"email": "password",
"role": "User"
},
"userPost": {
"username": "cypress1",
"password": "password",
"role": "User"
},
"userDelete": {
"name": "Charlie",
"email": "charlie@example.com",
"age": 35
}
}

View file

@ -5,6 +5,12 @@ Cypress.Commands.add('getDataTest', (value: string) => {
return cy.get(`[data-test=${value}]`); return cy.get(`[data-test=${value}]`);
}); });
Cypress.Commands.add('logout', () => {
cy.getDataTest('button-profile').click();
cy.getDataTest('item-logout').click();
cy.url().should('eq', Cypress.config().baseUrl + '/login');
});
Cypress.Commands.add('login', (username: string, password: string) => { Cypress.Commands.add('login', (username: string, password: string) => {
cy.session([username, password], () => { cy.session([username, password], () => {
cy.request({ cy.request({

View file

@ -1,4 +1,5 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
/* global JQuery */
declare namespace Cypress { declare namespace Cypress {
interface Chainable { interface Chainable {
@ -7,6 +8,11 @@ declare namespace Cypress {
* @example cy.getDataTest('greeting') * @example cy.getDataTest('greeting')
*/ */
getDataTest(value: string): Chainable<JQuery<HTMLElement>>; getDataTest(value: string): Chainable<JQuery<HTMLElement>>;
/**
* Custom command to logout through UI.
* @example cy.logout()
*/
logout(): Chainable<JQuery<HTMLElement>>;
/** /**
* Custom command to login user into the app. * Custom command to login user into the app.
* @example cy.login('admin', 'password) * @example cy.login('admin', 'password)

View file

@ -15,7 +15,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) {
return ( return (
<ModalTrigger> <ModalTrigger>
<Button variant="primary"> <Button data-test="button-create-user" variant="primary">
<Icon> <Icon>
<Icons.Plus /> <Icons.Plus />
</Icon> </Icon>

View file

@ -12,6 +12,7 @@ import {
} from 'react-basics'; } from 'react-basics';
import { useApi, useMessages } from '@/components/hooks'; import { useApi, useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { messages } from '@/components/messages';
export function UserAddForm({ onSave, onClose }) { export function UserAddForm({ onSave, onClose }) {
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
@ -44,26 +45,43 @@ export function UserAddForm({ onSave, onClose }) {
return ( return (
<Form onSubmit={handleSubmit} error={error}> <Form onSubmit={handleSubmit} error={error}>
<FormRow label={formatMessage(labels.username)}> <FormRow label={formatMessage(labels.username)}>
<FormInput name="username" rules={{ required: formatMessage(labels.required) }}> <FormInput
data-test="input-username"
name="username"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="new-username" /> <TextField autoComplete="new-username" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.password)}> <FormRow label={formatMessage(labels.password)}>
<FormInput name="password" rules={{ required: formatMessage(labels.required) }}> <FormInput
data-test="input-password"
name="password"
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
}}
>
<PasswordField autoComplete="new-password" /> <PasswordField autoComplete="new-password" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.role)}> <FormRow label={formatMessage(labels.role)}>
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}> <FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
<Dropdown renderValue={renderValue}> <Dropdown data-test="dropdown-role" renderValue={renderValue}>
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item> <Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item> {formatMessage(labels.viewOnly)}
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item> </Item>
<Item data-test="dropdown-item-user" key={ROLES.user}>
{formatMessage(labels.user)}
</Item>
<Item data-test="dropdown-item-admin" key={ROLES.admin}>
{formatMessage(labels.admin)}
</Item>
</Dropdown> </Dropdown>
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormButtons flex> <FormButtons flex>
<SubmitButton variant="primary" disabled={false}> <SubmitButton data-test="button-submit" variant="primary" disabled={false}>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</SubmitButton> </SubmitButton>
<Button disabled={isPending} onClick={onClose}> <Button disabled={isPending} onClick={onClose}>

View file

@ -16,7 +16,7 @@ export function UserDeleteButton({
return ( return (
<ModalTrigger disabled={userId === user?.id}> <ModalTrigger disabled={userId === user?.id}>
<Button disabled={userId === user?.id} variant="quiet"> <Button data-test="button-delete" disabled={userId === user?.id} variant="quiet">
<Icon> <Icon>
<Icons.Trash /> <Icons.Trash />
</Icon> </Icon>

View file

@ -44,7 +44,7 @@ export function UsersTable({
<> <>
<UserDeleteButton userId={id} username={username} /> <UserDeleteButton userId={id} username={username} />
<LinkButton href={`/settings/users/${id}`}> <LinkButton href={`/settings/users/${id}`}>
<Icon> <Icon data-test="link-button-edit">
<Icons.Edit /> <Icons.Edit />
</Icon> </Icon>
<Text>{formatMessage(labels.edit)}</Text> <Text>{formatMessage(labels.edit)}</Text>

View file

@ -62,6 +62,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.password)}> <FormRow label={formatMessage(labels.password)}>
<FormInput <FormInput
data-test="input-password"
name="password" name="password"
rules={{ rules={{
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) }, minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
@ -73,16 +74,24 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
{user.id !== login.id && ( {user.id !== login.id && (
<FormRow label={formatMessage(labels.role)}> <FormRow label={formatMessage(labels.role)}>
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}> <FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
<Dropdown renderValue={renderValue}> <Dropdown data-test="dropdown-role" renderValue={renderValue}>
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item> <Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item> {formatMessage(labels.viewOnly)}
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item> </Item>
<Item data-test="dropdown-item-user" key={ROLES.user}>
{formatMessage(labels.user)}
</Item>
<Item data-test="dropdown-item-admin" key={ROLES.admin}>
{formatMessage(labels.admin)}
</Item>
</Dropdown> </Dropdown>
</FormInput> </FormInput>
</FormRow> </FormRow>
)} )}
<FormButtons> <FormButtons>
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton> <SubmitButton data-test="button-submit" variant="primary">
{formatMessage(labels.save)}
</SubmitButton>
</FormButtons> </FormButtons>
</Form> </Form>
); );

View file

@ -26,7 +26,7 @@ export async function POST(request: Request) {
const user = await getUserByUsername(username, { includePassword: true }); const user = await getUserByUsername(username, { includePassword: true });
if (!user || !checkPassword(password, user.password)) { if (!user || !checkPassword(password, user.password)) {
return unauthorized(); return unauthorized('message.incorrect-username-password');
} }
const { id, role, createdAt } = user; const { id, role, createdAt } = user;

View file

@ -1,9 +1,8 @@
import { z } from 'zod'; import { canDeleteUser, canUpdateUser, canViewUser, hashPassword } from '@/lib/auth';
import { canUpdateUser, canViewUser, canDeleteUser } from '@/lib/auth';
import { getUser, getUserByUsername, updateUser, deleteUser } from '@/queries';
import { json, unauthorized, badRequest, ok } from '@/lib/response';
import { hashPassword } from '@/lib/auth';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries';
import { z } from 'zod';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const { auth, error } = await parseRequest(request); const { auth, error } = await parseRequest(request);
@ -26,7 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({ const schema = z.object({
username: z.string().max(255), username: z.string().max(255),
password: z.string().max(255), password: z.string().max(255).optional(),
role: z.string().regex(/admin|user|view-only/i), role: z.string().regex(/admin|user|view-only/i),
}); });

View file

@ -16,7 +16,7 @@ import Logo from '@/assets/logo.svg';
import styles from './LoginForm.module.css'; import styles from './LoginForm.module.css';
export function LoginForm() { export function LoginForm() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels, getMessage } = useMessages();
const router = useRouter(); const router = useRouter();
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({ const { mutate, error, isPending } = useMutation({
@ -40,7 +40,7 @@ export function LoginForm() {
<Logo /> <Logo />
</Icon> </Icon>
<div className={styles.title}>umami</div> <div className={styles.title}>umami</div>
<Form className={styles.form} onSubmit={handleSubmit} error={error}> <Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
<FormRow label={formatMessage(labels.username)}> <FormRow label={formatMessage(labels.username)}>
<FormInput <FormInput
data-test="input-username" data-test="input-username"

View file

@ -27,7 +27,12 @@ export function ConfirmationForm({
<Form error={error}> <Form error={error}>
<p>{message}</p> <p>{message}</p>
<FormButtons flex> <FormButtons flex>
<LoadingButton isLoading={isLoading} onClick={onConfirm} variant={buttonVariant}> <LoadingButton
data-test="button-confirm"
isLoading={isLoading}
onClick={onConfirm}
variant={buttonVariant}
>
{buttonLabel || formatMessage(labels.ok)} {buttonLabel || formatMessage(labels.ok)}
</LoadingButton> </LoadingButton>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button> <Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>

View file

@ -1,4 +1,4 @@
import { ZodSchema } from 'zod'; import { z, ZodSchema } from 'zod';
import { FILTER_COLUMNS } from '@/lib/constants'; import { FILTER_COLUMNS } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response'; import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getMinimumUnit } from '@/lib/date'; import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
@ -24,12 +24,21 @@ export async function parseRequest(
let error: () => void | undefined; let error: () => void | undefined;
let auth = null; let auth = null;
const getErrorMessages = (error: z.ZodError) => {
return Object.entries(error.format())
.map(([key, value]) => {
const messages = (value as any)._errors;
return messages ? `${key}: ${messages.join(', ')}` : null;
})
.filter(Boolean);
};
if (schema) { if (schema) {
const isGet = request.method === 'GET'; const isGet = request.method === 'GET';
const result = schema.safeParse(isGet ? query : body); const result = schema.safeParse(isGet ? query : body);
if (!result.success) { if (!result.success) {
error = () => badRequest(result.error); error = () => badRequest(getErrorMessages(result.error));
} else if (isGet) { } else if (isGet) {
query = result.data; query = result.data;
} else { } else {