Add user profile panel with self-service password change and dark theme UserMenu
This commit is contained in:
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Property-Based Test: Profile panel displays all required fields
|
||||
*
|
||||
* Feature: user-profile, Property 1: Profile panel displays all required fields
|
||||
* **Validates: Requirements 1.2**
|
||||
*
|
||||
* For any valid profile object with arbitrary username, email, group, created_at,
|
||||
* and last_login values, rendering UserProfilePanel displays all five values
|
||||
* in the output.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import UserProfilePanel from '../components/UserProfilePanel';
|
||||
|
||||
// Replicate the component's formatting logic so we know what to expect in the DOM
|
||||
function formatGroupName(group) {
|
||||
if (!group) return '';
|
||||
return group.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'Unknown';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}) + ' at ' + date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ISO date strings from integer timestamps to avoid invalid Date issues
|
||||
const MIN_TS = new Date('2020-01-01T00:00:00Z').getTime();
|
||||
const MAX_TS = new Date('2030-12-31T23:59:59Z').getTime();
|
||||
const isoDateArbitrary = fc
|
||||
.integer({ min: MIN_TS, max: MAX_TS })
|
||||
.map(ts => new Date(ts).toISOString());
|
||||
|
||||
// Arbitrary that generates valid profile objects.
|
||||
// Use minLength >= 3 for username to avoid single-character strings that
|
||||
// match substrings in other UI text (e.g., "d" appearing in "Password").
|
||||
// Use a custom email generator with a longer local part for the same reason.
|
||||
const profileArbitrary = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
username: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,19}$/),
|
||||
email: fc.tuple(
|
||||
fc.stringMatching(/^[a-z]{4,10}$/),
|
||||
fc.stringMatching(/^[a-z]{3,8}$/),
|
||||
fc.constantFrom('com', 'org', 'net', 'io')
|
||||
).map(([local, domain, tld]) => `${local}@${domain}.${tld}`),
|
||||
group: fc.constantFrom('Admin', 'Standard_User', 'Leadership', 'Read_Only'),
|
||||
created_at: isoDateArbitrary,
|
||||
last_login: isoDateArbitrary,
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: find all fieldValue spans in the rendered component.
|
||||
* The component renders each profile field in a fieldRow div containing
|
||||
* a fieldLabel span and a fieldValue span. We query by the known label
|
||||
* text to locate the corresponding value span.
|
||||
*/
|
||||
function getFieldValueByLabel(container, labelText) {
|
||||
// Each field row has structure:
|
||||
// <div style={fieldRow}>
|
||||
// <svg ... />
|
||||
// <div style={fieldContent}>
|
||||
// <span style={fieldLabel}>LABEL</span>
|
||||
// <span style={fieldValue}>VALUE</span>
|
||||
// </div>
|
||||
// </div>
|
||||
const labels = container.querySelectorAll('span');
|
||||
for (const label of labels) {
|
||||
if (label.textContent.trim().toLowerCase() === labelText.toLowerCase()) {
|
||||
// The value span is the next sibling of the label span
|
||||
const valueSibling = label.nextElementSibling;
|
||||
if (valueSibling) {
|
||||
return valueSibling.textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Property 1: Profile panel displays all required fields for any valid profile', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(profileArbitrary, async (profile) => {
|
||||
// Mock fetch to return the generated profile
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ...profile }),
|
||||
});
|
||||
|
||||
const onClose = jest.fn();
|
||||
const { container, unmount } = render(
|
||||
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||
);
|
||||
|
||||
try {
|
||||
// Wait for the profile data to be fetched and rendered.
|
||||
// Check for the username label to confirm the profile section loaded.
|
||||
await waitFor(() => {
|
||||
expect(getFieldValueByLabel(container, 'Username')).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Assert all five field values appear in their respective field rows
|
||||
|
||||
// 1. Username — rendered directly in the Username field value span
|
||||
const usernameValue = getFieldValueByLabel(container, 'Username');
|
||||
expect(usernameValue).toBe(profile.username);
|
||||
|
||||
// 2. Email — rendered directly in the Email field value span
|
||||
const emailValue = getFieldValueByLabel(container, 'Email');
|
||||
expect(emailValue).toBe(profile.email);
|
||||
|
||||
// 3. Group — rendered through formatGroupName in the Group field value span
|
||||
const groupValue = getFieldValueByLabel(container, 'Group');
|
||||
const expectedGroup = formatGroupName(profile.group);
|
||||
expect(groupValue).toContain(expectedGroup);
|
||||
|
||||
// 4. Created at — rendered through formatDate in the Account Created field value span
|
||||
const createdAtValue = getFieldValueByLabel(container, 'Account Created');
|
||||
const expectedCreatedAt = formatDate(profile.created_at);
|
||||
expect(createdAtValue).toBe(expectedCreatedAt);
|
||||
|
||||
// 5. Last login — rendered through formatDate in the Last Login field value span
|
||||
const lastLoginValue = getFieldValueByLabel(container, 'Last Login');
|
||||
const expectedLastLogin = formatDate(profile.last_login);
|
||||
expect(lastLoginValue).toBe(expectedLastLogin);
|
||||
} finally {
|
||||
// Clean up to avoid leaking state between iterations
|
||||
unmount();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 120000);
|
||||
Reference in New Issue
Block a user