diff --git a/README.md b/README.md index 91d7ed3..b1f3c5b 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,15 @@ bun install ## Compile and run the project -```bash -# development with docker -$ bun start -``` +### docker dev + +- `bun start` to start all the services in a docker compose environment +- `bun stop` to shutdown all the services run via docker compose -## Compile and run individual services +## individual services ```bash -# development with docker +# general pattern to run an app $ bun dev # for example, to run the swap microservice @@ -34,6 +34,13 @@ $ bun dev api # unit tests $ bun test +# target a specific test +$ bun test + +# watch for changes and re-run tests +$ bun test:watch +$ bun test:watch + # e2e tests $ bun test:e2e diff --git a/apps/api/src/swap/swap.service.spec.ts b/apps/api/src/swap/swap.service.spec.ts index a6b3ee5..6403dac 100644 --- a/apps/api/src/swap/swap.service.spec.ts +++ b/apps/api/src/swap/swap.service.spec.ts @@ -55,6 +55,20 @@ describe('SwapService', () => { status: 'PENDING', }; }), + listSwaps: jest.fn().mockImplementation(async () => { + return { + swaps: [ + { + id: mock_id, + rate: mock_rate.toString(), + status: 'PENDING', + }, + ], + page: 0, + size: 10, + pages: 2, + }; + }), }; }); @@ -131,7 +145,12 @@ describe('SwapService', () => { describe('getOnrampTransactions', () => { it('should return status 200', () => { - expect(swapService.getOnrampTransactions()).toEqual({ status: 200 }); + expect( + swapService.getOnrampTransactions({ + page: 0, + size: 10, + }), + ).toBeDefined(); }); }); diff --git a/apps/swap/src/events.controller.spec.ts b/apps/swap/src/events.controller.spec.ts index 05af5fb..9a22b3a 100644 --- a/apps/swap/src/events.controller.spec.ts +++ b/apps/swap/src/events.controller.spec.ts @@ -18,7 +18,7 @@ describe('EventsController', () => { { provide: SwapService, useValue: { - processSwapUpdate: jest.fn(), //.mockImplementation((data: MpesaTransactionUpdateDto) => {}), + processSwapUpdate: jest.fn(), }, }, ], diff --git a/apps/swap/src/fx/fx.service.spec.ts b/apps/swap/src/fx/fx.service.spec.ts index 314574e..a27de6d 100644 --- a/apps/swap/src/fx/fx.service.spec.ts +++ b/apps/swap/src/fx/fx.service.spec.ts @@ -1,4 +1,4 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { TestingModule } from '@nestjs/testing'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { createTestingModuleWithValidation } from '@bitsacco/common'; import { CacheModule } from '@nestjs/cache-manager'; diff --git a/apps/swap/src/fx/fx.service.ts b/apps/swap/src/fx/fx.service.ts index 04e50e3..93ec113 100644 --- a/apps/swap/src/fx/fx.service.ts +++ b/apps/swap/src/fx/fx.service.ts @@ -1,10 +1,10 @@ +import { AxiosError } from 'axios'; import { firstValueFrom, catchError } from 'rxjs'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import type { Cache } from 'cache-manager'; -import { AxiosError } from 'axios'; +import { CustomStore } from '@bitsacco/common'; interface CurrencyApiResponse { meta: { @@ -27,13 +27,15 @@ export class FxService { constructor( private readonly configService: ConfigService, private readonly httpService: HttpService, - @Inject(CACHE_MANAGER) private cacheManager: Cache, + @Inject(CACHE_MANAGER) private cacheManager: CustomStore, ) { this.logger.log('FxService initialized'); } private async getCurrencyApiRates() { - const cachedData = await this.cacheManager.get(this.CACHE_KEY); + const cachedData = await this.cacheManager.get<{ + btcToKesRate: string; + } | void>(this.CACHE_KEY); if (cachedData) { this.logger.log('Returning cached currency rates'); return cachedData; diff --git a/apps/swap/src/swap.controller.spec.ts b/apps/swap/src/swap.controller.spec.ts index 1d53704..ed27313 100644 --- a/apps/swap/src/swap.controller.spec.ts +++ b/apps/swap/src/swap.controller.spec.ts @@ -1,8 +1,11 @@ -import { Currency, createTestingModuleWithValidation } from '@bitsacco/common'; +import { + CreateOnrampSwapDto, + Currency, + createTestingModuleWithValidation, +} from '@bitsacco/common'; import { TestingModule } from '@nestjs/testing'; import { SwapService } from './swap.service'; import { SwapController } from './swap.controller'; -import { CreateOnrampSwapDto } from './dto'; describe('SwapController', () => { let swapController: SwapController; diff --git a/apps/swap/src/swap.module.ts b/apps/swap/src/swap.module.ts index 960bd27..51d752d 100644 --- a/apps/swap/src/swap.module.ts +++ b/apps/swap/src/swap.module.ts @@ -1,9 +1,10 @@ import * as Joi from 'joi'; +import { redisStore } from 'cache-manager-redis-store'; import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { CACHE_MANAGER, CacheModule, CacheStore } from '@nestjs/cache-manager'; -import { LoggerModule } from '@bitsacco/common'; +import { CACHE_MANAGER, CacheModule } from '@nestjs/cache-manager'; +import { CustomStore, LoggerModule } from '@bitsacco/common'; import { SwapController } from './swap.controller'; import { SwapService } from './swap.service'; import { FxService } from './fx/fx.service'; @@ -11,7 +12,6 @@ import { PrismaService } from './prisma.service'; import { IntasendService } from './intasend/intasend.service'; import { EventsController } from './events.controller'; import { FedimintService } from './fedimint/fedimint.service'; -import { RedisStore, redisStore } from 'cache-manager-redis-store'; @Module({ imports: [ @@ -48,7 +48,7 @@ import { RedisStore, redisStore } from 'cache-manager-redis-store'; }); return { - store: store as unknown as CacheStore, + store: new CustomStore(store, undefined /* TODO: inject logger */), ttl: 60 * 60 * 5, // 5 hours }; }, @@ -64,7 +64,7 @@ import { RedisStore, redisStore } from 'cache-manager-redis-store'; intasendService: IntasendService, fedimintService: FedimintService, prismaService: PrismaService, - cacheManager: RedisStore, + cacheManager: CustomStore, ) => { return new SwapService( fxService, diff --git a/apps/swap/src/swap.service.spec.ts b/apps/swap/src/swap.service.spec.ts index d5b8813..5109ff6 100644 --- a/apps/swap/src/swap.service.spec.ts +++ b/apps/swap/src/swap.service.spec.ts @@ -1,5 +1,6 @@ import { btcFromKes, + CreateOnrampSwapDto, createTestingModuleWithValidation, Currency, SwapStatus, @@ -11,8 +12,8 @@ import { FxService } from './fx/fx.service'; import { SwapService } from './swap.service'; import { IntasendService } from './intasend/intasend.service'; import { MpesaTractactionState } from './intasend/intasend.types'; -import { CreateOnrampSwapDto, MpesaTransactionUpdateDto } from './dto'; import { FedimintService } from './fedimint/fedimint.service'; +import { MpesaTransactionUpdateDto } from './dto'; const mock_rate = 8708520.117232416; @@ -24,13 +25,11 @@ describe('SwapService', () => { let mockCacheManager: { get: jest.Mock; set: jest.Mock; - getOrThrow: jest.Mock; }; beforeEach(async () => { mockCacheManager = { get: jest.fn(), - getOrThrow: jest.fn(), set: jest.fn(), }; @@ -154,7 +153,7 @@ describe('SwapService', () => { ref: 'test-onramp-swap', }; - (mockCacheManager.getOrThrow as jest.Mock).mockImplementation( + (mockCacheManager.get as jest.Mock).mockImplementation( (_key: string) => cache, ); @@ -215,7 +214,7 @@ describe('SwapService', () => { state: MpesaTractactionState.Pending, }; - (mockCacheManager.getOrThrow as jest.Mock).mockImplementation( + (mockCacheManager.get as jest.Mock).mockImplementation( (_key: string) => cache, ); @@ -226,7 +225,7 @@ describe('SwapService', () => { expect(swap).toBeDefined(); expect(swap.rate).toEqual(cache.rate); expect(swap.status).toEqual(SwapStatus.PENDING); - expect(mockCacheManager.getOrThrow).toHaveBeenCalled(); + expect(mockCacheManager.get).toHaveBeenCalled(); }); }); @@ -255,7 +254,7 @@ describe('SwapService', () => { }; it('creates a new swap tx if there was none recorded before', async () => { - (mockCacheManager.getOrThrow as jest.Mock).mockImplementation( + (mockCacheManager.get as jest.Mock).mockImplementation( (_key: string) => ({ lightning: 'lnbtcexampleinvoicee', phone: '0700000000', diff --git a/apps/swap/src/swap.service.ts b/apps/swap/src/swap.service.ts index c265a8b..0b85a7e 100644 --- a/apps/swap/src/swap.service.ts +++ b/apps/swap/src/swap.service.ts @@ -9,8 +9,7 @@ import { SwapStatus, CreateOnrampSwapDto, FindSwapDto, - cacheSetOrThrow, - cacheGetOrThrow, + CustomStore, } from '@bitsacco/common'; import { v4 as uuidv4 } from 'uuid'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; @@ -22,7 +21,6 @@ import { IntasendService } from './intasend/intasend.service'; import { MpesaTransactionUpdateDto } from './dto'; import { MpesaTractactionState } from './intasend/intasend.types'; import { FedimintService } from './fedimint/fedimint.service'; -import { RedisStore } from 'cache-manager-redis-store'; @Injectable() export class SwapService { @@ -34,7 +32,7 @@ export class SwapService { private readonly intasendService: IntasendService, private readonly fedimintService: FedimintService, private readonly prismaService: PrismaService, - @Inject(CACHE_MANAGER) private readonly cacheManager: RedisStore, + @Inject(CACHE_MANAGER) private readonly cacheManager: CustomStore, ) { this.logger.log('SwapService initialized'); } @@ -60,13 +58,7 @@ export class SwapService { expiry: expiry.toString(), }; - await cacheSetOrThrow( - quote.id, - quote, - this.CACHE_TTL_SECS, - this.cacheManager, - this.logger, - ); + await this.cacheManager.set(quote.id, quote, this.CACHE_TTL_SECS); return quote; } catch (error) { @@ -83,8 +75,7 @@ export class SwapService { lightning, }: CreateOnrampSwapDto): Promise { let currentQuote: QuoteResponse | undefined = - quote && - (await cacheGetOrThrow(quote.id, this.cacheManager, this.logger)); + quote && (await this.cacheManager.get(quote.id)); if ( !currentQuote || @@ -108,7 +99,7 @@ export class SwapService { // We record stk push response to a temporary cache // so we can track status of the swap later // NOTE: we use mpesa ids as cache keys - await cacheSetOrThrow( + this.cacheManager.set( mpesa.id, { lightning, @@ -119,8 +110,6 @@ export class SwapService { ref, }, this.CACHE_TTL_SECS, - this.cacheManager, - this.logger, ); const swap = await this.prismaService.mpesaOnrampSwap.create({ @@ -165,11 +154,7 @@ export class SwapService { // FIXME! // Look up stk push response in cache // NOTE: we use mpesa ids as cache keys - const stk: STKPushCache = await cacheGetOrThrow( - id, - this.cacheManager, - this.logger, - ); + const stk: STKPushCache = await this.cacheManager.get(id); resp = { id, @@ -226,10 +211,8 @@ export class SwapService { }); } catch { // look up mpesa tx in cache - const stk: STKPushCache = await cacheGetOrThrow( + const stk: STKPushCache = await this.cacheManager.get( mpesa.id, - this.cacheManager, - this.logger, ); // record a new swap in db diff --git a/libs/common/src/utils/cache.spec.ts b/libs/common/src/utils/cache.spec.ts index 3038d02..e50c3cd 100644 --- a/libs/common/src/utils/cache.spec.ts +++ b/libs/common/src/utils/cache.spec.ts @@ -1,6 +1,5 @@ import { Logger } from '@nestjs/common'; -import { RedisStore } from 'cache-manager-redis-store'; -import { cacheGetOrThrow, cacheSetOrThrow } from './cache'; +import { cacheGetOrThrow, cacheSetOrThrow, RedisStore } from './cache'; describe('Cache Utils', () => { let mockCache: jest.Mocked; diff --git a/libs/common/src/utils/cache.ts b/libs/common/src/utils/cache.ts index 0cf189c..90175f1 100644 --- a/libs/common/src/utils/cache.ts +++ b/libs/common/src/utils/cache.ts @@ -1,5 +1,57 @@ +import type { Store } from 'cache-manager'; +import type { RedisClientType } from 'redis'; +import { CacheStore } from '@nestjs/cache-manager'; import { Logger } from '@nestjs/common'; -import { RedisStore } from 'cache-manager-redis-store'; + +export interface RedisStore extends Store { + name: string; + getClient: () => RedisClientType; + isCacheableValue: any; + set: (key: any, value: any, options: any, cb: any) => Promise; + get: (key: any, options: any, cb: any) => Promise; + del: (...args: any[]) => Promise; + mset: (...args: any[]) => Promise; + mget: (...args: any[]) => Promise; + mdel: (...args: any[]) => Promise; + reset: (cb: any) => Promise; + keys: (pattern: string, cb: any) => Promise; + ttl: (key: any, cb: any) => Promise; +} + +/** + * Adapt `RedisStore` to `CacheStore` interface + * Nest has a generic CacheStore interface to abstract cache implementations + * This is a workaround to make it work with RedisStore + * and cache-manager v4.1.0 + * @see https://docs.nestjs.com/techniques/caching + * @see https://www.npmjs.com/package/cache-manager/v/4.1.0 + */ +export class CustomStore implements CacheStore { + constructor( + private readonly cache: RedisStore, + private readonly logger?: Logger, + ) {} + + async get(key: string): Promise { + return cacheGetOrThrow(key, this.cache, this.logger); + } + + async set(key: string, value: T, ttl: number): Promise { + return cacheSetOrThrow(key, value, ttl, this.cache, this.logger); + } + + async del(key: string): Promise { + return new Promise((resolve, reject) => { + this.cache.del(key, (err) => { + if (err) { + this.logger?.error(err); + return reject(err); + } + return resolve(); + }); + }); + } +} export async function cacheGetOrThrow( key: string, diff --git a/libs/common/src/utils/index.ts b/libs/common/src/utils/index.ts index 8e1ac76..cfea0a5 100644 --- a/libs/common/src/utils/index.ts +++ b/libs/common/src/utils/index.ts @@ -1,3 +1,3 @@ export * from './currency'; export * from './testing'; -export * from './cache'; +export { CustomStore } from './cache'; diff --git a/package.json b/package.json index 86e6ac1..ae18c67 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "license": "MIT", "scripts": { "start": "docker compose -p os up", + "stop": "docker compose -p os down", "dev": "nest start --watch", "debug": "nest start --debug --watch", "build": "nest build",