Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Frontend/cypress.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from 'cypress';

export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/e2e.js',
viewportWidth: 1280,
viewportHeight: 800,
video: false,
screenshotOnRunFailure: true,
defaultCommandTimeout: 8000,
requestTimeout: 10000,
setupNodeEvents(on, config) {
return config;
},
},
env: {
ADMIN_EMAIL: 'admin@helpdesk.ai',
ADMIN_PASSWORD: 'AdminPass@123',
},
});
83 changes: 83 additions & 0 deletions Frontend/cypress/e2e/admin-settings.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* E2E test suite — Admin Settings persistence workflows
* Covers: settings load, update, persist on reload, and API roundtrip
*/

describe('Admin Settings — Persistence Workflows', () => {
beforeEach(() => {
cy.stubSettingsApi();
cy.loginAsAdmin();
cy.goToAdminSettings();
});

it('renders all major settings sections', () => {
cy.contains('AI Settings').should('be.visible');
cy.contains('Ticket Settings').should('be.visible');
cy.contains('Notifications').should('be.visible');
});

it('displays the current AI confidence threshold value', () => {
cy.wait('@getSettings');
// The slider label reflects the persisted value
cy.contains(/AI Confidence Threshold/i).should('be.visible');
cy.get('input[type="range"]').first().should('have.value', '0.7');
});

it('updates AI confidence threshold and reflects the new value in label', () => {
cy.wait('@getSettings');
cy.get('input[type="range"]').first().as('slider');

// Cypress range slider interaction: set value via invoke then trigger change
cy.get('@slider').invoke('val', '0.9').trigger('change');
cy.contains('90%').should('be.visible');
});

it('toggles Auto Resolve and verifies state change', () => {
cy.wait('@getSettings');
cy.contains('Enable Auto Resolve')
.closest('div.flex')
.find('button')
.as('toggle');

cy.get('@toggle').then(($btn) => {
const wasActive = $btn.hasClass('bg-indigo-600');
cy.get('@toggle').click();
if (wasActive) {
cy.get('@toggle').should('have.class', 'bg-slate-200');
} else {
cy.get('@toggle').should('have.class', 'bg-indigo-600');
}
});
});

it('persists settings after page reload (store hydration)', () => {
cy.wait('@getSettings');
// Set a specific slider value
cy.get('input[type="range"]').eq(1).invoke('val', '0.65').trigger('change');
cy.contains('65%').should('be.visible');

// Reload and verify the value is still there via stubbed API
cy.reload();
cy.wait('@getSettings');
cy.contains(/Duplicate Detection/i).should('be.visible');
});

it('email notifications toggle switches between active and inactive state', () => {
cy.wait('@getSettings');
cy.contains('Email Notifications')
.closest('div.flex')
.find('button')
.as('emailToggle');

cy.get('@emailToggle').click();
cy.get('@emailToggle').should('not.have.class', 'bg-amber-500');

cy.get('@emailToggle').click();
cy.get('@emailToggle').should('have.class', 'bg-amber-500');
});

it('auto-close days selector updates the persisted value', () => {
cy.wait('@getSettings');
cy.contains('Auto-Close Tickets').should('be.visible');
});
});
127 changes: 127 additions & 0 deletions Frontend/cypress/e2e/auto-close-timeline.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* E2E test suite — Auto-Close Ticket Notification & Timeline Workflows
* Covers: WebSocket mocked events, status transitions, timeline rendering
*/

const MOCK_TICKET = {
ticket_id: 'test-ticket-001',
subject: 'VPN connection drops after 30 minutes',
status: 'resolved',
priority: 'medium',
created_at: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString(),
auto_close_scheduled: true,
};

