add cypress api and page sweep tests, fix agent and context link bugs they found

This commit is contained in:
Abhinav Raut
2026-08-19 03:18:39 +05:30
parent eb4a8c49de
commit fb4ad5f067
8 changed files with 262 additions and 4 deletions
+18
View File
@@ -6,6 +6,24 @@ jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: postgres:17-alpine
ports:
- 5432:5432
env:
POSTGRES_USER: libredesk
POSTGRES_PASSWORD: libredesk
POSTGRES_DB: libredesk
options: >-
--health-cmd="pg_isready -U libredesk"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
LIBREDESK_TEST_DB_DSN: "postgres://libredesk:libredesk@127.0.0.1:5432/libredesk?sslmode=disable&connect_timeout=3"
steps:
- name: Checkout code
uses: actions/checkout@v4
+3 -3
View File
@@ -53,7 +53,7 @@ func handleCreateContextLink(r *fastglue.Request) error {
if err := r.Decode(&contextLink, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
}
if err := validateContextLink(app, contextLink); err != nil {
if err := validateContextLink(app, &contextLink); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -77,7 +77,7 @@ func handleUpdateContextLink(r *fastglue.Request) error {
if err := r.Decode(&contextLink, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
}
if err := validateContextLink(app, contextLink); err != nil {
if err := validateContextLink(app, &contextLink); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -163,7 +163,7 @@ func handleGetContextLinkURL(r *fastglue.Request) error {
return r.SendEnvelope(url)
}
func validateContextLink(app *App, contextLink models.ContextLink) error {
func validateContextLink(app *App, contextLink *models.ContextLink) error {
if contextLink.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
+6
View File
@@ -630,5 +630,11 @@ func validateAgentRequest(app *App, req *agentReq) error {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil)
}
switch req.AvailabilityStatus {
case "", models.Online, models.Offline, models.Away, models.AwayManual, models.AwayAndReassigning:
default:
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidAvailabilityStatus"), nil)
}
return nil
}
+152
View File
@@ -0,0 +1,152 @@
// Backend contract for /api/v1/agents. No browser: these assert what the API
// really accepts, rejects and persists, independent of what the form allows.
describe('API: agents', () => {
const stamp = Date.now()
const email = `api.agent.${stamp}@example.com`
let agentId
before(() => cy.login())
beforeEach(() => cy.login())
it('rejects a create with no email', () => {
cy.api('POST', '/api/v1/agents', { first_name: 'NoEmail', roles: ['Agent'] }, {
failOnStatusCode: false
}).then(({ status, body }) => {
expect(status).to.eq(400)
expect(body.error_type).to.eq('InputException')
expect(body.message).to.match(/email/i)
})
})
it('rejects a create with a malformed email', () => {
cy.api('POST', '/api/v1/agents', {
first_name: 'Bad', email: 'not-an-email', roles: ['Agent']
}, { failOnStatusCode: false }).then(({ status, body }) => {
expect(status).to.eq(400)
expect(body.error_type).to.eq('InputException')
})
})
it('rejects a create with no first name', () => {
cy.api('POST', '/api/v1/agents', {
first_name: '', email: `blank.${stamp}@example.com`, roles: ['Agent']
}, { failOnStatusCode: false }).then(({ status, body }) => {
expect(status).to.eq(400)
expect(body.error_type).to.eq('InputException')
})
})
it('rejects a create with an unknown role', () => {
cy.api('POST', '/api/v1/agents', {
first_name: 'Ghost', email: `ghost.${stamp}@example.com`, roles: ['NoSuchRole']
}, { failOnStatusCode: false }).then(({ status, body }) => {
expect(status).to.eq(400)
expect(body.error_type).to.eq('InputException')
})
})
it('rejects an update with an unknown availability status', () => {
cy.api('POST', '/api/v1/agents', {
first_name: 'Avail', email: `avail.${stamp}@example.com`, roles: ['Agent'], send_welcome_email: false
}).then(({ body }) => {
cy.api('PUT', `/api/v1/agents/${body.data.id}`, {
first_name: 'Avail',
email: `avail.${stamp}@example.com`,
roles: ['Agent'],
enabled: true,
availability_status: 'not_a_status'
}, { failOnStatusCode: false }).then((res) => {
expect(res.status).to.eq(400)
expect(res.body.error_type).to.eq('InputException')
})
})
})
it('preserves availability status when the update omits it', () => {
const availEmail = `keep.${stamp}@example.com`
cy.api('POST', '/api/v1/agents', {
first_name: 'Keep', email: availEmail, roles: ['Agent'], send_welcome_email: false
}).then(({ body }) => {
const id = body.data.id
const before = body.data.availability_status
cy.api('PUT', `/api/v1/agents/${id}`, {
first_name: 'Keep', email: availEmail, roles: ['Agent'], enabled: true
}).its('status').should('eq', 200)
cy.api('GET', `/api/v1/agents/${id}`)
.its('body.data.availability_status')
.should('eq', before)
})
})
it('creates an agent and persists every field', () => {
cy.api('POST', '/api/v1/agents', {
first_name: 'Api',
last_name: 'Agent',
email,
roles: ['Agent'],
enabled: true,
send_welcome_email: false
}).then(({ status, body }) => {
expect(status).to.eq(200)
expect(body.status).to.eq('success')
agentId = body.data.id
expect(agentId).to.be.a('number')
expect(body.data.email).to.eq(email)
expect(body.data.first_name).to.eq('Api')
expect(body.data.last_name).to.eq('Agent')
expect(body.data.type).to.eq('agent')
expect(body.data.enabled).to.eq(true)
expect(body.data.roles).to.deep.eq(['Agent'])
})
})
it('reads the agent back by id', () => {
cy.api('GET', `/api/v1/agents/${agentId}`).then(({ status, body }) => {
expect(status).to.eq(200)
expect(body.data.email).to.eq(email)
expect(body.data.first_name).to.eq('Api')
})
})
it('lists the agent', () => {
cy.api('GET', '/api/v1/agents').then(({ status, body }) => {
expect(status).to.eq(200)
const rows = body.data.results || body.data
expect(rows.some((a) => a.email === email), 'created agent in list').to.be.true
})
})
it('rejects a duplicate email', () => {
cy.api('POST', '/api/v1/agents', {
first_name: 'Dupe', email, roles: ['Agent'], send_welcome_email: false
}, { failOnStatusCode: false }).its('status').should('be.gte', 400)
})
it('updates the agent', () => {
cy.api('PUT', `/api/v1/agents/${agentId}`, {
first_name: 'Renamed',
last_name: 'Agent',
email,
roles: ['Agent'],
enabled: true
}).its('status').should('eq', 200)
cy.api('GET', `/api/v1/agents/${agentId}`)
.its('body.data.first_name')
.should('eq', 'Renamed')
})
it('404s on an agent that does not exist', () => {
cy.api('GET', '/api/v1/agents/99999999', null, { failOnStatusCode: false })
.its('status')
.should('be.gte', 400)
})
it('deletes the agent', () => {
cy.api('DELETE', `/api/v1/agents/${agentId}`).its('status').should('eq', 200)
cy.api('GET', `/api/v1/agents/${agentId}`, null, { failOnStatusCode: false })
.its('status')
.should('be.gte', 400)
})
})
+52
View File
@@ -0,0 +1,52 @@
// Opens every page listed in the app's own navigation and reports the ones that
// break. A page that blanks out after a shared-component change is caught here
// with no per-page knowledge of forms or fields.
//
// The href list is read from the navigation source at runtime, so adding a nav
// entry extends this spec automatically.
const NAV_SOURCE = 'apps/main/src/constants/navigation.js'
const normalise = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p)
describe('Every navigable page loads', () => {
let hrefs = []
const failures = []
let current = null
// Record the page that threw and keep going, so one broken page does not hide
// the state of every page after it.
Cypress.on('uncaught:exception', (err) => {
failures.push(`${current}: ${err.message.split('\n')[0]}`)
return false
})
before(() => {
cy.readFile(NAV_SOURCE).then((src) => {
hrefs = [...src.matchAll(/href:\s*'([^']+)'/g)].map((m) => m[1])
expect(hrefs.length, 'nav hrefs discovered').to.be.greaterThan(10)
})
})
beforeEach(() => {
cy.viewport(1280, 800)
cy.login()
})
it('opens each one without a client-side error', () => {
cy.wrap(hrefs).each((href) => {
current = href
cy.visit(href)
// Landing anywhere else means the route died or the session broke.
cy.location('pathname', { timeout: 15000 })
.then((p) => normalise(p))
.should('eq', normalise(href))
cy.get('body').should('be.visible')
cy.contains(/something went wrong|unexpected error/i).should('not.exist')
})
cy.then(() => {
expect(failures, `pages that threw:\n${failures.join('\n')}`).to.be.empty
})
})
})
+15
View File
@@ -52,3 +52,18 @@ Cypress.Commands.add('selectOption', (triggerLabel, optionText) => {
cy.contains('button[role="combobox"]', triggerLabel).click()
cy.get('[role="option"]').contains(optionText).click()
})
// Authenticated API request. Writes need the X-CSRFTOKEN header echoing the
// csrf_token cookie the backend set at login, else they are rejected with 403.
// Pass failOnStatusCode: false to assert on error responses.
Cypress.Commands.add('api', (method, path, body, options = {}) => {
return cy.getCookie('csrf_token').then((cookie) => {
return cy.request({
method,
url: path,
body,
headers: cookie ? { 'X-CSRFTOKEN': cookie.value } : {},
...options
})
})
})
+2
View File
@@ -1471,6 +1471,7 @@
"user.userAlreadyLoggedIn": "User already logged in",
"user.userCannotDeleteSelf": "You cannot delete yourself",
"validation.invalid": "Invalid",
"validation.invalidAvailabilityStatus": "Invalid availability status",
"validation.invalidColor": "Invalid color",
"validation.invalidCredential": "Invalid credential",
"validation.invalidCsvFile": "Invalid CSV file",
@@ -1484,6 +1485,7 @@
"validation.invalidPermission": "Invalid permission",
"validation.invalidPhone": "Invalid phone number",
"validation.invalidPortValue": "Invalid port value",
"validation.invalidRole": "Invalid role",
"validation.invalidSnoozeDuration": "Invalid snooze duration",
"validation.invalidTimeFormat": "Invalid time format (HH:mm)",
"validation.invalidUrl": "Invalid URL",
+14 -1
View File
@@ -2,6 +2,8 @@ package user
import (
"context"
"database/sql"
"errors"
"strings"
"time"
@@ -102,6 +104,10 @@ func (u *Manager) CreateAgent(firstName, lastName, email string, roles []string)
if dbutil.IsUniqueViolationError(err) {
return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
}
// The insert joins the named roles, so an unknown role yields no row.
if errors.Is(err, sql.ErrNoRows) {
return models.User{}, envelope.NewError(envelope.InputError, u.i18n.T("validation.invalidRole"), nil)
}
u.lo.Error("error creating user", "error", err)
return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -129,7 +135,14 @@ func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles [
}
// Update user in the database.
if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availabilityStatus); err != nil {
// COALESCE in the query preserves the stored status only on NULL; an empty
// string would reach the enum cast and error.
availability := null.String{}
if availabilityStatus != "" {
availability = null.StringFrom(availabilityStatus)
}
if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availability); err != nil {
if dbutil.IsUniqueViolationError(err) {
return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
}