phase2 - get backend api modified for new implmentations and create api test
This commit is contained in:
parent
ccf0c39294
commit
4d5d2f0f6d
@ -1,10 +1,14 @@
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const path = require("path");
|
||||
const User = require("./models/user.model");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use('/test', express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
|
||||
console.log("Allowed Origins:", allowedOrigins);
|
||||
app.use(
|
||||
@ -14,9 +18,10 @@ app.use(
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
console.error(`🚫 CORS blocked origin: ${origin}`);
|
||||
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
||||
},
|
||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
})
|
||||
);
|
||||
|
||||
@ -43,4 +48,10 @@ app.use("/users", usersRoutes);
|
||||
const configRoutes = require("./routes/config.routes");
|
||||
app.use("/config", configRoutes);
|
||||
|
||||
const householdsRoutes = require("./routes/households.routes");
|
||||
app.use("/households", householdsRoutes);
|
||||
|
||||
const storesRoutes = require("./routes/stores.routes");
|
||||
app.use("/stores", storesRoutes);
|
||||
|
||||
module.exports = app;
|
||||
211
backend/controllers/households.controller.js
Normal file
211
backend/controllers/households.controller.js
Normal file
@ -0,0 +1,211 @@
|
||||
const householdModel = require("../models/household.model");
|
||||
|
||||
// Get all households user belongs to
|
||||
exports.getUserHouseholds = async (req, res) => {
|
||||
try {
|
||||
const households = await householdModel.getUserHouseholds(req.user.id);
|
||||
res.json(households);
|
||||
} catch (error) {
|
||||
console.error("Get user households error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch households" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get household details
|
||||
exports.getHousehold = async (req, res) => {
|
||||
try {
|
||||
const household = await householdModel.getHouseholdById(
|
||||
req.params.householdId,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
if (!household) {
|
||||
return res.status(404).json({ error: "Household not found" });
|
||||
}
|
||||
|
||||
res.json(household);
|
||||
} catch (error) {
|
||||
console.error("Get household error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Create new household
|
||||
exports.createHousehold = async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: "Household name is required" });
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
return res.status(400).json({ error: "Household name must be 100 characters or less" });
|
||||
}
|
||||
|
||||
const household = await householdModel.createHousehold(
|
||||
name.trim(),
|
||||
req.user.id
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Household created successfully",
|
||||
household
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Create household error:", error);
|
||||
res.status(500).json({ error: "Failed to create household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update household
|
||||
exports.updateHousehold = async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: "Household name is required" });
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
return res.status(400).json({ error: "Household name must be 100 characters or less" });
|
||||
}
|
||||
|
||||
const household = await householdModel.updateHousehold(
|
||||
req.params.householdId,
|
||||
{ name: name.trim() }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Household updated successfully",
|
||||
household
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Update household error:", error);
|
||||
res.status(500).json({ error: "Failed to update household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete household
|
||||
exports.deleteHousehold = async (req, res) => {
|
||||
try {
|
||||
await householdModel.deleteHousehold(req.params.householdId);
|
||||
res.json({ message: "Household deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Delete household error:", error);
|
||||
res.status(500).json({ error: "Failed to delete household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh invite code
|
||||
exports.refreshInviteCode = async (req, res) => {
|
||||
try {
|
||||
const household = await householdModel.refreshInviteCode(req.params.householdId);
|
||||
res.json({
|
||||
message: "Invite code refreshed successfully",
|
||||
household
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Refresh invite code error:", error);
|
||||
res.status(500).json({ error: "Failed to refresh invite code" });
|
||||
}
|
||||
};
|
||||
|
||||
// Join household via invite code
|
||||
exports.joinHousehold = async (req, res) => {
|
||||
try {
|
||||
const { inviteCode } = req.params;
|
||||
|
||||
if (!inviteCode) {
|
||||
return res.status(400).json({ error: "Invite code is required" });
|
||||
}
|
||||
|
||||
const result = await householdModel.joinHousehold(
|
||||
inviteCode.toUpperCase(),
|
||||
req.user.id
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: "Invalid or expired invite code" });
|
||||
}
|
||||
|
||||
if (result.alreadyMember) {
|
||||
return res.status(200).json({
|
||||
message: "You are already a member of this household",
|
||||
household: { id: result.id, name: result.name }
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
message: `Successfully joined ${result.name}`,
|
||||
household: { id: result.id, name: result.name }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Join household error:", error);
|
||||
res.status(500).json({ error: "Failed to join household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get household members
|
||||
exports.getMembers = async (req, res) => {
|
||||
try {
|
||||
const members = await householdModel.getHouseholdMembers(req.params.householdId);
|
||||
res.json(members);
|
||||
} catch (error) {
|
||||
console.error("Get members error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch members" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update member role
|
||||
exports.updateMemberRole = async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { role } = req.body;
|
||||
|
||||
if (!role || !['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: "Invalid role. Must be 'admin' or 'user'" });
|
||||
}
|
||||
|
||||
// Can't change own role
|
||||
if (parseInt(userId) === req.user.id) {
|
||||
return res.status(400).json({ error: "Cannot change your own role" });
|
||||
}
|
||||
|
||||
const updated = await householdModel.updateMemberRole(
|
||||
req.params.householdId,
|
||||
userId,
|
||||
role
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Member role updated successfully",
|
||||
member: updated
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Update member role error:", error);
|
||||
res.status(500).json({ error: "Failed to update member role" });
|
||||
}
|
||||
};
|
||||
|
||||
// Remove member
|
||||
exports.removeMember = async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const targetUserId = parseInt(userId);
|
||||
|
||||
// Allow users to remove themselves, or admins to remove others
|
||||
if (targetUserId !== req.user.id && req.household.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
error: "Only admins can remove other members"
|
||||
});
|
||||
}
|
||||
|
||||
await householdModel.removeMember(req.params.householdId, userId);
|
||||
|
||||
res.json({ message: "Member removed successfully" });
|
||||
} catch (error) {
|
||||
console.error("Remove member error:", error);
|
||||
res.status(500).json({ error: "Failed to remove member" });
|
||||
}
|
||||
};
|
||||
146
backend/controllers/stores.controller.js
Normal file
146
backend/controllers/stores.controller.js
Normal file
@ -0,0 +1,146 @@
|
||||
const storeModel = require("../models/store.model");
|
||||
|
||||
// Get all available stores
|
||||
exports.getAllStores = async (req, res) => {
|
||||
try {
|
||||
const stores = await storeModel.getAllStores();
|
||||
res.json(stores);
|
||||
} catch (error) {
|
||||
console.error("Get all stores error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch stores" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get stores for household
|
||||
exports.getHouseholdStores = async (req, res) => {
|
||||
try {
|
||||
const stores = await storeModel.getHouseholdStores(req.params.householdId);
|
||||
res.json(stores);
|
||||
} catch (error) {
|
||||
console.error("Get household stores error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch household stores" });
|
||||
}
|
||||
};
|
||||
|
||||
// Add store to household
|
||||
exports.addStoreToHousehold = async (req, res) => {
|
||||
try {
|
||||
const { storeId, isDefault } = req.body;
|
||||
|
||||
if (!storeId) {
|
||||
return res.status(400).json({ error: "Store ID is required" });
|
||||
}
|
||||
|
||||
// Check if store exists
|
||||
const store = await storeModel.getStoreById(storeId);
|
||||
if (!store) {
|
||||
return res.status(404).json({ error: "Store not found" });
|
||||
}
|
||||
|
||||
await storeModel.addStoreToHousehold(
|
||||
req.params.householdId,
|
||||
storeId,
|
||||
isDefault || false
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Store added to household successfully",
|
||||
store
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Add store to household error:", error);
|
||||
res.status(500).json({ error: "Failed to add store to household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Remove store from household
|
||||
exports.removeStoreFromHousehold = async (req, res) => {
|
||||
try {
|
||||
await storeModel.removeStoreFromHousehold(
|
||||
req.params.householdId,
|
||||
req.params.storeId
|
||||
);
|
||||
|
||||
res.json({ message: "Store removed from household successfully" });
|
||||
} catch (error) {
|
||||
console.error("Remove store from household error:", error);
|
||||
res.status(500).json({ error: "Failed to remove store from household" });
|
||||
}
|
||||
};
|
||||
|
||||
// Set default store
|
||||
exports.setDefaultStore = async (req, res) => {
|
||||
try {
|
||||
await storeModel.setDefaultStore(
|
||||
req.params.householdId,
|
||||
req.params.storeId
|
||||
);
|
||||
|
||||
res.json({ message: "Default store updated successfully" });
|
||||
} catch (error) {
|
||||
console.error("Set default store error:", error);
|
||||
res.status(500).json({ error: "Failed to set default store" });
|
||||
}
|
||||
};
|
||||
|
||||
// Create store (system admin only)
|
||||
exports.createStore = async (req, res) => {
|
||||
try {
|
||||
const { name, default_zones } = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: "Store name is required" });
|
||||
}
|
||||
|
||||
const store = await storeModel.createStore(name.trim(), default_zones || null);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Store created successfully",
|
||||
store
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Create store error:", error);
|
||||
if (error.code === '23505') { // Unique violation
|
||||
return res.status(400).json({ error: "Store with this name already exists" });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to create store" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update store (system admin only)
|
||||
exports.updateStore = async (req, res) => {
|
||||
try {
|
||||
const { name, default_zones } = req.body;
|
||||
|
||||
const store = await storeModel.updateStore(req.params.storeId, {
|
||||
name: name?.trim(),
|
||||
default_zones
|
||||
});
|
||||
|
||||
if (!store) {
|
||||
return res.status(404).json({ error: "Store not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Store updated successfully",
|
||||
store
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Update store error:", error);
|
||||
res.status(500).json({ error: "Failed to update store" });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete store (system admin only)
|
||||
exports.deleteStore = async (req, res) => {
|
||||
try {
|
||||
await storeModel.deleteStore(req.params.storeId);
|
||||
res.json({ message: "Store deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Delete store error:", error);
|
||||
if (error.message.includes('in use')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to delete store" });
|
||||
}
|
||||
};
|
||||
110
backend/middleware/household.js
Normal file
110
backend/middleware/household.js
Normal file
@ -0,0 +1,110 @@
|
||||
const householdModel = require("../models/household.model");
|
||||
|
||||
// Middleware to check if user belongs to household
|
||||
exports.householdAccess = async (req, res, next) => {
|
||||
try {
|
||||
const householdId = parseInt(req.params.householdId || req.params.hId);
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!householdId) {
|
||||
return res.status(400).json({ error: "Household ID required" });
|
||||
}
|
||||
|
||||
// Check if user is member of household
|
||||
const isMember = await householdModel.isHouseholdMember(householdId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({
|
||||
error: "Access denied. You are not a member of this household."
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's role in household
|
||||
const role = await householdModel.getUserRole(householdId, userId);
|
||||
|
||||
// Attach household info to request
|
||||
req.household = {
|
||||
id: householdId,
|
||||
role: role
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("Household access check error:", error);
|
||||
res.status(500).json({ error: "Server error checking household access" });
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to require specific household role(s)
|
||||
exports.requireHouseholdRole = (...allowedRoles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.household) {
|
||||
return res.status(500).json({
|
||||
error: "Household context not set. Use householdAccess middleware first."
|
||||
});
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.household.role)) {
|
||||
return res.status(403).json({
|
||||
error: `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Middleware to require admin role in household
|
||||
exports.requireHouseholdAdmin = exports.requireHouseholdRole('admin');
|
||||
|
||||
// Middleware to check store access (household must have store)
|
||||
exports.storeAccess = async (req, res, next) => {
|
||||
try {
|
||||
const storeId = parseInt(req.params.storeId || req.params.sId);
|
||||
|
||||
if (!storeId) {
|
||||
return res.status(400).json({ error: "Store ID required" });
|
||||
}
|
||||
|
||||
if (!req.household) {
|
||||
return res.status(500).json({
|
||||
error: "Household context not set. Use householdAccess middleware first."
|
||||
});
|
||||
}
|
||||
|
||||
// Check if household has access to this store
|
||||
const storeModel = require("../models/store.model");
|
||||
const hasStore = await storeModel.householdHasStore(req.household.id, storeId);
|
||||
|
||||
if (!hasStore) {
|
||||
return res.status(403).json({
|
||||
error: "This household does not have access to this store."
|
||||
});
|
||||
}
|
||||
|
||||
// Attach store info to request
|
||||
req.store = {
|
||||
id: storeId
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("Store access check error:", error);
|
||||
res.status(500).json({ error: "Server error checking store access" });
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to require system admin role
|
||||
exports.requireSystemAdmin = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (req.user.role !== 'system_admin') {
|
||||
return res.status(403).json({
|
||||
error: "Access denied. System administrator privileges required."
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
195
backend/models/household.model.js
Normal file
195
backend/models/household.model.js
Normal file
@ -0,0 +1,195 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
// Get all households a user belongs to
|
||||
exports.getUserHouseholds = async (userId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
h.id,
|
||||
h.name,
|
||||
h.invite_code,
|
||||
h.created_at,
|
||||
hm.role,
|
||||
hm.joined_at,
|
||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
||||
FROM households h
|
||||
JOIN household_members hm ON h.id = hm.household_id
|
||||
WHERE hm.user_id = $1
|
||||
ORDER BY hm.joined_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Get household by ID (with member check)
|
||||
exports.getHouseholdById = async (householdId, userId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
h.id,
|
||||
h.name,
|
||||
h.invite_code,
|
||||
h.created_at,
|
||||
h.created_by,
|
||||
hm.role as user_role,
|
||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
||||
FROM households h
|
||||
LEFT JOIN household_members hm ON h.id = hm.household_id AND hm.user_id = $2
|
||||
WHERE h.id = $1`,
|
||||
[householdId, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Create new household
|
||||
exports.createHousehold = async (name, createdBy) => {
|
||||
// Generate random 6-digit invite code
|
||||
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO households (name, created_by, invite_code)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, invite_code, created_at`,
|
||||
[name, createdBy, inviteCode]
|
||||
);
|
||||
|
||||
// Add creator as admin
|
||||
await pool.query(
|
||||
`INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES ($1, $2, 'admin')`,
|
||||
[result.rows[0].id, createdBy]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Update household
|
||||
exports.updateHousehold = async (householdId, updates) => {
|
||||
const { name } = updates;
|
||||
const result = await pool.query(
|
||||
`UPDATE households
|
||||
SET name = COALESCE($1, name)
|
||||
WHERE id = $2
|
||||
RETURNING id, name, invite_code, created_at`,
|
||||
[name, householdId]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Delete household
|
||||
exports.deleteHousehold = async (householdId) => {
|
||||
await pool.query('DELETE FROM households WHERE id = $1', [householdId]);
|
||||
};
|
||||
|
||||
// Refresh invite code
|
||||
exports.refreshInviteCode = async (householdId) => {
|
||||
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const result = await pool.query(
|
||||
`UPDATE households
|
||||
SET invite_code = $1, code_expires_at = NULL
|
||||
WHERE id = $2
|
||||
RETURNING id, name, invite_code`,
|
||||
[inviteCode, householdId]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Join household via invite code
|
||||
exports.joinHousehold = async (inviteCode, userId) => {
|
||||
// Find household by invite code
|
||||
const householdResult = await pool.query(
|
||||
`SELECT id, name FROM households
|
||||
WHERE invite_code = $1
|
||||
AND (code_expires_at IS NULL OR code_expires_at > NOW())`,
|
||||
[inviteCode]
|
||||
);
|
||||
|
||||
if (householdResult.rows.length === 0) {
|
||||
return null; // Invalid or expired code
|
||||
}
|
||||
|
||||
const household = householdResult.rows[0];
|
||||
|
||||
// Check if already member
|
||||
const existingMember = await pool.query(
|
||||
`SELECT id FROM household_members
|
||||
WHERE household_id = $1 AND user_id = $2`,
|
||||
[household.id, userId]
|
||||
);
|
||||
|
||||
if (existingMember.rows.length > 0) {
|
||||
return { ...household, alreadyMember: true };
|
||||
}
|
||||
|
||||
// Add as user role
|
||||
await pool.query(
|
||||
`INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES ($1, $2, 'user')`,
|
||||
[household.id, userId]
|
||||
);
|
||||
|
||||
return { ...household, alreadyMember: false };
|
||||
};
|
||||
|
||||
// Get household members
|
||||
exports.getHouseholdMembers = async (householdId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.name,
|
||||
u.display_name,
|
||||
hm.role,
|
||||
hm.joined_at
|
||||
FROM household_members hm
|
||||
JOIN users u ON hm.user_id = u.id
|
||||
WHERE hm.household_id = $1
|
||||
ORDER BY
|
||||
CASE hm.role
|
||||
WHEN 'admin' THEN 1
|
||||
WHEN 'user' THEN 2
|
||||
END,
|
||||
hm.joined_at ASC`,
|
||||
[householdId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Update member role
|
||||
exports.updateMemberRole = async (householdId, userId, newRole) => {
|
||||
const result = await pool.query(
|
||||
`UPDATE household_members
|
||||
SET role = $1
|
||||
WHERE household_id = $2 AND user_id = $3
|
||||
RETURNING user_id, role`,
|
||||
[newRole, householdId, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Remove member from household
|
||||
exports.removeMember = async (householdId, userId) => {
|
||||
await pool.query(
|
||||
`DELETE FROM household_members
|
||||
WHERE household_id = $1 AND user_id = $2`,
|
||||
[householdId, userId]
|
||||
);
|
||||
};
|
||||
|
||||
// Get user's role in household
|
||||
exports.getUserRole = async (householdId, userId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT role FROM household_members
|
||||
WHERE household_id = $1 AND user_id = $2`,
|
||||
[householdId, userId]
|
||||
);
|
||||
return result.rows[0]?.role || null;
|
||||
};
|
||||
|
||||
// Check if user is household member
|
||||
exports.isHouseholdMember = async (householdId, userId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT 1 FROM household_members
|
||||
WHERE household_id = $1 AND user_id = $2`,
|
||||
[householdId, userId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
};
|
||||
403
backend/models/list.model.v2.js
Normal file
403
backend/models/list.model.v2.js
Normal file
@ -0,0 +1,403 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
/**
|
||||
* Get list items for a specific household and store
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {boolean} includeHistory - Include purchase history
|
||||
* @returns {Promise<Array>} List of items
|
||||
*/
|
||||
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.item_image, 'base64') as item_image,
|
||||
hl.image_mime_type,
|
||||
${includeHistory ? `
|
||||
(
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT DISTINCT hlh.added_by
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.list_id = hl.id
|
||||
ORDER BY hlh.added_by
|
||||
) hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
) as added_by_users,
|
||||
` : 'NULL as added_by_users,'}
|
||||
hl.modified_on as last_added_on,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
FROM household_lists hl
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hl.household_id = hic.household_id
|
||||
AND hl.item_id = hic.item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.bought = FALSE
|
||||
ORDER BY hl.id ASC`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific item from household list by name
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {string} itemName - Item name to search for
|
||||
* @returns {Promise<Object|null>} Item or null
|
||||
*/
|
||||
exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
// First check if item exists in master catalog
|
||||
const itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[itemName]
|
||||
);
|
||||
|
||||
if (itemResult.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemId = itemResult.rows[0].id;
|
||||
|
||||
// Check if item exists in household list
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.item_image, 'base64') as item_image,
|
||||
hl.image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT DISTINCT hlh.added_by
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.list_id = hl.id
|
||||
ORDER BY hlh.added_by
|
||||
) hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
) as added_by_users,
|
||||
hl.modified_on as last_added_on,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
FROM household_lists hl
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hl.household_id = hic.household_id
|
||||
AND hl.item_id = hic.item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update an item in household list
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {string} itemName - Item name
|
||||
* @param {number} quantity - Quantity
|
||||
* @param {number} userId - User adding the item
|
||||
* @param {Buffer|null} imageBuffer - Image buffer
|
||||
* @param {string|null} mimeType - MIME type
|
||||
* @returns {Promise<number>} List item ID
|
||||
*/
|
||||
exports.addOrUpdateItem = async (
|
||||
householdId,
|
||||
storeId,
|
||||
itemName,
|
||||
quantity,
|
||||
userId,
|
||||
imageBuffer = null,
|
||||
mimeType = null
|
||||
) => {
|
||||
const lowerItemName = itemName.toLowerCase();
|
||||
|
||||
// First, ensure item exists in master catalog
|
||||
let itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[lowerItemName]
|
||||
);
|
||||
|
||||
let itemId;
|
||||
if (itemResult.rowCount === 0) {
|
||||
// Create new item in master catalog
|
||||
const insertItem = await pool.query(
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||
[lowerItemName]
|
||||
);
|
||||
itemId = insertItem.rows[0].id;
|
||||
} else {
|
||||
itemId = itemResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Check if item exists in household list
|
||||
const listResult = await pool.query(
|
||||
`SELECT id, bought FROM household_lists
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
|
||||
if (listResult.rowCount > 0) {
|
||||
// Update existing list item
|
||||
const listId = listResult.rows[0].id;
|
||||
if (imageBuffer && mimeType) {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
item_image = $2,
|
||||
image_mime_type = $3,
|
||||
modified_on = NOW()
|
||||
WHERE id = $4`,
|
||||
[quantity, imageBuffer, mimeType, listId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
modified_on = NOW()
|
||||
WHERE id = $2`,
|
||||
[quantity, listId]
|
||||
);
|
||||
}
|
||||
return listId;
|
||||
} else {
|
||||
// Insert new list item
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO household_lists
|
||||
(household_id, store_id, item_id, quantity, item_image, image_mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||
);
|
||||
return insert.rows[0].id;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark item as bought (full or partial)
|
||||
* @param {number} listId - List item ID
|
||||
* @param {number} quantityBought - Quantity bought
|
||||
*/
|
||||
exports.setBought = async (listId, quantityBought) => {
|
||||
// Get current item
|
||||
const item = await pool.query(
|
||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (!item.rows[0]) return;
|
||||
|
||||
const currentQuantity = item.rows[0].quantity;
|
||||
const remainingQuantity = currentQuantity - quantityBought;
|
||||
|
||||
if (remainingQuantity <= 0) {
|
||||
// Mark as bought if all quantity is purchased
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
} else {
|
||||
// Reduce quantity if partial purchase
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||
[remainingQuantity, listId]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add history record for item addition
|
||||
* @param {number} listId - List item ID
|
||||
* @param {number} quantity - Quantity added
|
||||
* @param {number} userId - User who added
|
||||
*/
|
||||
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||
await pool.query(
|
||||
`INSERT INTO household_list_history (list_id, quantity, added_by, added_on)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
[listId, quantity, userId]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get suggestions for autocomplete
|
||||
* @param {string} query - Search query
|
||||
* @param {number} householdId - Household ID (for personalized suggestions)
|
||||
* @param {number} storeId - Store ID
|
||||
* @returns {Promise<Array>} Suggestions
|
||||
*/
|
||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||
// Get items from both master catalog and household history
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT i.name as item_name
|
||||
FROM items i
|
||||
LEFT JOIN household_lists hl
|
||||
ON i.id = hl.item_id
|
||||
AND hl.household_id = $2
|
||||
AND hl.store_id = $3
|
||||
WHERE i.name ILIKE $1
|
||||
ORDER BY
|
||||
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END,
|
||||
i.name
|
||||
LIMIT 10`,
|
||||
[`%${query}%`, householdId, storeId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recently bought items for household/store
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @returns {Promise<Array>} Recently bought items
|
||||
*/
|
||||
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.item_image, 'base64') as item_image,
|
||||
hl.image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT DISTINCT hlh.added_by
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.list_id = hl.id
|
||||
ORDER BY hlh.added_by
|
||||
) hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
) as added_by_users,
|
||||
hl.modified_on as last_added_on
|
||||
FROM household_lists hl
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.bought = TRUE
|
||||
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY hl.modified_on DESC`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get classification for household item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @returns {Promise<Object|null>} Classification or null
|
||||
*/
|
||||
exports.getClassification = async (householdId, itemId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT item_type, item_group, zone, confidence, source
|
||||
FROM household_item_classifications
|
||||
WHERE household_id = $1 AND item_id = $2`,
|
||||
[householdId, itemId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert classification for household item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @param {Object} classification - Classification data
|
||||
* @returns {Promise<Object>} Updated classification
|
||||
*/
|
||||
exports.upsertClassification = async (householdId, itemId, classification) => {
|
||||
const { item_type, item_group, zone, confidence, source } = classification;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_item_classifications
|
||||
(household_id, item_id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (household_id, item_id)
|
||||
DO UPDATE SET
|
||||
item_type = EXCLUDED.item_type,
|
||||
item_group = EXCLUDED.item_group,
|
||||
zone = EXCLUDED.zone,
|
||||
confidence = EXCLUDED.confidence,
|
||||
source = EXCLUDED.source
|
||||
RETURNING *`,
|
||||
[householdId, itemId, item_type, item_group, zone, confidence, source]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Update list item details
|
||||
* @param {number} listId - List item ID
|
||||
* @param {string} itemName - New item name
|
||||
* @param {number} quantity - New quantity
|
||||
* @returns {Promise<Object>} Updated item
|
||||
*/
|
||||
exports.updateItem = async (listId, itemName, quantity) => {
|
||||
// This is more complex now because we need to handle the master catalog
|
||||
// Get current list item
|
||||
const listItem = await pool.query(
|
||||
"SELECT item_id FROM household_lists WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (listItem.rowCount === 0) {
|
||||
throw new Error("List item not found");
|
||||
}
|
||||
|
||||
const oldItemId = listItem.rows[0].item_id;
|
||||
|
||||
// Check if new item name exists in catalog
|
||||
let newItemId;
|
||||
const itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[itemName.toLowerCase()]
|
||||
);
|
||||
|
||||
if (itemResult.rowCount === 0) {
|
||||
// Create new item
|
||||
const insertItem = await pool.query(
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||
[itemName.toLowerCase()]
|
||||
);
|
||||
newItemId = insertItem.rows[0].id;
|
||||
} else {
|
||||
newItemId = itemResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Update list item
|
||||
const result = await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET item_id = $2, quantity = $3, modified_on = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[listId, newItemId, quantity]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a list item
|
||||
* @param {number} listId - List item ID
|
||||
*/
|
||||
exports.deleteItem = async (listId) => {
|
||||
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
||||
};
|
||||
143
backend/models/store.model.js
Normal file
143
backend/models/store.model.js
Normal file
@ -0,0 +1,143 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
// Get all available stores
|
||||
exports.getAllStores = async () => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, default_zones, created_at
|
||||
FROM stores
|
||||
ORDER BY name ASC`
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Get store by ID
|
||||
exports.getStoreById = async (storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, default_zones, created_at
|
||||
FROM stores
|
||||
WHERE id = $1`,
|
||||
[storeId]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Get stores for a specific household
|
||||
exports.getHouseholdStores = async (householdId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.default_zones,
|
||||
hs.is_default,
|
||||
hs.added_at
|
||||
FROM stores s
|
||||
JOIN household_stores hs ON s.id = hs.store_id
|
||||
WHERE hs.household_id = $1
|
||||
ORDER BY hs.is_default DESC, s.name ASC`,
|
||||
[householdId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Add store to household
|
||||
exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => {
|
||||
// If setting as default, unset other defaults
|
||||
if (isDefault) {
|
||||
await pool.query(
|
||||
`UPDATE household_stores
|
||||
SET is_default = FALSE
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_stores (household_id, store_id, is_default)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (household_id, store_id)
|
||||
DO UPDATE SET is_default = $3
|
||||
RETURNING household_id, store_id, is_default`,
|
||||
[householdId, storeId, isDefault]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Remove store from household
|
||||
exports.removeStoreFromHousehold = async (householdId, storeId) => {
|
||||
await pool.query(
|
||||
`DELETE FROM household_stores
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
};
|
||||
|
||||
// Set default store for household
|
||||
exports.setDefaultStore = async (householdId, storeId) => {
|
||||
// Unset all defaults
|
||||
await pool.query(
|
||||
`UPDATE household_stores
|
||||
SET is_default = FALSE
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
// Set new default
|
||||
await pool.query(
|
||||
`UPDATE household_stores
|
||||
SET is_default = TRUE
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
};
|
||||
|
||||
// Create new store (system admin only)
|
||||
exports.createStore = async (name, defaultZones) => {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO stores (name, default_zones)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, default_zones, created_at`,
|
||||
[name, JSON.stringify(defaultZones)]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Update store (system admin only)
|
||||
exports.updateStore = async (storeId, updates) => {
|
||||
const { name, default_zones } = updates;
|
||||
const result = await pool.query(
|
||||
`UPDATE stores
|
||||
SET
|
||||
name = COALESCE($1, name),
|
||||
default_zones = COALESCE($2, default_zones)
|
||||
WHERE id = $3
|
||||
RETURNING id, name, default_zones, created_at`,
|
||||
[name, default_zones ? JSON.stringify(default_zones) : null, storeId]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Delete store (system admin only, only if not in use)
|
||||
exports.deleteStore = async (storeId) => {
|
||||
// Check if store is in use
|
||||
const usage = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
||||
[storeId]
|
||||
);
|
||||
|
||||
if (parseInt(usage.rows[0].count) > 0) {
|
||||
throw new Error('Cannot delete store that is in use by households');
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]);
|
||||
};
|
||||
|
||||
// Check if household has store
|
||||
exports.householdHasStore = async (householdId, storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT 1 FROM household_stores
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
};
|
||||
43
backend/public/TEST_SUITE_README.md
Normal file
43
backend/public/TEST_SUITE_README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# API Test Suite
|
||||
|
||||
The test suite has been reorganized into separate files for better maintainability:
|
||||
|
||||
## New Modular Structure (✅ Complete)
|
||||
- **api-tests.html** - Main HTML file
|
||||
- **test-config.js** - Global state management
|
||||
- **test-definitions.js** - All 62 test cases across 8 categories
|
||||
- **test-runner.js** - Test execution logic
|
||||
- **test-ui.js** - UI manipulation functions
|
||||
- **test-styles.css** - All CSS styles
|
||||
|
||||
## How to Use
|
||||
1. Start the dev server: `docker-compose -f docker-compose.dev.yml up`
|
||||
2. Navigate to: `http://localhost:5000/test/api-tests.html`
|
||||
3. Configure credentials (default: admin/admin123)
|
||||
4. Click "▶ Run All Tests"
|
||||
|
||||
## Features
|
||||
- ✅ 62 comprehensive tests
|
||||
- ✅ Collapsible test cards (collapsed by default)
|
||||
- ✅ Expected field validation with visual indicators
|
||||
- ✅ Color-coded HTTP status badges
|
||||
- ✅ Auto-expansion on test run
|
||||
- ✅ Expand/Collapse all buttons
|
||||
- ✅ Real-time pass/fail/error states
|
||||
- ✅ Summary dashboard
|
||||
|
||||
## File Structure
|
||||
```
|
||||
backend/public/
|
||||
├── api-tests.html # Main entry point (use this)
|
||||
├── test-config.js # State management (19 lines)
|
||||
├── test-definitions.js # Test cases (450+ lines)
|
||||
├── test-runner.js # Test execution (160+ lines)
|
||||
├── test-ui.js # UI functions (90+ lines)
|
||||
└── test-styles.css # All styles (310+ lines)
|
||||
```
|
||||
|
||||
## Old File
|
||||
- **api-test.html** - Original monolithic version (kept for reference)
|
||||
|
||||
Total: ~1030 lines split into 6 clean, modular files
|
||||
1037
backend/public/api-test.html
Normal file
1037
backend/public/api-test.html
Normal file
File diff suppressed because it is too large
Load Diff
60
backend/public/api-tests.html
Normal file
60
backend/public/api-tests.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="test-styles.css">
|
||||
</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 src="test-config.js"></script>
|
||||
<script src="test-definitions.js"></script>
|
||||
<script src="test-runner.js"></script>
|
||||
<script src="test-ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
backend/public/test-config.js
Normal file
19
backend/public/test-config.js
Normal file
@ -0,0 +1,19 @@
|
||||
// Global state
|
||||
let authToken = null;
|
||||
let householdId = null;
|
||||
let storeId = null;
|
||||
let testUserId = null;
|
||||
let createdHouseholdId = null;
|
||||
let secondHouseholdId = null;
|
||||
let inviteCode = null;
|
||||
|
||||
// Reset state
|
||||
function resetState() {
|
||||
authToken = null;
|
||||
householdId = null;
|
||||
storeId = null;
|
||||
testUserId = null;
|
||||
createdHouseholdId = null;
|
||||
secondHouseholdId = null;
|
||||
inviteCode = null;
|
||||
}
|
||||
826
backend/public/test-definitions.js
Normal file
826
backend/public/test-definitions.js
Normal file
@ -0,0 +1,826 @@
|
||||
// Test definitions - 108 tests across 14 categories
|
||||
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)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "List Operations",
|
||||
tests: [
|
||||
{
|
||||
name: "Get grocery list for household+store",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => Array.isArray(res),
|
||||
expectedFields: ['items']
|
||||
},
|
||||
{
|
||||
name: "Add item to list",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test API Item",
|
||||
quantity: "2 units"
|
||||
},
|
||||
expect: (res) => res.item,
|
||||
expectedFields: ['item', 'item.id', 'item.item_name', 'item.quantity']
|
||||
},
|
||||
{
|
||||
name: "Add duplicate item (should update quantity)",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test API Item",
|
||||
quantity: "3 units"
|
||||
},
|
||||
expect: (res) => res.item && res.item.quantity === "3 units"
|
||||
},
|
||||
{
|
||||
name: "Mark item as bought",
|
||||
method: "PATCH",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test API Item",
|
||||
bought: true
|
||||
},
|
||||
expect: (res) => res.message,
|
||||
expectedFields: ['message']
|
||||
},
|
||||
{
|
||||
name: "Unmark item (set bought to false)",
|
||||
method: "PATCH",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test API Item",
|
||||
bought: false
|
||||
},
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Update item details",
|
||||
method: "PUT",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test API Item",
|
||||
quantity: "5 units",
|
||||
notes: "Updated via API test"
|
||||
},
|
||||
expect: (res) => res.item,
|
||||
expectedFields: ['item', 'item.quantity', 'item.notes']
|
||||
},
|
||||
{
|
||||
name: "Get suggestions based on history",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/suggestions?query=test`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => Array.isArray(res)
|
||||
},
|
||||
{
|
||||
name: "Get recently bought items",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => Array.isArray(res)
|
||||
},
|
||||
{
|
||||
name: "Delete item from list",
|
||||
method: "DELETE",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test API Item"
|
||||
},
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Try to add item with empty name",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "",
|
||||
quantity: "1"
|
||||
},
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Item Classifications",
|
||||
tests: [
|
||||
{
|
||||
name: "Get item classification",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Milk`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => res.classification !== undefined,
|
||||
expectedFields: ['classification']
|
||||
},
|
||||
{
|
||||
name: "Set item classification",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test Classified Item",
|
||||
classification: "dairy"
|
||||
},
|
||||
expect: (res) => res.message || res.classification
|
||||
},
|
||||
{
|
||||
name: "Update item classification",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test Classified Item",
|
||||
classification: "produce"
|
||||
},
|
||||
expect: (res) => res.message || res.classification
|
||||
},
|
||||
{
|
||||
name: "Verify classification persists",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Test Classified Item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => res.classification === "produce"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Account Management",
|
||||
tests: [
|
||||
{
|
||||
name: "Get current user profile",
|
||||
method: "GET",
|
||||
endpoint: "/users/me",
|
||||
auth: true,
|
||||
expect: (res) => res.username,
|
||||
expectedFields: ['id', 'username', 'name', 'display_name', 'role'],
|
||||
onSuccess: (res) => { testUserId = res.id; }
|
||||
},
|
||||
{
|
||||
name: "Update display name",
|
||||
method: "PATCH",
|
||||
endpoint: "/users/me/display-name",
|
||||
auth: true,
|
||||
body: {
|
||||
display_name: "Test Display Name"
|
||||
},
|
||||
expect: (res) => res.message,
|
||||
expectedFields: ['message']
|
||||
},
|
||||
{
|
||||
name: "Verify display name updated",
|
||||
method: "GET",
|
||||
endpoint: "/users/me",
|
||||
auth: true,
|
||||
expect: (res) => res.display_name === "Test Display Name"
|
||||
},
|
||||
{
|
||||
name: "Clear display name (set to null)",
|
||||
method: "PATCH",
|
||||
endpoint: "/users/me/display-name",
|
||||
auth: true,
|
||||
body: {
|
||||
display_name: null
|
||||
},
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Update password",
|
||||
method: "PATCH",
|
||||
endpoint: "/users/me/password",
|
||||
auth: true,
|
||||
body: () => ({
|
||||
currentPassword: document.getElementById('password').value,
|
||||
newPassword: document.getElementById('password').value
|
||||
}),
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Try to update password with wrong current password",
|
||||
method: "PATCH",
|
||||
endpoint: "/users/me/password",
|
||||
auth: true,
|
||||
body: {
|
||||
currentPassword: "wrongpassword",
|
||||
newPassword: "newpass123"
|
||||
},
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 401
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Config Endpoints",
|
||||
tests: [
|
||||
{
|
||||
name: "Get classifications list",
|
||||
method: "GET",
|
||||
endpoint: "/config/classifications",
|
||||
auth: false,
|
||||
expect: (res) => Array.isArray(res),
|
||||
expectedFields: ['[0].value', '[0].label', '[0].color']
|
||||
},
|
||||
{
|
||||
name: "Get system config",
|
||||
method: "GET",
|
||||
endpoint: "/config",
|
||||
auth: false,
|
||||
expect: (res) => res.classifications,
|
||||
expectedFields: ['classifications']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Advanced List Scenarios",
|
||||
tests: [
|
||||
{
|
||||
name: "Add multiple items rapidly",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Rapid Test Item 1",
|
||||
quantity: "1"
|
||||
},
|
||||
expect: (res) => res.item
|
||||
},
|
||||
{
|
||||
name: "Add second rapid item",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Rapid Test Item 2",
|
||||
quantity: "1"
|
||||
},
|
||||
expect: (res) => res.item
|
||||
},
|
||||
{
|
||||
name: "Verify list contains both items",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => res.items && res.items.length >= 2
|
||||
},
|
||||
{
|
||||
name: "Mark both items as bought",
|
||||
method: "PATCH",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Rapid Test Item 1",
|
||||
bought: true
|
||||
},
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Mark second item as bought",
|
||||
method: "PATCH",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Rapid Test Item 2",
|
||||
bought: true
|
||||
},
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Verify recent items includes bought items",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
expect: (res) => Array.isArray(res) && res.length > 0
|
||||
},
|
||||
{
|
||||
name: "Delete first rapid test item",
|
||||
method: "DELETE",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Rapid Test Item 1"
|
||||
},
|
||||
expect: (res) => res.message
|
||||
},
|
||||
{
|
||||
name: "Delete second rapid test item",
|
||||
method: "DELETE",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Rapid Test Item 2"
|
||||
},
|
||||
expect: (res) => res.message
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Edge Cases & Error Handling",
|
||||
tests: [
|
||||
{
|
||||
name: "Access non-existent household",
|
||||
method: "GET",
|
||||
endpoint: "/households/99999",
|
||||
auth: true,
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 403 || status === 404
|
||||
},
|
||||
{
|
||||
name: "Access non-existent store in household",
|
||||
method: "GET",
|
||||
endpoint: () => `/households/${householdId}/stores/99999/list`,
|
||||
auth: true,
|
||||
skip: () => !householdId,
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 403 || status === 404
|
||||
},
|
||||
{
|
||||
name: "Try to update non-existent item",
|
||||
method: "PUT",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Non Existent Item 999",
|
||||
quantity: "1"
|
||||
},
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 404
|
||||
},
|
||||
{
|
||||
name: "Try to delete non-existent item",
|
||||
method: "DELETE",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Non Existent Item 999"
|
||||
},
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 404
|
||||
},
|
||||
{
|
||||
name: "Invalid classification value",
|
||||
method: "POST",
|
||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
||||
auth: true,
|
||||
skip: () => !householdId || !storeId,
|
||||
body: {
|
||||
item_name: "Test Item",
|
||||
classification: "invalid_category_xyz"
|
||||
},
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 400
|
||||
},
|
||||
{
|
||||
name: "Empty household name on creation",
|
||||
method: "POST",
|
||||
endpoint: "/households",
|
||||
auth: true,
|
||||
body: {
|
||||
name: ""
|
||||
},
|
||||
expectFail: true,
|
||||
expect: (res, status) => status === 400
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
147
backend/public/test-runner.js
Normal file
147
backend/public/test-runner.js
Normal file
@ -0,0 +1,147 @@
|
||||
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');
|
||||
|
||||
if (test.skip && test.skip()) {
|
||||
testEl.querySelector('.test-status').textContent = 'SKIPPED';
|
||||
testEl.querySelector('.test-status').className = 'test-status pending';
|
||||
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';
|
||||
|
||||
// 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.style.display = 'block';
|
||||
resultEl.className = 'test-result';
|
||||
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) {
|
||||
resetState();
|
||||
|
||||
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';
|
||||
}
|
||||
666
backend/public/test-script.js
Normal file
666
backend/public/test-script.js
Normal file
@ -0,0 +1,666 @@
|
||||
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();
|
||||
309
backend/public/test-styles.css
Normal file
309
backend/public/test-styles.css
Normal file
@ -0,0 +1,309 @@
|
||||
* {
|
||||
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;
|
||||
}
|
||||
85
backend/public/test-ui.js
Normal file
85
backend/public/test-ui.js
Normal file
@ -0,0 +1,85 @@
|
||||
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';
|
||||
resetState();
|
||||
}
|
||||
|
||||
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 on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
renderTests();
|
||||
});
|
||||
60
backend/routes/households.routes.js
Normal file
60
backend/routes/households.routes.js
Normal file
@ -0,0 +1,60 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const controller = require("../controllers/households.controller");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
} = require("../middleware/household");
|
||||
|
||||
// Public routes (authenticated only)
|
||||
router.get("/", auth, controller.getUserHouseholds);
|
||||
router.post("/", auth, controller.createHousehold);
|
||||
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
||||
|
||||
// Household-scoped routes (member access required)
|
||||
router.get("/:householdId", auth, householdAccess, controller.getHousehold);
|
||||
router.patch(
|
||||
"/:householdId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.updateHousehold
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.deleteHousehold
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/invite/refresh",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.refreshInviteCode
|
||||
);
|
||||
|
||||
// Member management routes
|
||||
router.get(
|
||||
"/:householdId/members",
|
||||
auth,
|
||||
householdAccess,
|
||||
controller.getMembers
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/members/:userId/role",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.updateMemberRole
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/members/:userId",
|
||||
auth,
|
||||
householdAccess,
|
||||
controller.removeMember
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
48
backend/routes/stores.routes.js
Normal file
48
backend/routes/stores.routes.js
Normal file
@ -0,0 +1,48 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const controller = require("../controllers/stores.controller");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
requireSystemAdmin,
|
||||
} = require("../middleware/household");
|
||||
|
||||
// Public routes
|
||||
router.get("/", auth, controller.getAllStores);
|
||||
|
||||
// Household store management
|
||||
router.get(
|
||||
"/household/:householdId",
|
||||
auth,
|
||||
householdAccess,
|
||||
controller.getHouseholdStores
|
||||
);
|
||||
router.post(
|
||||
"/household/:householdId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.addStoreToHousehold
|
||||
);
|
||||
router.delete(
|
||||
"/household/:householdId/:storeId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.removeStoreFromHousehold
|
||||
);
|
||||
router.patch(
|
||||
"/household/:householdId/:storeId/default",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.setDefaultStore
|
||||
);
|
||||
|
||||
// System admin routes
|
||||
router.post("/", auth, requireSystemAdmin, controller.createStore);
|
||||
router.patch("/:storeId", auth, requireSystemAdmin, controller.updateStore);
|
||||
router.delete("/:storeId", auth, requireSystemAdmin, controller.deleteStore);
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Reference in New Issue
Block a user