describe('Auto-Close Ticket Timeline & Notifications', () => {
beforeEach(() => {
// Stub all ticket-related API calls
cy.intercept('GET', '**/tickets**', {
body: [MOCK_TICKET],
}).as('getTickets');

cy.intercept('GET', `**/tickets?ticket_id=eq.${MOCK_TICKET.ticket_id}**`, {
body: [MOCK_TICKET],
}).as('getTicketDetail');
Comment on lines +23 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match the ticket detail query the app actually sends

The detail page fetches tickets with .eq('id', ticket_id) after navigating to /admin/ticket/:ticket_id, but this alias only matches ticket_id=eq.... In the detail-view tests that do cy.wait('@getTicketDetail'), the Supabase request will be id=eq.test-ticket-001 (or be caught by the broad @getTickets route), so the @getTicketDetail wait times out and the specs never reach their assertions.

Useful? React with 👍 / 👎.


cy.intercept('PATCH', `**/tickets?ticket_id=eq.${MOCK_TICKET.ticket_id}**`, (req) => {
req.reply({ body: [{ ...MOCK_TICKET, ...req.body }] });
}).as('updateTicket');

cy.stubSettingsApi({ autoCloseDays: 7 });
cy.loginAsAdmin();
});

it('renders ticket list showing resolved tickets pending auto-close', () => {
cy.visit('/admin/tickets');
cy.wait('@getTickets');
cy.contains(MOCK_TICKET.subject).should('be.visible');
});

it('shows ticket timeline section in ticket detail view', () => {
cy.visit('/admin/tickets');
cy.wait('@getTickets');
cy.contains(MOCK_TICKET.subject).click({ force: true });
cy.wait('@getTicketDetail');
// Timeline section or status badge should be present
cy.get('body').then(($body) => {
const hasTimeline = $body.text().includes('resolved') || $body.text().includes('Resolved');
expect(hasTimeline).to.be.true;
});
});

it('dynamically updates ticket status when realtime event fires', () => {
cy.visit('/admin/tickets');
cy.wait('@getTickets');
cy.contains(MOCK_TICKET.subject).should('be.visible');

// Simulate a WebSocket status update
cy.emitRealtimeTicketUpdate(MOCK_TICKET.ticket_id, 'closed');

// Verify the app reacts — the ticket should reflect new status
cy.get('body').should('exist'); // baseline assertion that page did not crash
});

it('notification popover renders after realtime update', () => {
cy.visit('/admin/tickets');
cy.wait('@getTickets');

cy.emitRealtimeTicketUpdate(MOCK_TICKET.ticket_id, 'closed');

// Bell icon or notification badge should appear/update
cy.get('[data-testid="notification-bell"], [aria-label*="notification"], button')
.filter(':contains("bell"), :has(svg)')
.first()
.should('exist');
});

it('settings auto-close days value reflects in admin settings UI', () => {
cy.goToAdminSettings();
cy.wait('@getSettings');
cy.contains('Auto-Close Tickets').should('be.visible');
cy.contains('7').should('exist');
});

it('changing auto-close days sends PATCH to settings API', () => {
cy.goToAdminSettings();
cy.wait('@getSettings');

// Interact with the auto-close dropdown/select
cy.contains('Auto-Close Tickets')
.closest('[class*="flex"]')
.find('select, button[role="combobox"], [data-testid="select-trigger"]')
.first()
.click({ force: true });

// If a select element, change it directly
cy.get('select').then(($selects) => {
if ($selects.length > 0) {
cy.wrap($selects.first()).select('14', { force: true });
}
});
});

it('ticket auto-close countdown badge is visible for stale resolved tickets', () => {
cy.intercept('GET', '**/tickets**', {
body: [{ ...MOCK_TICKET, status: 'resolved', auto_close_scheduled: true }],
}).as('getStaleTickets');

cy.visit('/admin/tickets');
cy.wait('@getStaleTickets');
cy.get('body').should('exist');
});

it('admin can manually close a ticket before auto-close fires', () => {
cy.visit('/admin/tickets');
cy.wait('@getTickets');
cy.contains(MOCK_TICKET.subject).click({ force: true });
cy.wait('@getTicketDetail');

// Look for a close/resolve action button
cy.get('button').filter(':contains("Close"), :contains("Resolve")').first().then(($btn) => {
if ($btn.length > 0) {
cy.wrap($btn).click({ force: true });
}
});
});
});
66 changes: 66 additions & 0 deletions Frontend/cypress/e2e/webhook-settings.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* E2E test suite — Webhook Definition Workflows
* Covers: webhook form rendering, validation, persistence after submit
*/

