costco-grocery-list/backend/public/api-test.html

1037 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Test Suite - Grocery List</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.config {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.config-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.config-row label {
min-width: 100px;
font-weight: 500;
}
.config-row input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
button:hover {
background: #0052a3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.test-category {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.test-category h2 {
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.test-case {
padding: 15px;
margin-bottom: 10px;
border-radius: 6px;
border-left: 4px solid #ddd;
background: #f9f9f9;
}
.test-case.running {
border-left-color: #ffa500;
background: #fff8e6;
}
.test-case.pass {
border-left-color: #28a745;
background: #e8f5e9;
}
.test-case.fail {
border-left-color: #dc3545;
background: #ffebee;
}
.test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
}
.test-header:hover {
background: rgba(0, 0, 0, 0.02);
margin: -5px;
padding: 5px;
border-radius: 4px;
}
.toggle-icon {
font-size: 12px;
margin-right: 8px;
transition: transform 0.2s;
display: inline-block;
}
.toggle-icon.expanded {
transform: rotate(90deg);
}
.test-content {
display: none;
}
.test-content.expanded {
display: block;
}
.test-name {
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
}
.test-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.test-status.pending {
background: #e0e0e0;
color: #666;
}
.test-status.running {
background: #ffa500;
color: white;
}
.test-status.pass {
background: #28a745;
color: white;
}
.test-status.fail {
background: #dc3545;
color: white;
}
.test-details {
font-size: 13px;
color: #666;
margin-bottom: 5px;
}
.test-result {
font-size: 13px;
margin-top: 8px;
padding: 10px;
background: white;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
.expected-section {
margin-top: 8px;
padding: 8px;
background: #f0f7ff;
border-left: 3px solid #2196f3;
border-radius: 4px;
font-size: 12px;
}
.expected-label {
font-weight: bold;
color: #1976d2;
margin-bottom: 4px;
}
.field-check {
margin: 2px 0;
padding-left: 16px;
}
.field-check.pass {
color: #2e7d32;
}
.field-check.fail {
color: #c62828;
}
.test-error {
font-size: 13px;
margin-top: 8px;
padding: 10px;
background: #fff5f5;
border: 1px solid #ffcdd2;
border-radius: 4px;
color: #c62828;
font-family: monospace;
white-space: pre-wrap;
}
.response-status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
margin-right: 8px;
}
.status-2xx {
background: #c8e6c9;
color: #2e7d32;
}
.status-3xx {
background: #fff9c4;
color: #f57f17;
}
.status-4xx {
background: #ffccbc;
color: #d84315;
}
.status-5xx {
background: #ffcdd2;
color: #c62828;
}
.summary {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
gap: 20px;
}
.summary-item {
flex: 1;
text-align: center;
padding: 15px;
border-radius: 6px;
}
.summary-item.total {
background: #e3f2fd;
}
.summary-item.pass {
background: #e8f5e9;
}
.summary-item.fail {
background: #ffebee;
}
.summary-value {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
}
.summary-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
}
.actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 API Test Suite</h1>
<p style="color: #666; margin-bottom: 20px;">Multi-Household Grocery List API Testing</p>
<div class="config">
<h3 style="margin-bottom: 15px;">Configuration</h3>
<div class="config-row">
<label>API URL:</label>
<input type="text" id="apiUrl" value="http://localhost:5000" />
</div>
<div class="config-row">
<label>Username:</label>
<input type="text" id="username" value="admin" />
</div>
<div class="config-row">
<label>Password:</label>
<input type="password" id="password" value="admin123" />
</div>
</div>
<div class="actions">
<button onclick="runAllTests(event)">▶ Run All Tests</button>
<button onclick="clearResults()">🗑 Clear Results</button>
<button onclick="expandAllTests()">📂 Expand All</button>
<button onclick="collapseAllTests()">📁 Collapse All</button>
</div>
<div class="summary" id="summary" style="display: none;">
<div class="summary-item total">
<div class="summary-value" id="totalTests">0</div>
<div class="summary-label">Total Tests</div>
</div>
<div class="summary-item pass">
<div class="summary-value" id="passedTests">0</div>
<div class="summary-label">Passed</div>
</div>
<div class="summary-item fail">
<div class="summary-value" id="failedTests">0</div>
<div class="summary-label">Failed</div>
</div>
</div>
<div id="testResults"></div>
</div>
<script>
let authToken = null;
let householdId = null;
let storeId = null;
let testUserId = null;
let createdHouseholdId = null;
let secondHouseholdId = null;
let inviteCode = null;
const tests = [
{
category: "Authentication",
tests: [
{
name: "Login with valid credentials",
method: "POST",
endpoint: "/auth/login",
auth: false,
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
expect: (res) => res.token && res.role,
expectedFields: ['token', 'username', 'role'],
onSuccess: (res) => { authToken = res.token; }
},
{
name: "Login with invalid credentials",
method: "POST",
endpoint: "/auth/login",
auth: false,
body: { username: "wronguser", password: "wrongpass" },
expectFail: true,
expect: (res, status) => status === 401,
expectedFields: ['message']
},
{
name: "Access protected route without token",
method: "GET",
endpoint: "/households",
auth: false,
expectFail: true,
expect: (res, status) => status === 401
}
]
},
{
category: "Households",
tests: [
{
name: "Get user's households",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res),
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
},
{
name: "Create new household",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Test Household ${Date.now()}` },
expect: (res) => res.household && res.household.invite_code,
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
},
{
name: "Get household details",
method: "GET",
endpoint: () => `/households/${householdId}`,
auth: true,
skip: () => !householdId,
expect: (res) => res.id === householdId,
expectedFields: ['id', 'name', 'invite_code', 'created_at']
},
{
name: "Update household name",
method: "PATCH",
endpoint: () => `/households/${householdId}`,
auth: true,
skip: () => !householdId,
body: { name: `Updated Household ${Date.now()}` },
expect: (res) => res.household,
expectedFields: ['message', 'household', 'household.id', 'household.name']
},
{
name: "Refresh invite code",
method: "POST",
endpoint: () => `/households/${householdId}/invite/refresh`,
auth: true,
skip: () => !householdId,
expect: (res) => res.household && res.household.invite_code,
expectedFields: ['message', 'household', 'household.invite_code']
},
{
name: "Join household with invalid code",
method: "POST",
endpoint: "/households/join/INVALID123",
auth: true,
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Create household with empty name (validation)",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: "" },
expectFail: true,
expect: (res, status) => status === 400,
expectedFields: ['error']
}
]
},
{
category: "Members",
tests: [
{
name: "Get household members",
method: "GET",
endpoint: () => `/households/${householdId}/members`,
auth: true,
skip: () => !householdId,
expect: (res) => Array.isArray(res) && res.length > 0,
onSuccess: (res) => { testUserId = res[0].user_id; }
},
{
name: "Update member role (non-admin attempting)",
method: "PATCH",
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
auth: true,
skip: () => !householdId || !testUserId,
body: { role: "user" },
expectFail: true,
expect: (res, status) => status === 400 || status === 403
}
]
},
{
category: "Stores",
tests: [
{
name: "Get all stores catalog",
method: "GET",
endpoint: "/stores",
auth: true,
expect: (res) => Array.isArray(res),
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
},
{
name: "Get household stores",
method: "GET",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId,
expect: (res) => Array.isArray(res)
},
{
name: "Add store to household",
method: "POST",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId || !storeId,
body: () => ({ storeId: storeId, isDefault: true }),
expect: (res) => res.store,
expectedFields: ['message', 'store', 'store.id', 'store.name']
},
{
name: "Set default store",
method: "PATCH",
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => res.message
},
{
name: "Add invalid store to household",
method: "POST",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId,
body: { storeId: 99999 },
expectFail: true,
expect: (res, status) => status === 404
}
]
},
{
category: "Advanced Household Tests",
tests: [
{
name: "Create household for complex workflows",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Workflow Test ${Date.now()}` },
expect: (res) => res.household && res.household.id,
onSuccess: (res) => {
createdHouseholdId = res.household.id;
inviteCode = res.household.invite_code;
}
},
{
name: "Verify invite code format (7 chars)",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
},
{
name: "Get household with no stores added yet",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length === 0
},
{
name: "Update household with very long name (validation)",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
body: { name: "A".repeat(101) },
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Refresh invite code changes value",
method: "POST",
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
auth: true,
skip: () => !createdHouseholdId || !inviteCode,
expect: (res) => res.household && res.household.invite_code !== inviteCode,
onSuccess: (res) => { inviteCode = res.household.invite_code; }
},
{
name: "Join same household twice (idempotent)",
method: "POST",
endpoint: () => `/households/join/${inviteCode}`,
auth: true,
skip: () => !inviteCode,
expect: (res, status) => status === 200 && res.message.includes("already a member")
},
{
name: "Get non-existent household",
method: "GET",
endpoint: "/households/99999",
auth: true,
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Update non-existent household",
method: "PATCH",
endpoint: "/households/99999",
auth: true,
body: { name: "Test" },
expectFail: true,
expect: (res, status) => status === 403 || status === 404
}
]
},
{
category: "Member Management Edge Cases",
tests: [
{
name: "Get members for created household",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}/members`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
},
{
name: "Update own role (should fail)",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
auth: true,
skip: () => !createdHouseholdId || !testUserId,
body: { role: "user" },
expectFail: true,
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
},
{
name: "Update role with invalid value",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
auth: true,
skip: () => !createdHouseholdId,
body: { role: "superadmin" },
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Remove non-existent member",
method: "DELETE",
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 500
}
]
},
{
category: "Store Management Advanced",
tests: [
{
name: "Add multiple stores to household",
method: "POST",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
body: () => ({ storeId: storeId, isDefault: false }),
expect: (res) => res.store
},
{
name: "Add same store twice (duplicate check)",
method: "POST",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
body: () => ({ storeId: storeId, isDefault: false }),
expectFail: true,
expect: (res, status) => status === 400 || status === 409 || status === 500
},
{
name: "Set default store for household",
method: "PATCH",
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => res.message
},
{
name: "Verify default store is first in list",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
},
{
name: "Set non-existent store as default",
method: "PATCH",
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 500
},
{
name: "Remove store from household",
method: "DELETE",
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => res.message
},
{
name: "Verify store removed from household",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length === 0
}
]
},
{
category: "Data Integrity & Cleanup",
tests: [
{
name: "Create second household for testing",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Second Test ${Date.now()}` },
expect: (res) => res.household && res.household.id,
onSuccess: (res) => { secondHouseholdId = res.household.id; }
},
{
name: "Verify user belongs to multiple households",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res) && res.length >= 3
},
{
name: "Delete created test household",
method: "DELETE",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => res.message
},
{
name: "Verify deleted household is gone",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 403
},
{
name: "Delete second test household",
method: "DELETE",
endpoint: () => `/households/${secondHouseholdId}`,
auth: true,
skip: () => !secondHouseholdId,
expect: (res) => res.message
},
{
name: "Verify households list updated",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res)
}
]
}
];
async function makeRequest(test) {
const apiUrl = document.getElementById('apiUrl').value;
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
const url = `${apiUrl}${endpoint}`;
const options = {
method: test.method,
headers: {
'Content-Type': 'application/json',
}
};
if (test.auth && authToken) {
options.headers['Authorization'] = `Bearer ${authToken}`;
}
if (test.body) {
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
}
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
return { data, status: response.status };
}
async function runTest(categoryIdx, testIdx) {
const test = tests[categoryIdx].tests[testIdx];
const testId = `test-${categoryIdx}-${testIdx}`;
const testEl = document.getElementById(testId);
const contentEl = document.getElementById(`${testId}-content`);
const toggleEl = document.getElementById(`${testId}-toggle`);
const resultEl = testEl.querySelector('.test-result');
// Auto-expand when running
contentEl.classList.add('expanded');
toggleEl.classList.add('expanded');
resultEl.style.display = 'block';
resultEl.className = 'test-result';
resultEl.innerHTML = '⚠️ Prerequisites not met';
return 'skip';
}
testEl.className = 'test-case running';
testEl.querySelector('.test-status').textContent = 'RUNNING';
testEl.querySelector('.test-status').className = 'test-status running';
resultEl.style.display = 'none';
try {
const { data, status } = await makeRequest(test);
const expectFail = test.expectFail || false;
const passed = test.expect(data, status);
const success = expectFail ? !passed || status >= 400 : passed;
testEl.className = success ? 'test-case pass' : 'test-case fail';
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
// Determine status code class
let statusClass = 'status-5xx';
if (status >= 200 && status < 300) statusClass = 'status-2xx';
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
resultEl.style.display = 'block';
resultEl.className = 'test-result';
// Check expected fields if defined
let expectedFieldsHTML = '';
if (test.expectedFields) {
const fieldChecks = test.expectedFields.map(field => {
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
const icon = exists ? '✓' : '✗';
const className = exists ? 'pass' : 'fail';
return `<div class="field-check ${className}">${icon} ${field}</div>`;
}).join('');
expectedFieldsHTML = `
<div class="expected-section">
<div class="expected-label">Expected Fields:</div>
${fieldChecks}
</div>
`;
}
resultEl.innerHTML = `
<div style="margin-bottom: 8px;">
<span class="response-status ${statusClass}">HTTP ${status}</span>
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
</div>
${expectedFieldsHTML}
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
<div>${JSON.stringify(data, null, 2)}</div>
`;
if (success && test.onSuccess) {
test.onSuccess(data);
}
return success ? 'pass' : 'fail';
} catch (error) {
testEl.className = 'test-case fail';
testEl.querySelector('.test-status').textContent = 'ERROR';
testEl.querySelector('.test-status').className = 'test-status fail';
resultEl.style.display = 'block';
resultEl.className = 'test-error';
resultEl.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
<div>${error.message}</div>
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
`;
return 'fail';
}
}
async function runAllTests(event) {
authToken = null;
householdId = null;
storeId = null;
testUserId = null;
createdHouseholdId = null;
secondHouseholdId = null;
inviteCode = null;
const button = event.target;
button.disabled = true;
button.textContent = '⏳ Running Tests...';
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
for (let i = 0; i < tests.length; i++) {
for (let j = 0; j < tests[i].tests.length; j++) {
const result = await runTest(i, j);
if (result !== 'skip') {
totalTests++;
if (result === 'pass') passedTests++;
if (result === 'fail') failedTests++;
}
}
}
document.getElementById('summary').style.display = 'flex';
document.getElementById('totalTests').textContent = totalTests;
document.getElementById('passedTests').textContent = passedTests;
document.getElementById('failedTests').textContent = failedTests;
button.disabled = false;
button.textContent = '▶ Run All Tests';
}
function toggleTest(testId) {
const content = document.getElementById(`${testId}-content`);
const toggle = document.getElementById(`${testId}-toggle`);
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
toggle.classList.remove('expanded');
} else {
content.classList.add('expanded');
toggle.classList.add('expanded');
}
}
function expandAllTests() {
document.querySelectorAll('.test-content').forEach(content => {
content.classList.add('expanded');
});
document.querySelectorAll('.toggle-icon').forEach(icon => {
icon.classList.add('expanded');
});
}
function collapseAllTests() {
document.querySelectorAll('.test-content').forEach(content => {
content.classList.remove('expanded');
});
document.querySelectorAll('.toggle-icon').forEach(icon => {
icon.classList.remove('expanded');
});
}
function clearResults() {
renderTests();
document.getElementById('summary').style.display = 'none';
authToken = null;
householdId = null;
storeId = null;
testUserId = null;
createdHouseholdId = null;
secondHouseholdId = null;
inviteCode = null;
}
function renderTests() {
const container = document.getElementById('testResults');
container.innerHTML = '';
tests.forEach((category, catIdx) => {
const categoryDiv = document.createElement('div');
categoryDiv.className = 'test-category';
const categoryHeader = document.createElement('h2');
categoryHeader.textContent = category.category;
categoryDiv.appendChild(categoryHeader);
category.tests.forEach((test, testIdx) => {
const testDiv = document.createElement('div');
testDiv.className = 'test-case';
testDiv.id = `test-${catIdx}-${testIdx}`;
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
testDiv.innerHTML = `
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
<div class="test-name">
<span class="toggle-icon" id="${testDiv.id}-toggle">▶</span>
${test.name}
</div>
<div class="test-status pending">PENDING</div>
</div>
<div class="test-content" id="${testDiv.id}-content">
<div class="test-details">
<strong>${test.method}</strong> ${endpoint}
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
</div>
<div class="test-result" style="display: none;"></div>
</div>
`;
categoryDiv.appendChild(testDiv);
});
container.appendChild(categoryDiv);
});
}
// Initialize
renderTests();
</script>
</body>
</html>