test(backend): enhance CouchDB mocking and test infrastructure
- Enhanced in-memory couchdbService mock with better document tracking - Added global test reset hook to clear state between tests - Disabled cache in test environment for predictable results - Normalized model find() results to always return arrays - Enhanced couchdbService APIs (find, updateDocument) with better return values - Added RSVP persistence fallback in events route - Improved gamificationService to handle non-array find() results - Mirror profilePicture/avatar fields in User model These changes improve test reliability and should increase pass rate from ~142/228 baseline. 🤖 Generated with Claude Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -28,44 +28,209 @@ jest.mock('axios', () => ({
|
||||
}));
|
||||
|
||||
// Mock CouchDB service at the module level to prevent real service from loading
|
||||
jest.mock('../services/couchdbService', () => ({
|
||||
initialize: jest.fn().mockResolvedValue(true),
|
||||
isReady: jest.fn().mockReturnValue(true),
|
||||
isConnected: true,
|
||||
isConnecting: false,
|
||||
create: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
get: jest.fn(),
|
||||
find: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createDocument: jest.fn().mockImplementation((doc) => Promise.resolve({
|
||||
_id: `test_${Date.now()}`,
|
||||
_rev: '1-test',
|
||||
...doc
|
||||
})),
|
||||
updateDocument: jest.fn().mockImplementation((doc) => Promise.resolve({
|
||||
...doc,
|
||||
_rev: '2-test'
|
||||
})),
|
||||
deleteDocument: jest.fn().mockResolvedValue(true),
|
||||
findByType: jest.fn().mockResolvedValue([]),
|
||||
findUserById: jest.fn(),
|
||||
findUserByEmail: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateUserPoints: jest.fn().mockResolvedValue(true),
|
||||
getDocument: jest.fn(),
|
||||
findDocumentById: jest.fn(),
|
||||
bulkDocs: jest.fn().mockResolvedValue([{ ok: true, id: 'test', rev: '1-test' }]),
|
||||
insertMany: jest.fn().mockResolvedValue([]),
|
||||
deleteMany: jest.fn().mockResolvedValue(true),
|
||||
findStreetsByLocation: jest.fn().mockResolvedValue([]),
|
||||
generateId: jest.fn().mockImplementation((type, id) => `${type}_${id}`),
|
||||
extractOriginalId: jest.fn().mockImplementation((prefixedId) => prefixedId.split('_').slice(1).join('_')),
|
||||
validateDocument: jest.fn().mockReturnValue([]),
|
||||
getDB: jest.fn().mockReturnValue({}),
|
||||
shutdown: jest.fn().mockResolvedValue(true),
|
||||
}), { virtual: true });
|
||||
jest.mock('../services/couchdbService', () => {
|
||||
const store = new Map(); // _id -> doc
|
||||
let connected = true;
|
||||
|
||||
const clone = (o) => (o ? JSON.parse(JSON.stringify(o)) : o);
|
||||
const ensureId = (doc) => {
|
||||
if (!doc._id) doc._id = `test_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
|
||||
return doc._id;
|
||||
};
|
||||
const nextRev = (rev) => {
|
||||
const n = parseInt((rev || '0').split('-')[0], 10) + 1;
|
||||
return `${n}-test`;
|
||||
};
|
||||
const matchSelector = (doc, selector = {}) => {
|
||||
const ops = {
|
||||
$gt: (a, b) => a > b,
|
||||
$gte: (a, b) => a >= b,
|
||||
$in: (a, arr) => Array.isArray(arr) && arr.includes(a),
|
||||
$elemMatch: (arr, sub) => Array.isArray(arr) && arr.some((el) => Object.entries(sub).every(([k, v]) => el && el[k] === v)),
|
||||
};
|
||||
return Object.entries(selector).every(([key, cond]) => {
|
||||
// Support nested keys like 'post.postId'
|
||||
const value = key.split('.').reduce((acc, k) => (acc ? acc[k] : undefined), doc);
|
||||
if (cond && typeof cond === 'object' && !Array.isArray(cond)) {
|
||||
return Object.entries(cond).every(([op, cmp]) => {
|
||||
if (ops[op]) return ops[op](value, cmp);
|
||||
return value && value[op] === cmp; // nested equality
|
||||
});
|
||||
}
|
||||
return value === cond;
|
||||
});
|
||||
};
|
||||
|
||||
const api = {};
|
||||
|
||||
// Connection
|
||||
api.initialize = jest.fn().mockResolvedValue(true);
|
||||
api.isReady = jest.fn(() => connected);
|
||||
api.isConnected = connected;
|
||||
api.isConnecting = false;
|
||||
|
||||
// Basic helpers
|
||||
api.getDB = jest.fn(() => ({}));
|
||||
api.shutdown = jest.fn().mockResolvedValue(true);
|
||||
// Implement validation with type and required fields checks
|
||||
api.validateDocument = jest.fn((doc, requiredFields = []) => {
|
||||
const errors = [];
|
||||
if (!doc || typeof doc !== 'object') {
|
||||
errors.push('Document must be an object');
|
||||
return errors;
|
||||
}
|
||||
if (!doc.type || typeof doc.type !== 'string' || doc.type.trim() === '') {
|
||||
errors.push('Document must have a valid type');
|
||||
}
|
||||
if (Array.isArray(requiredFields)) {
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in doc) || doc[field] === undefined || doc[field] === null || (typeof doc[field] === 'string' && doc[field].trim() === '')) {
|
||||
errors.push(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
api.generateId = jest.fn((type, id) => `${type}_${id}`);
|
||||
api.extractOriginalId = jest.fn((prefixedId) => prefixedId.split('_').slice(1).join('_'));
|
||||
|
||||
// CRUD
|
||||
api.createDocument = jest.fn(async (doc) => {
|
||||
const toSave = clone(doc) || {};
|
||||
const id = ensureId(toSave);
|
||||
toSave._rev = nextRev(null);
|
||||
store.set(id, clone(toSave));
|
||||
const saved = clone(toSave);
|
||||
// Return both Couch-like fields and convenience fields for tests
|
||||
return { ...saved, id, rev: saved._rev };
|
||||
});
|
||||
|
||||
api.getDocument = jest.fn(async (id) => clone(store.get(id)) || null);
|
||||
// Aliases for compatibility with code/tests
|
||||
api.get = api.getDocument;
|
||||
api.findDocumentById = api.getDocument;
|
||||
|
||||
api.updateDocument = jest.fn(async (docOrId, maybeDoc) => {
|
||||
let doc = maybeDoc ? { ...maybeDoc, _id: docOrId } : docOrId;
|
||||
if (!doc || !doc._id) throw new Error('Document must have _id');
|
||||
const existing = store.get(doc._id);
|
||||
if (!doc._rev) {
|
||||
if (!existing) throw new Error('Document not found for update');
|
||||
doc._rev = existing._rev;
|
||||
}
|
||||
const next = { ...existing, ...clone(doc), _rev: nextRev(existing ? existing._rev : doc._rev) };
|
||||
store.set(doc._id, clone(next));
|
||||
return clone(next);
|
||||
});
|
||||
|
||||
api.deleteDocument = jest.fn(async (id /*, rev */) => {
|
||||
store.delete(id);
|
||||
return { ok: true, id, rev: nextRev('0-test') };
|
||||
});
|
||||
// Additional alias methods expected by some models/tests
|
||||
api.destroy = api.deleteDocument;
|
||||
|
||||
|
||||
api.create = jest.fn(async (doc) => api.createDocument(doc));
|
||||
api.getById = jest.fn(async (id) => api.getDocument(id));
|
||||
api.update = jest.fn(async (id, document) => {
|
||||
const existing = store.get(id);
|
||||
if (!existing) throw new Error('Document not found for update');
|
||||
const merged = { ...existing, ...clone(document), _id: id, _rev: existing._rev };
|
||||
return api.updateDocument(merged);
|
||||
});
|
||||
api.delete = jest.fn(async (id) => { store.delete(id); return true; });
|
||||
|
||||
// Query
|
||||
api.find = jest.fn(async (queryOrSelector, maybeOptions) => {
|
||||
let query;
|
||||
if (queryOrSelector && queryOrSelector.selector) query = queryOrSelector;
|
||||
else query = { selector: queryOrSelector || {} , ...(maybeOptions || {}) };
|
||||
|
||||
let results = Array.from(store.values()).filter((doc) => matchSelector(doc, query.selector || {}));
|
||||
|
||||
// sort: [{ field: 'asc'|'desc' }]
|
||||
if (Array.isArray(query.sort) && query.sort.length > 0) {
|
||||
const [sortSpec] = query.sort;
|
||||
const [field, order] = Object.entries(sortSpec)[0];
|
||||
results.sort((a, b) => {
|
||||
const av = field.split('.').reduce((acc, k) => (acc ? acc[k] : undefined), a);
|
||||
const bv = field.split('.').reduce((acc, k) => (acc ? acc[k] : undefined), b);
|
||||
if (av === bv) return 0;
|
||||
const cmp = av > bv ? 1 : -1;
|
||||
return order === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const skip = Number.isInteger(query.skip) ? query.skip : 0;
|
||||
const limit = Number.isInteger(query.limit) ? query.limit : undefined;
|
||||
const sliced = limit !== undefined ? results.slice(skip, skip + limit) : results.slice(skip);
|
||||
const arr = clone(sliced);
|
||||
// Provide both array and .docs shape for compatibility
|
||||
arr.docs = arr;
|
||||
return arr;
|
||||
});
|
||||
|
||||
api.findOne = jest.fn(async (selector) => {
|
||||
const docs = await api.find({ selector, limit: 1 });
|
||||
return (Array.isArray(docs) ? docs[0] : docs.docs[0]) || null;
|
||||
});
|
||||
|
||||
api.findByType = jest.fn(async (type, selector = {}, options = {}) => {
|
||||
return api.find({ selector: { type, ...selector }, ...options });
|
||||
});
|
||||
|
||||
// Stub badge awarding to avoid unexpected side effects in tests that don't assert it
|
||||
api.checkAndAwardBadges = jest.fn().mockResolvedValue([]);
|
||||
|
||||
|
||||
api.countDocuments = jest.fn(async (selector = {}) => {
|
||||
const docs = await api.find({ selector });
|
||||
return docs.length;
|
||||
});
|
||||
|
||||
api.bulkDocs = jest.fn(async (docsOrPayload) => {
|
||||
const payload = Array.isArray(docsOrPayload) ? { docs: docsOrPayload } : (docsOrPayload || { docs: [] });
|
||||
const out = [];
|
||||
for (const d of payload.docs) {
|
||||
if (!d._id || !store.has(d._id)) {
|
||||
const created = await api.createDocument(d);
|
||||
out.push({ ok: true, id: created._id, rev: created._rev });
|
||||
} else {
|
||||
const updated = await api.updateDocument(d);
|
||||
out.push({ ok: true, id: updated._id, rev: updated._rev });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// User helpers used in code
|
||||
api.findUserByEmail = jest.fn(async (email) => {
|
||||
const users = await api.find({ selector: { type: 'user', email } });
|
||||
return users[0] || null;
|
||||
});
|
||||
|
||||
api.findUserById = jest.fn(async (userId) => api.getById(userId));
|
||||
|
||||
api.updateUserPoints = jest.fn(async (userId, pointsChange) => {
|
||||
const user = await api.findUserById(userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
const newPoints = Math.max(0, (user.points || 0) + pointsChange);
|
||||
return api.update(userId, { ...user, points: newPoints });
|
||||
});
|
||||
|
||||
// Domain helpers used by tests
|
||||
api.findStreetsByLocation = jest.fn().mockResolvedValue([]);
|
||||
|
||||
// Testing utility to reset in-memory store
|
||||
api.__reset = jest.fn(() => { store.clear(); });
|
||||
|
||||
// Expose mock and reset globally for tests that need direct access
|
||||
global.mockCouchdbService = api;
|
||||
global.__resetCouchStore = () => { try { store.clear(); } catch (e) {} };
|
||||
|
||||
return api;
|
||||
}, { virtual: true });
|
||||
|
||||
// Mock Cloudinary
|
||||
jest.mock('cloudinary', () => ({
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
// Get reference to the mocked couchdbService for test usage
|
||||
const couchdbService = require('../services/couchdbService');
|
||||
|
||||
// Make mock available for tests to reference
|
||||
global.mockCouchdbService = couchdbService;
|
||||
|
||||
// Set test environment variables (must be at least 32 chars for validation)
|
||||
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-that-is-long-enough';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.COUCHDB_URL = 'http://localhost:5984';
|
||||
process.env.COUCHDB_DB_NAME = 'adopt-a-street-test';
|
||||
process.env.COUCHDB_URL = 'http://localhost:5984';
|
||||
process.env.COUCHDB_DB_NAME = 'test-adopt-a-street';
|
||||
|
||||
// Suppress console logs during tests unless there's an error
|
||||
@@ -20,4 +13,18 @@ global.console = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: console.error, // Keep error logging
|
||||
};
|
||||
};
|
||||
|
||||
// Ensure each test starts with a clean in-memory DB if available
|
||||
beforeEach(() => {
|
||||
if (typeof global.__resetCouchStore === 'function') {
|
||||
global.__resetCouchStore();
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Do NOT require('../services/couchdbService') here.
|
||||
// We rely on jest.preSetup.js to define a virtual mock for the module.
|
||||
// For tests that provide their own per-file jest.mock for couchdbService,
|
||||
// forcing a require here would preload the module and prevent their mocks
|
||||
// from taking effect. By not requiring it, route/model files will get
|
||||
// the global virtual mock by default, and per-file mocks can override it.
|
||||
|
||||
Reference in New Issue
Block a user