Files
adopt-a-street/backend/__tests__/jest.preSetup.js
William Valentin b8ffc22259 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>
2025-11-05 13:05:25 -08:00

250 lines
9.1 KiB
JavaScript

// This file runs before any modules are loaded
// Set test environment variables FIRST (before any module loads)
// Must be at least 32 chars for validation
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-that-is-long-enough';
process.env.COUCHDB_URL = 'http://localhost:5984';
process.env.COUCHDB_DB_NAME = 'test-adopt-a-street';
process.env.PORT = '5001';
// Mock dotenv to prevent .env file from overriding test values
jest.mock('dotenv', () => ({
config: jest.fn()
}));
// Mock axios first since couchdbService uses it
jest.mock('axios', () => ({
create: jest.fn(() => ({
get: jest.fn().mockResolvedValue({ data: {} }),
put: jest.fn().mockResolvedValue({ data: { ok: true } }),
post: jest.fn().mockResolvedValue({ data: { ok: true } }),
delete: jest.fn().mockResolvedValue({ data: { ok: true } }),
})),
get: jest.fn().mockResolvedValue({ data: {} }),
put: jest.fn().mockResolvedValue({ data: { ok: true } }),
post: jest.fn().mockResolvedValue({ data: { ok: true } }),
delete: jest.fn().mockResolvedValue({ data: { ok: true } }),
}));
// Mock CouchDB service at the module level to prevent real service from loading
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', () => ({
v2: {
config: jest.fn(),
uploader: {
upload: jest.fn().mockResolvedValue({
secure_url: 'https://cloudinary.com/test/image.jpg',
public_id: 'test_public_id',
width: 500,
height: 500,
format: 'jpg'
}),
destroy: jest.fn().mockResolvedValue({ result: 'ok' })
}
}
}));