- 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>
250 lines
9.1 KiB
JavaScript
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' })
|
|
}
|
|
}
|
|
})); |