diff --git a/package.json b/package.json index f75b5316..63353aa1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "escape-html": "^1.0.3", "express": "^5.2.1", "helmet": "^8.0.0", - "pino": "^10.3.1" + "pino": "^10.3.1", + "ioredis": "^5.4.0" }, "devDependencies": { "eslint-plugin-security": "^3.0.1", diff --git a/src/middleware/cache.js b/src/middleware/cache.js new file mode 100644 index 00000000..c5644896 --- /dev/null +++ b/src/middleware/cache.js @@ -0,0 +1,84 @@ +// src/middleware/cache.js +// +// Express middleware for automatic GET response caching (issue #12). +// Only caches GET requests. Varies cache key by URL + query + auth status. + +const { cache, TTL_PRESETS } = require('../services/cache'); +const { childLogger } = require('../config/logger'); + +const log = childLogger('middleware:cache'); + +/** + * Determine TTL from the request path. + * /api/meters/* → meter TTL (60s) + * /api/admin/config/* → config TTL (300s) + * /api/analytics/* → analytics TTL (120s) + * default → 60s + */ +function getTTL(path) { + if (path.includes('/config')) return TTL_PRESETS.config; + if (path.includes('/analytics')) return TTL_PRESETS.analytics; + if (path.includes('/meter')) return TTL_PRESETS.meter; + return TTL_PRESETS.default; +} + +/** + * Build a cache key from request. + */ +function buildCacheKey(req) { + const path = req.originalUrl || req.url; + const authStatus = req.headers.authorization ? 'authed' : 'anon'; + return `equipchain:http:${authStatus}:${Buffer.from(path).toString('base64url')}`; +} + +/** + * Cache middleware factory. + * @param {number} ttl - Cache TTL in seconds (overrides auto-detection) + */ +function cacheMiddleware(ttl) { + return async (req, res, next) => { + // Only cache GET requests + if (req.method !== 'GET') { + return next(); + } + + const key = buildCacheKey(req); + const effectiveTTL = ttl || getTTL(req.path); + + // Try cache first + const cached = await cache.get(key); + if (cached) { + log.debug({ key }, 'Cache hit'); + res.set('X-Cache', 'HIT'); + res.set('X-Cache-TTL', effectiveTTL.toString()); + return res.json(cached); + } + + // Intercept res.json to cache the response + const originalJson = res.json.bind(res); + res.json = function (body) { + // Only cache successful responses (2xx) + if (res.statusCode >= 200 && res.statusCode < 300 && body) { + cache.set(key, body, effectiveTTL).catch((err) => { + log.error({ err: err.message, key }, 'Failed to cache response'); + }); + } + + res.set('X-Cache', 'MISS'); + return originalJson(body); + }; + + next(); + }; +} + +/** + * Cache invalidation helper — call after write operations. + * @param {string} entity - Entity type (meter, config, etc.) + * @param {string} id - Entity ID + */ +async function invalidateCache(entity, id) { + return cache.invalidate(entity, id); +} + +module.exports = { cacheMiddleware, invalidateCache, getTTL, buildCacheKey }; diff --git a/src/routes/exports.js b/src/routes/exports.js index 5c3e967e..2abee3ed 100644 --- a/src/routes/exports.js +++ b/src/routes/exports.js @@ -2,6 +2,8 @@ const express = require('express'); const router = express.Router(); const { handleExport } = require('../services/exporter'); const { childLogger } = require('../config/logger'); +const { validate } = require('../middleware/validate'); +const { readingsQuerySchema, analyticsExportSchema, createReadingSchema, bulkReadingsSchema } = require('../schemas/metering.schema'); const log = childLogger('routes:exports'); diff --git a/src/routes/index.js b/src/routes/index.js index 20ac8c46..4ed6c740 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,17 +1,18 @@ const express = require('express'); const router = express.Router(); const { services } = require('../services'); +const { cache } = require('../services/cache'); // Import route modules here as they are created // const authRoutes = require('./auth'); -// const adminRoutes = require('./admin'); -// const analyticsRoutes = require('./analytics'); +const adminRoutes = require('./admin'); +const analyticsRoutes = require('./analytics'); const exportRoutes = require('./exports'); // Mount routes under their respective prefixes // router.use('/api/auth', authRoutes); -// router.use('/api/admin', adminRoutes); -// router.use('/api/analytics', analyticsRoutes); +router.use('/api/admin', adminRoutes); +router.use('/api/analytics', analyticsRoutes); router.use('/api/exports', exportRoutes); // Health check route @@ -34,6 +35,11 @@ router.get('/health', (req, res) => { }; } + // Add cache stats if cache service is available + if (cache) { + healthData.cache = cache.health(); + } + res.json(healthData); }); diff --git a/src/schemas/metering.schema.js b/src/schemas/metering.schema.js new file mode 100644 index 00000000..654c32f2 --- /dev/null +++ b/src/schemas/metering.schema.js @@ -0,0 +1,53 @@ +// src/schemas/metering.schema.js +// +// Zod validation schemas for metering/export endpoints (issue #8). +// Covers the export routes that were missing validation in src/routes/exports.js. + +const { z } = require('zod'); +const { isoDateString, paginationQuerySchema } = require('./common.schema'); + +/** + * Query params for listing meter readings via export endpoints. + */ +const readingsQuerySchema = paginationQuerySchema.extend({ + meterId: z.string().min(1).optional(), + status: z.enum(['verified', 'pending', 'rejected']).optional(), + unit: z.enum(['kWh', 'kW', 'V', 'A', 'VAR', 'VA']).optional(), + startDate: isoDateString.optional(), + endDate: isoDateString.optional(), +}); + +/** + * Query params for analytics export endpoints. + */ +const analyticsExportSchema = z.object({ + startDate: isoDateString.optional(), + endDate: isoDateString.optional(), + granularity: z.enum(['hour', 'day', 'week', 'month']).default('day'), + format: z.enum(['json', 'csv']).default('json'), +}); + +/** + * Body schema for creating a meter reading submission. + */ +const createReadingSchema = z.object({ + meterId: z.string().min(1), + timestamp: isoDateString, + value: z.number().finite(), + unit: z.enum(['kWh', 'kW', 'V', 'A', 'VAR', 'VA']), + status: z.enum(['verified', 'pending', 'rejected']).default('pending'), +}).strict(); + +/** + * Body schema for bulk reading import. + */ +const bulkReadingsSchema = z.object({ + readings: z.array(createReadingSchema).min(1).max(1000), +}).strict(); + +module.exports = { + readingsQuerySchema, + analyticsExportSchema, + createReadingSchema, + bulkReadingsSchema, +}; diff --git a/src/services/cache.js b/src/services/cache.js new file mode 100644 index 00000000..d6dc15b8 --- /dev/null +++ b/src/services/cache.js @@ -0,0 +1,239 @@ +// src/services/cache.js +// +// Redis caching abstraction for EquipChain blockchain data queries (issue #12). +// Uses ioredis with graceful fallback when Redis is unavailable. + +const Redis = require('ioredis'); +const { childLogger } = require('../config/logger'); + +const log = childLogger('cache'); + +const { + REDIS_URL = 'redis://localhost:6379', + REDIS_PASSWORD = '', + CACHE_DEFAULT_TTL = '60', +} = process.env; + +const DEFAULT_TTL = parseInt(CACHE_DEFAULT_TTL, 10) || 60; + +// TTL presets by entity type (seconds) +const TTL_PRESETS = { + meter: 60, // meter readings change frequently + config: 300, // configuration data changes rarely + analytics: 120, // aggregated analytics + default: DEFAULT_TTL, +}; + +/** + * CacheService — wraps ioredis with graceful degradation. + * Falls back to no-cache mode when Redis is down. + */ +class CacheService { + constructor(url = REDIS_URL, password = REDIS_PASSWORD) { + this.url = url; + this.password = password || undefined; + this.connected = false; + this.client = null; + this.stats = { hits: 0, misses: 0, errors: 0, sets: 0, deletes: 0 }; + + this._connect(); + } + + _connect() { + try { + const opts = { + maxRetriesPerRequest: 3, + retryStrategy: (times) => { + if (times > 10) { + log.warn('Redis retry limit reached — caching disabled'); + return null; // stop retrying + } + return Math.min(times * 100, 2000); // exponential backoff capped at 2s + }, + enableOfflineQueue: true, + lazyConnect: false, + }; + + if (this.password) { + opts.password = this.password; + } + + this.client = new Redis(this.url, opts); + + this.client.on('connect', () => { + this.connected = true; + log.info('Redis cache connected'); + }); + + this.client.on('error', (err) => { + this.connected = false; + this.stats.errors++; + log.error({ err: err.message }, 'Redis cache error'); + }); + + this.client.on('close', () => { + this.connected = false; + log.warn('Redis cache connection closed'); + }); + + this.client.on('reconnecting', (delay) => { + log.info({ delay }, 'Redis cache reconnecting'); + }); + + } catch (err) { + log.warn({ err: err.message }, 'Redis init failed — caching disabled'); + this.connected = false; + } + } + + /** + * Build a cache key following the naming convention. + * equipchain:{entity}:{id}:{field} + */ + buildKey(entity, id, field = '') { + const parts = ['equipchain', entity, id]; + if (field) parts.push(field); + return parts.join(':'); + } + + /** + * Get a value from cache. Returns null on miss or error. + */ + async get(key) { + if (!this.connected) { + this.stats.misses++; + return null; + } + + try { + const raw = await this.client.get(key); + if (raw === null) { + this.stats.misses++; + return null; + } + this.stats.hits++; + return JSON.parse(raw); + } catch (err) { + this.stats.errors++; + log.error({ err: err.message, key }, 'Cache get error'); + return null; + } + } + + /** + * Set a value in cache with TTL (seconds). + */ + async set(key, value, ttl = DEFAULT_TTL) { + if (!this.connected) return false; + + try { + const serialized = JSON.stringify(value); + if (ttl > 0) { + await this.client.setex(key, ttl, serialized); + } else { + await this.client.set(key, serialized); + } + this.stats.sets++; + return true; + } catch (err) { + this.stats.errors++; + log.error({ err: err.message, key }, 'Cache set error'); + return false; + } + } + + /** + * Delete a key from cache. + */ + async del(key) { + if (!this.connected) return false; + + try { + await this.client.del(key); + this.stats.deletes++; + return true; + } catch (err) { + this.stats.errors++; + log.error({ err: err.message, key }, 'Cache del error'); + return false; + } + } + + /** + * Flush keys matching a pattern (glob-style). + */ + async flush(pattern) { + if (!this.connected) return 0; + + try { + const keys = await this.client.keys(pattern); + if (keys.length === 0) return 0; + + // Delete in batches to avoid blocking Redis + const batchSize = 100; + let deleted = 0; + for (let i = 0; i < keys.length; i += batchSize) { + const batch = keys.slice(i, i + batchSize); + deleted += await this.client.del(...batch); + } + + log.info({ pattern, deleted }, 'Cache flushed'); + return deleted; + } catch (err) { + this.stats.errors++; + log.error({ err: err.message, pattern }, 'Cache flush error'); + return 0; + } + } + + /** + * Invalidate cache for a specific entity (e.g., after a write). + */ + async invalidate(entity, id) { + return this.flush(`equipchain:${entity}:${id}:*`); + } + + /** + * Warm the cache for high-priority queries. + * @param {Array<{key: string, fetcher: () => Promise, ttl: number}>} entries + */ + async warm(entries) { + for (const { key, fetcher, ttl } of entries) { + try { + const value = await fetcher(); + if (value !== null && value !== undefined) { + await this.set(key, value, ttl || DEFAULT_TTL); + } + } catch (err) { + log.error({ err: err.message, key }, 'Cache warm error'); + } + } + } + + /** + * Get cache health and stats. + */ + health() { + return { + connected: this.connected, + stats: { ...this.stats }, + hitRate: this.stats.hits + this.stats.misses > 0 + ? (this.stats.hits / (this.stats.hits + this.stats.misses) * 100).toFixed(2) + '%' + : '0%', + }; + } + + /** + * Gracefully close the connection. + */ + async quit() { + if (this.client) { + await this.client.quit(); + } + } +} + +// Singleton instance +const cache = new CacheService(); + +module.exports = { cache, CacheService, TTL_PRESETS }; diff --git a/tests/unit/cache.test.js b/tests/unit/cache.test.js new file mode 100644 index 00000000..4ffff97f --- /dev/null +++ b/tests/unit/cache.test.js @@ -0,0 +1,153 @@ +// tests/unit/cache.test.js +// +// Unit tests for the CacheService (issue #12). +// Tests use a mock Redis client — no real Redis required. + +const { CacheService, TTL_PRESETS } = require('../../src/services/cache'); + +// Mock ioredis +jest.mock('ioredis', () => { + const store = new Map(); + const Redis = jest.fn().mockImplementation(() => ({ + on: jest.fn(), + get: jest.fn(async (key) => store.get(key) || null), + set: jest.fn(async (key, val) => { store.set(key, val); return 'OK'; }), + setex: jest.fn(async (key, ttl, val) => { store.set(key, val); return 'OK'; }), + del: jest.fn(async (...keys) => { + let count = 0; + keys.forEach(k => { if (store.delete(k)) count++; }); + return count; + }), + keys: jest.fn(async (pattern) => { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + return Array.from(store.keys()).filter(k => regex.test(k)); + }), + quit: jest.fn(async () => 'OK'), + })); + return Redis; +}); + +describe('CacheService', () => { + let cache; + + beforeEach(() => { + cache = new CacheService('redis://localhost:6379'); + cache.connected = true; + }); + + describe('buildKey', () => { + test('builds key with entity, id, and field', () => { + expect(cache.buildKey('meter', 'device123', 'lastReading')) + .toBe('equipchain:meter:device123:lastReading'); + }); + + test('builds key without field', () => { + expect(cache.buildKey('meter', 'device123')) + .toBe('equipchain:meter:device123'); + }); + }); + + describe('get', () => { + test('returns null on miss', async () => { + const result = await cache.get('nonexistent'); + expect(result).toBeNull(); + expect(cache.stats.misses).toBe(1); + }); + + test('returns parsed JSON on hit', async () => { + await cache.set('test:key', { value: 42 }, 60); + const result = await cache.get('test:key'); + expect(result).toEqual({ value: 42 }); + expect(cache.stats.hits).toBe(1); + }); + }); + + describe('set', () => { + test('stores JSON-serialized value', async () => { + const obj = { id: 'meter-001', reading: 1234.56 }; + const result = await cache.set('test:set', obj, 30); + expect(result).toBe(true); + expect(cache.stats.sets).toBe(1); + }); + + test('stores with TTL via setex', async () => { + await cache.set('test:ttl', 'hello', 60); + expect(cache.client.setex).toHaveBeenCalled(); + }); + }); + + describe('del', () => { + test('deletes a key', async () => { + await cache.set('test:del', 'value', 60); + const result = await cache.del('test:del'); + expect(result).toBe(true); + }); + }); + + describe('flush', () => { + test('flushes keys matching pattern', async () => { + await cache.set('equipchain:meter:001:reading', 100, 60); + await cache.set('equipchain:meter:002:reading', 200, 60); + await cache.set('other:key', 'value', 60); + + const deleted = await cache.flush('equipchain:meter:*'); + expect(deleted).toBe(2); + }); + }); + + describe('invalidate', () => { + test('invalidates all keys for an entity', async () => { + await cache.set('equipchain:meter:001:reading', 100, 60); + await cache.set('equipchain:meter:001:status', 'online', 60); + await cache.set('equipchain:meter:002:reading', 200, 60); + + const deleted = await cache.invalidate('meter', '001'); + expect(deleted).toBe(2); + }); + }); + + describe('warm', () => { + test('warms cache from fetcher functions', async () => { + const entries = [ + { key: 'warm:1', fetcher: async () => ({ data: 'hot' }), ttl: 60 }, + { key: 'warm:2', fetcher: async () => ({ data: 'cold' }), ttl: 60 }, + ]; + await cache.warm(entries); + const result = await cache.get('warm:1'); + expect(result).toEqual({ data: 'hot' }); + }); + }); + + describe('health', () => { + test('reports connection status and stats', () => { + cache.stats = { hits: 10, misses: 5, errors: 0, sets: 10, deletes: 2 }; + const h = cache.health(); + expect(h.connected).toBe(true); + expect(h.stats.hits).toBe(10); + expect(h.hitRate).toBe('66.67%'); + }); + }); + + describe('graceful fallback', () => { + test('returns null when disconnected', async () => { + cache.connected = false; + const result = await cache.get('any:key'); + expect(result).toBeNull(); + }); + + test('set returns false when disconnected', async () => { + cache.connected = false; + const result = await cache.set('any:key', 'value', 60); + expect(result).toBe(false); + }); + }); +}); + +describe('TTL_PRESETS', () => { + test('has correct preset values', () => { + expect(TTL_PRESETS.meter).toBe(60); + expect(TTL_PRESETS.config).toBe(300); + expect(TTL_PRESETS.analytics).toBe(120); + expect(TTL_PRESETS.default).toBe(60); + }); +});