servcraft 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +29 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/README.md +1070 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
package/src/utils/errors.ts
CHANGED
|
@@ -22,42 +22,49 @@ export class AppError extends Error {
|
|
|
22
22
|
export class NotFoundError extends AppError {
|
|
23
23
|
constructor(resource = 'Resource') {
|
|
24
24
|
super(`${resource} not found`, 404);
|
|
25
|
+
Object.setPrototypeOf(this, NotFoundError.prototype);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export class UnauthorizedError extends AppError {
|
|
29
30
|
constructor(message = 'Unauthorized') {
|
|
30
31
|
super(message, 401);
|
|
32
|
+
Object.setPrototypeOf(this, UnauthorizedError.prototype);
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export class ForbiddenError extends AppError {
|
|
35
37
|
constructor(message = 'Forbidden') {
|
|
36
38
|
super(message, 403);
|
|
39
|
+
Object.setPrototypeOf(this, ForbiddenError.prototype);
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export class BadRequestError extends AppError {
|
|
41
44
|
constructor(message = 'Bad request', errors?: Record<string, string[]>) {
|
|
42
45
|
super(message, 400, true, errors);
|
|
46
|
+
Object.setPrototypeOf(this, BadRequestError.prototype);
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export class ConflictError extends AppError {
|
|
47
51
|
constructor(message = 'Resource already exists') {
|
|
48
52
|
super(message, 409);
|
|
53
|
+
Object.setPrototypeOf(this, ConflictError.prototype);
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export class ValidationError extends AppError {
|
|
53
58
|
constructor(errors: Record<string, string[]>) {
|
|
54
59
|
super('Validation failed', 422, true, errors);
|
|
60
|
+
Object.setPrototypeOf(this, ValidationError.prototype);
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export class TooManyRequestsError extends AppError {
|
|
59
65
|
constructor(message = 'Too many requests') {
|
|
60
66
|
super(message, 429);
|
|
67
|
+
Object.setPrototypeOf(this, TooManyRequestsError.prototype);
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
package/src/utils/pagination.ts
CHANGED
|
@@ -6,7 +6,10 @@ export const MAX_LIMIT = 100;
|
|
|
6
6
|
|
|
7
7
|
export function parsePaginationParams(query: Record<string, unknown>): PaginationParams {
|
|
8
8
|
const page = Math.max(1, parseInt(String(query.page || DEFAULT_PAGE), 10));
|
|
9
|
-
const limit = Math.min(
|
|
9
|
+
const limit = Math.min(
|
|
10
|
+
MAX_LIMIT,
|
|
11
|
+
Math.max(1, parseInt(String(query.limit || DEFAULT_LIMIT), 10))
|
|
12
|
+
);
|
|
10
13
|
const sortBy = typeof query.sortBy === 'string' ? query.sortBy : undefined;
|
|
11
14
|
const sortOrder = query.sortOrder === 'desc' ? 'desc' : 'asc';
|
|
12
15
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database availability check helpers for integration tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe } from 'vitest';
|
|
5
|
+
|
|
6
|
+
let _dbAvailable: boolean | null = null;
|
|
7
|
+
let _redisAvailable: boolean | null = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if PostgreSQL database is available
|
|
11
|
+
*/
|
|
12
|
+
export async function isDatabaseAvailable(): Promise<boolean> {
|
|
13
|
+
if (_dbAvailable !== null) return _dbAvailable;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Dynamic import to avoid initialization errors
|
|
17
|
+
const { prisma } = await import('../../src/database/prisma.js');
|
|
18
|
+
await prisma.$queryRaw`SELECT 1`;
|
|
19
|
+
_dbAvailable = true;
|
|
20
|
+
} catch {
|
|
21
|
+
_dbAvailable = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return _dbAvailable;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if Redis is available
|
|
29
|
+
*/
|
|
30
|
+
export async function isRedisAvailable(): Promise<boolean> {
|
|
31
|
+
if (_redisAvailable !== null) return _redisAvailable;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const { getRedis } = await import('../../src/database/redis.js');
|
|
35
|
+
const redis = getRedis();
|
|
36
|
+
await redis.ping();
|
|
37
|
+
_redisAvailable = true;
|
|
38
|
+
} catch {
|
|
39
|
+
_redisAvailable = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return _redisAvailable;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Conditionally run describe block based on database availability
|
|
47
|
+
* If database is not available, tests are skipped
|
|
48
|
+
*/
|
|
49
|
+
export function describeWithDb(
|
|
50
|
+
name: string,
|
|
51
|
+
fn: () => void
|
|
52
|
+
): ReturnType<typeof describe> | ReturnType<typeof describe.skip> {
|
|
53
|
+
// We need to check synchronously for Vitest
|
|
54
|
+
// Use environment variable to indicate DB availability
|
|
55
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
56
|
+
const hasValidDbUrl = dbUrl && !dbUrl.includes('test:test@localhost');
|
|
57
|
+
|
|
58
|
+
if (process.env.SKIP_DB_TESTS === 'true' || !hasValidDbUrl) {
|
|
59
|
+
return describe.skip(name, fn);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return describe(name, fn);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Conditionally run describe block based on Redis availability
|
|
67
|
+
*/
|
|
68
|
+
export function describeWithRedis(
|
|
69
|
+
name: string,
|
|
70
|
+
fn: () => void
|
|
71
|
+
): ReturnType<typeof describe> | ReturnType<typeof describe.skip> {
|
|
72
|
+
const redisUrl = process.env.REDIS_URL;
|
|
73
|
+
|
|
74
|
+
if (process.env.SKIP_REDIS_TESTS === 'true' || !redisUrl) {
|
|
75
|
+
return describe.skip(name, fn);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return describe(name, fn);
|
|
79
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import Redis from 'ioredis';
|
|
3
|
+
|
|
4
|
+
// These tests verify the Redis token blacklist functionality
|
|
5
|
+
// The @fastify/jwt integration is tested separately when Fastify 5.x is available
|
|
6
|
+
|
|
7
|
+
describe('Auth Service - Redis Token Blacklist Integration', () => {
|
|
8
|
+
let redis: Redis;
|
|
9
|
+
const testRedisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
// Setup Redis client for verification
|
|
13
|
+
redis = new Redis(testRedisUrl);
|
|
14
|
+
|
|
15
|
+
// Wait for Redis connection
|
|
16
|
+
await new Promise<void>((resolve, reject) => {
|
|
17
|
+
const timeout = setTimeout(() => reject(new Error('Redis connection timeout')), 5000);
|
|
18
|
+
redis.once('ready', () => {
|
|
19
|
+
clearTimeout(timeout);
|
|
20
|
+
resolve();
|
|
21
|
+
});
|
|
22
|
+
redis.once('error', (err) => {
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
reject(err);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
if (redis) {
|
|
31
|
+
await redis.quit();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
// Clean up test keys before each test
|
|
37
|
+
const keys = await redis.keys('auth:blacklist:*');
|
|
38
|
+
if (keys.length > 0) {
|
|
39
|
+
await redis.del(...keys);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('Redis Connection', () => {
|
|
44
|
+
it('should connect to Redis', async () => {
|
|
45
|
+
const pong = await redis.ping();
|
|
46
|
+
expect(pong).toBe('PONG');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should set and get values', async () => {
|
|
50
|
+
await redis.set('test:key', 'test-value');
|
|
51
|
+
const value = await redis.get('test:key');
|
|
52
|
+
expect(value).toBe('test-value');
|
|
53
|
+
await redis.del('test:key');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Token Blacklist Operations', () => {
|
|
58
|
+
it('should blacklist a token', async () => {
|
|
59
|
+
const token = 'test-token-123';
|
|
60
|
+
const ttl = 3600; // 1 hour
|
|
61
|
+
|
|
62
|
+
await redis.setex(`auth:blacklist:${token}`, ttl, '1');
|
|
63
|
+
|
|
64
|
+
const exists = await redis.exists(`auth:blacklist:${token}`);
|
|
65
|
+
expect(exists).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should check if token is blacklisted', async () => {
|
|
69
|
+
const token = 'blacklisted-token';
|
|
70
|
+
await redis.setex(`auth:blacklist:${token}`, 3600, '1');
|
|
71
|
+
|
|
72
|
+
const isBlacklisted = (await redis.exists(`auth:blacklist:${token}`)) === 1;
|
|
73
|
+
expect(isBlacklisted).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return false for non-blacklisted token', async () => {
|
|
77
|
+
const token = 'valid-token';
|
|
78
|
+
|
|
79
|
+
const isBlacklisted = (await redis.exists(`auth:blacklist:${token}`)) === 1;
|
|
80
|
+
expect(isBlacklisted).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should expire blacklisted tokens', async () => {
|
|
84
|
+
const token = 'expiring-token';
|
|
85
|
+
await redis.setex(`auth:blacklist:${token}`, 1, '1'); // 1 second TTL
|
|
86
|
+
|
|
87
|
+
// Wait for expiration
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
89
|
+
|
|
90
|
+
const exists = await redis.exists(`auth:blacklist:${token}`);
|
|
91
|
+
expect(exists).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import { CacheService } from '../../src/modules/cache/cache.service.js';
|
|
3
|
+
|
|
4
|
+
describe('CacheService - Redis Integration', () => {
|
|
5
|
+
let redisCache: CacheService;
|
|
6
|
+
let memoryCache: CacheService;
|
|
7
|
+
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
// Redis cache instance
|
|
10
|
+
redisCache = new CacheService({
|
|
11
|
+
provider: 'redis',
|
|
12
|
+
ttl: 60, // 1 minute for tests
|
|
13
|
+
prefix: 'test:',
|
|
14
|
+
redis: {
|
|
15
|
+
host: process.env.REDIS_HOST || 'localhost',
|
|
16
|
+
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
17
|
+
password: process.env.REDIS_PASSWORD,
|
|
18
|
+
db: 1, // Use DB 1 for tests
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Memory cache instance for comparison
|
|
23
|
+
memoryCache = new CacheService({
|
|
24
|
+
provider: 'memory',
|
|
25
|
+
ttl: 60,
|
|
26
|
+
prefix: 'test:',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await redisCache.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
// Clear cache before each test
|
|
36
|
+
await redisCache.clear();
|
|
37
|
+
await memoryCache.clear();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ==========================================
|
|
41
|
+
// BASIC OPERATIONS
|
|
42
|
+
// ==========================================
|
|
43
|
+
|
|
44
|
+
describe('Basic Operations', () => {
|
|
45
|
+
it('should set and get a string value', async () => {
|
|
46
|
+
await redisCache.set('test-string', 'Hello Redis!');
|
|
47
|
+
const value = await redisCache.get<string>('test-string');
|
|
48
|
+
expect(value).toBe('Hello Redis!');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should set and get a number value', async () => {
|
|
52
|
+
await redisCache.set('test-number', 42);
|
|
53
|
+
const value = await redisCache.get<number>('test-number');
|
|
54
|
+
expect(value).toBe(42);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should set and get an object value', async () => {
|
|
58
|
+
const obj = { name: 'John', age: 30, active: true };
|
|
59
|
+
await redisCache.set('test-object', obj);
|
|
60
|
+
const value = await redisCache.get<typeof obj>('test-object');
|
|
61
|
+
expect(value).toEqual(obj);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should set and get an array value', async () => {
|
|
65
|
+
const arr = [1, 2, 3, 4, 5];
|
|
66
|
+
await redisCache.set('test-array', arr);
|
|
67
|
+
const value = await redisCache.get<number[]>('test-array');
|
|
68
|
+
expect(value).toEqual(arr);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should return null for non-existent key', async () => {
|
|
72
|
+
const value = await redisCache.get('non-existent-key');
|
|
73
|
+
expect(value).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should delete a key', async () => {
|
|
77
|
+
await redisCache.set('test-delete', 'value');
|
|
78
|
+
const deleted = await redisCache.delete('test-delete');
|
|
79
|
+
expect(deleted).toBe(true);
|
|
80
|
+
|
|
81
|
+
const value = await redisCache.get('test-delete');
|
|
82
|
+
expect(value).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return false when deleting non-existent key', async () => {
|
|
86
|
+
const deleted = await redisCache.delete('non-existent');
|
|
87
|
+
expect(deleted).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should check if key exists', async () => {
|
|
91
|
+
await redisCache.set('test-exists', 'value');
|
|
92
|
+
const exists = await redisCache.exists('test-exists');
|
|
93
|
+
expect(exists).toBe(true);
|
|
94
|
+
|
|
95
|
+
const notExists = await redisCache.exists('not-exists');
|
|
96
|
+
expect(notExists).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ==========================================
|
|
101
|
+
// TTL AND EXPIRATION
|
|
102
|
+
// ==========================================
|
|
103
|
+
|
|
104
|
+
describe('TTL and Expiration', () => {
|
|
105
|
+
it('should expire key after TTL', async () => {
|
|
106
|
+
await redisCache.set('test-ttl', 'expires soon', { ttl: 1 }); // 1 second
|
|
107
|
+
|
|
108
|
+
const valueBefore = await redisCache.get('test-ttl');
|
|
109
|
+
expect(valueBefore).toBe('expires soon');
|
|
110
|
+
|
|
111
|
+
// Wait for expiration
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
113
|
+
|
|
114
|
+
const valueAfter = await redisCache.get('test-ttl');
|
|
115
|
+
expect(valueAfter).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should use custom TTL per key', async () => {
|
|
119
|
+
await redisCache.set('short-ttl', 'short', { ttl: 1 });
|
|
120
|
+
await redisCache.set('long-ttl', 'long', { ttl: 60 });
|
|
121
|
+
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
123
|
+
|
|
124
|
+
const shortValue = await redisCache.get('short-ttl');
|
|
125
|
+
const longValue = await redisCache.get('long-ttl');
|
|
126
|
+
|
|
127
|
+
expect(shortValue).toBeNull();
|
|
128
|
+
expect(longValue).toBe('long');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should use default TTL when not specified', async () => {
|
|
132
|
+
await redisCache.set('default-ttl', 'uses default');
|
|
133
|
+
const value = await redisCache.get('default-ttl');
|
|
134
|
+
expect(value).toBe('uses default');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ==========================================
|
|
139
|
+
// ADVANCED OPERATIONS
|
|
140
|
+
// ==========================================
|
|
141
|
+
|
|
142
|
+
describe('Advanced Operations', () => {
|
|
143
|
+
it('should implement getOrSet pattern', async () => {
|
|
144
|
+
let factoryCalled = 0;
|
|
145
|
+
const factory = async () => {
|
|
146
|
+
factoryCalled++;
|
|
147
|
+
return 'computed value';
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// First call - should execute factory
|
|
151
|
+
const value1 = await redisCache.getOrSet('getOrSet-key', factory);
|
|
152
|
+
expect(value1).toBe('computed value');
|
|
153
|
+
expect(factoryCalled).toBe(1);
|
|
154
|
+
|
|
155
|
+
// Second call - should use cache
|
|
156
|
+
const value2 = await redisCache.getOrSet('getOrSet-key', factory);
|
|
157
|
+
expect(value2).toBe('computed value');
|
|
158
|
+
expect(factoryCalled).toBe(1); // Factory not called again
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should get multiple keys (mget)', async () => {
|
|
162
|
+
await redisCache.set('mget-1', 'value1');
|
|
163
|
+
await redisCache.set('mget-2', 'value2');
|
|
164
|
+
await redisCache.set('mget-3', 'value3');
|
|
165
|
+
|
|
166
|
+
const values = await redisCache.mget<string>(['mget-1', 'mget-2', 'mget-3', 'mget-missing']);
|
|
167
|
+
expect(values).toEqual(['value1', 'value2', 'value3', null]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should set multiple keys (mset)', async () => {
|
|
171
|
+
await redisCache.mset([
|
|
172
|
+
{ key: 'mset-1', value: 'val1' },
|
|
173
|
+
{ key: 'mset-2', value: 'val2' },
|
|
174
|
+
{ key: 'mset-3', value: 'val3', ttl: 30 },
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const value1 = await redisCache.get('mset-1');
|
|
178
|
+
const value2 = await redisCache.get('mset-2');
|
|
179
|
+
const value3 = await redisCache.get('mset-3');
|
|
180
|
+
|
|
181
|
+
expect(value1).toBe('val1');
|
|
182
|
+
expect(value2).toBe('val2');
|
|
183
|
+
expect(value3).toBe('val3');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should increment a counter', async () => {
|
|
187
|
+
await redisCache.set('counter', 10);
|
|
188
|
+
|
|
189
|
+
const val1 = await redisCache.increment('counter');
|
|
190
|
+
expect(val1).toBe(11);
|
|
191
|
+
|
|
192
|
+
const val2 = await redisCache.increment('counter', 5);
|
|
193
|
+
expect(val2).toBe(16);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should increment from 0 if key does not exist', async () => {
|
|
197
|
+
const val = await redisCache.increment('new-counter');
|
|
198
|
+
expect(val).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should decrement a counter', async () => {
|
|
202
|
+
await redisCache.set('decrement-counter', 20);
|
|
203
|
+
|
|
204
|
+
const val1 = await redisCache.decrement('decrement-counter');
|
|
205
|
+
expect(val1).toBe(19);
|
|
206
|
+
|
|
207
|
+
const val2 = await redisCache.decrement('decrement-counter', 5);
|
|
208
|
+
expect(val2).toBe(14);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should clear all keys with prefix', async () => {
|
|
212
|
+
await redisCache.set('clear-1', 'val1');
|
|
213
|
+
await redisCache.set('clear-2', 'val2');
|
|
214
|
+
await redisCache.set('clear-3', 'val3');
|
|
215
|
+
|
|
216
|
+
await redisCache.clear();
|
|
217
|
+
|
|
218
|
+
const val1 = await redisCache.get('clear-1');
|
|
219
|
+
const val2 = await redisCache.get('clear-2');
|
|
220
|
+
const val3 = await redisCache.get('clear-3');
|
|
221
|
+
|
|
222
|
+
expect(val1).toBeNull();
|
|
223
|
+
expect(val2).toBeNull();
|
|
224
|
+
expect(val3).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ==========================================
|
|
229
|
+
// STATISTICS
|
|
230
|
+
// ==========================================
|
|
231
|
+
|
|
232
|
+
describe('Statistics', () => {
|
|
233
|
+
it('should track cache hits and misses', async () => {
|
|
234
|
+
const initialStats = redisCache.getStats();
|
|
235
|
+
const initialHits = initialStats.hits;
|
|
236
|
+
const initialMisses = initialStats.misses;
|
|
237
|
+
|
|
238
|
+
await redisCache.set('stats-key', 'value');
|
|
239
|
+
|
|
240
|
+
// Hit
|
|
241
|
+
await redisCache.get('stats-key');
|
|
242
|
+
|
|
243
|
+
// Miss
|
|
244
|
+
await redisCache.get('non-existent');
|
|
245
|
+
|
|
246
|
+
const finalStats = redisCache.getStats();
|
|
247
|
+
|
|
248
|
+
expect(finalStats.hits).toBeGreaterThan(initialHits);
|
|
249
|
+
expect(finalStats.misses).toBeGreaterThan(initialMisses);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ==========================================
|
|
254
|
+
// COMPARISON WITH MEMORY CACHE
|
|
255
|
+
// ==========================================
|
|
256
|
+
|
|
257
|
+
describe('Comparison with Memory Cache', () => {
|
|
258
|
+
it('should behave identically to memory cache for basic operations', async () => {
|
|
259
|
+
const testData = { name: 'Test', value: 123, active: true };
|
|
260
|
+
|
|
261
|
+
// Set in both
|
|
262
|
+
await redisCache.set('comparison-key', testData);
|
|
263
|
+
await memoryCache.set('comparison-key', testData);
|
|
264
|
+
|
|
265
|
+
// Get from both
|
|
266
|
+
const redisValue = await redisCache.get<typeof testData>('comparison-key');
|
|
267
|
+
const memoryValue = await memoryCache.get<typeof testData>('comparison-key');
|
|
268
|
+
|
|
269
|
+
expect(redisValue).toEqual(memoryValue);
|
|
270
|
+
expect(redisValue).toEqual(testData);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should handle increment identically', async () => {
|
|
274
|
+
await redisCache.set('redis-counter', 5);
|
|
275
|
+
await memoryCache.set('memory-counter', 5);
|
|
276
|
+
|
|
277
|
+
const redisVal = await redisCache.increment('redis-counter', 3);
|
|
278
|
+
const memoryVal = await memoryCache.increment('memory-counter', 3);
|
|
279
|
+
|
|
280
|
+
expect(redisVal).toBe(memoryVal);
|
|
281
|
+
expect(redisVal).toBe(8);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ==========================================
|
|
286
|
+
// ERROR HANDLING
|
|
287
|
+
// ==========================================
|
|
288
|
+
|
|
289
|
+
describe('Error Handling', () => {
|
|
290
|
+
it('should handle invalid JSON gracefully', async () => {
|
|
291
|
+
// This test verifies error handling
|
|
292
|
+
// Set a value and then try to get it
|
|
293
|
+
await redisCache.set('json-test', { valid: 'json' });
|
|
294
|
+
const value = await redisCache.get('json-test');
|
|
295
|
+
expect(value).toEqual({ valid: 'json' });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should handle concurrent operations', async () => {
|
|
299
|
+
const operations = [];
|
|
300
|
+
for (let i = 0; i < 10; i++) {
|
|
301
|
+
operations.push(redisCache.set(`concurrent-${i}`, `value-${i}`));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await Promise.all(operations);
|
|
305
|
+
|
|
306
|
+
const value5 = await redisCache.get('concurrent-5');
|
|
307
|
+
expect(value5).toBe('value-5');
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ==========================================
|
|
312
|
+
// PREFIX HANDLING
|
|
313
|
+
// ==========================================
|
|
314
|
+
|
|
315
|
+
describe('Prefix Handling', () => {
|
|
316
|
+
it('should apply prefix to all keys', async () => {
|
|
317
|
+
await redisCache.set('prefixed-key', 'value');
|
|
318
|
+
|
|
319
|
+
// The actual key in Redis should be "test:prefixed-key"
|
|
320
|
+
// We can't directly verify this without accessing Redis client
|
|
321
|
+
// But we can verify that get/delete work correctly
|
|
322
|
+
|
|
323
|
+
const value = await redisCache.get('prefixed-key');
|
|
324
|
+
expect(value).toBe('value');
|
|
325
|
+
|
|
326
|
+
const deleted = await redisCache.delete('prefixed-key');
|
|
327
|
+
expect(deleted).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ==========================================
|
|
332
|
+
// COMPLEX DATA TYPES
|
|
333
|
+
// ==========================================
|
|
334
|
+
|
|
335
|
+
describe('Complex Data Types', () => {
|
|
336
|
+
it('should handle nested objects', async () => {
|
|
337
|
+
const complex = {
|
|
338
|
+
user: {
|
|
339
|
+
name: 'John',
|
|
340
|
+
profile: {
|
|
341
|
+
age: 30,
|
|
342
|
+
settings: {
|
|
343
|
+
theme: 'dark',
|
|
344
|
+
notifications: true,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
metadata: {
|
|
349
|
+
created: new Date().toISOString(),
|
|
350
|
+
tags: ['admin', 'premium'],
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
await redisCache.set('complex-object', complex);
|
|
355
|
+
const value = await redisCache.get<typeof complex>('complex-object');
|
|
356
|
+
|
|
357
|
+
expect(value).toEqual(complex);
|
|
358
|
+
expect(value?.user.profile.settings.theme).toBe('dark');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should handle arrays of objects', async () => {
|
|
362
|
+
const users = [
|
|
363
|
+
{ id: 1, name: 'Alice' },
|
|
364
|
+
{ id: 2, name: 'Bob' },
|
|
365
|
+
{ id: 3, name: 'Charlie' },
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
await redisCache.set('users-array', users);
|
|
369
|
+
const value = await redisCache.get<typeof users>('users-array');
|
|
370
|
+
|
|
371
|
+
expect(value).toEqual(users);
|
|
372
|
+
expect(value?.length).toBe(3);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ==========================================
|
|
377
|
+
// CONNECTION RESILIENCE
|
|
378
|
+
// ==========================================
|
|
379
|
+
|
|
380
|
+
describe('Connection Resilience', () => {
|
|
381
|
+
it('should handle operations when Redis is available', async () => {
|
|
382
|
+
await redisCache.set('resilience-test', 'working');
|
|
383
|
+
const value = await redisCache.get('resilience-test');
|
|
384
|
+
expect(value).toBe('working');
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|