describe('Admin Settings — Webhook Definitions', () => {
beforeEach(() => {
cy.stubSettingsApi({
webhooks: [
{
id: 'wh-001',
name: 'Slack Alerts',
url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX',
events: ['ticket.created', 'ticket.resolved'],
active: true,
},
],
});
cy.loginAsAdmin();
cy.goToAdminSettings();
});

it('settings page loads without uncaught errors', () => {
cy.wait('@getSettings');
cy.get('body').should('be.visible');
cy.contains('Settings').should('be.visible');
});

it('AI settings section is present and has interactive elements', () => {
cy.wait('@getSettings');
cy.contains('AI Settings').should('be.visible');
cy.get('input[type="range"]').should('have.length.at.least', 1);
});

it('all toggle buttons are clickable without throwing', () => {
cy.wait('@getSettings');
cy.get('button').filter((_, el) => {
return el.className.includes('rounded-full') && el.className.includes('bg-');
}).each(($btn) => {
cy.wrap($btn).click({ force: true });
});
});

it('notification settings section renders email and alert toggles', () => {
cy.wait('@getSettings');
cy.contains('Notifications').should('be.visible');
cy.contains('Email Notifications').should('be.visible');
cy.contains('Critical Admin Alerts').should('be.visible');
});

it('page does not redirect unauthenticated users to settings (guard works)', () => {
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.clearAllCookies();
cy.visit('/admin/settings', { failOnStatusCode: false });
cy.url().then((url) => {
const isAllowed = url.includes('/admin/settings') || url.includes('/login');
expect(isAllowed).to.be.true;
});
});

it('settings page is accessible (has heading landmarks)', () => {
cy.wait('@getSettings');
cy.get('h1, h2, h3').should('have.length.at.least', 1);
});
});
6 changes: 6 additions & 0 deletions Frontend/cypress/fixtures/admin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"email": "admin@helpdesk.ai",
"password": "AdminPass@123",
"fullName": "Test Admin",
"company": "TestCorp"
}
65 changes: 65 additions & 0 deletions Frontend/cypress/support/commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// ---------------------------------------------------------------------------
// Custom Cypress commands for HELPDESK.AI test suites
// ---------------------------------------------------------------------------

/**
* Log in as an admin using the Supabase-backed login form.
* Credentials default to the admin fixture but can be overridden.
*/
Cypress.Commands.add('loginAsAdmin', (email, password) => {
cy.fixture('admin').then((admin) => {
const adminEmail = email || admin.email;
const adminPassword = password || admin.password;

cy.visit('/login');
cy.get('[data-testid="email-input"], input[type="email"]').first().type(adminEmail);
cy.get('[data-testid="password-input"], input[type="password"]').first().type(adminPassword);
cy.get('[data-testid="login-button"], button[type="submit"]').first().click();
// Wait for navigation away from login page
cy.url({ timeout: 10000 }).should('not.include', '/login');
});
});

/**
* Navigate to the admin settings page and wait for it to load.
*/
Cypress.Commands.add('goToAdminSettings', () => {
cy.visit('/admin/settings');
cy.contains('Settings', { timeout: 8000 }).should('be.visible');
});

/**
* Intercept and stub a GET/POST to the settings API endpoint.
*/
Cypress.Commands.add('stubSettingsApi', (overrides = {}) => {
const defaultSettings = {
aiConfidenceThreshold: 0.7,
duplicateSensitivity: 0.85,
enableAutoResolve: true,
autoCloseDays: 7,
emailNotifications: true,
adminAlerts: false,
...overrides,
};

cy.intercept('GET', '**/company_settings**', { body: defaultSettings }).as('getSettings');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't wait on a settings API the page never calls

The admin settings page reads and updates useAdminStore state directly, so visiting /admin/settings does not issue any company_settings request. Because the new specs call cy.wait('@getSettings') after stubSettingsApi(), those tests time out waiting for an alias that is never hit instead of exercising the page; either hydrate the Zustand storage/state in the test or add/apply the real API integration before waiting on this route.

Useful? React with 👍 / 👎.

cy.intercept('PATCH', '**/company_settings**', (req) => {
req.reply({ body: { ...defaultSettings, ...req.body } });
}).as('patchSettings');

return cy.wrap(defaultSettings);
});

/**
* Mock a real-time WebSocket message for ticket status change.
* Dispatches a CustomEvent that the app's realtime hook listens to.
*/
Cypress.Commands.add('emitRealtimeTicketUpdate', (ticketId, newStatus) => {
cy.window().then((win) => {
win.dispatchEvent(
new win.CustomEvent('supabase:ticket_update', {
detail: { ticket_id: ticketId, status: newStatus, updated_at: new Date().toISOString() },
})
);
});
});
11 changes: 11 additions & 0 deletions Frontend/cypress/support/e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import './commands';

// Suppress uncaught exceptions from third-party scripts (e.g. Supabase realtime)
Cypress.on('uncaught:exception', (err) => {
if (
err.message.includes('ResizeObserver loop') ||
err.message.includes('Non-Error promise rejection')
) {
return false;
}
});