Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,11 @@ FRONTEND_URL=http://localhost:3000
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_CALLBACK_URL=http://localhost:3002/auth/google/callback
GOOGLE_CALLBACK_URL=http://localhost:3002/auth/google/callback

# XLM Price Feed Configuration
# Options: "coingecko" (default) or "custom"
XLM_PRICE_API_SOURCE=coingecko
# Required when XLM_PRICE_API_SOURCE=custom
# Example: https://api.example.com/xlm-price
XLM_PRICE_API_URL=
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ConferenceGalleryModule } from "./conference-gallery/conference-gallery
import { JwtStrategy } from "../security/strategies/jwt.strategy";
import { PromoCodeModule } from './promo-code/promo-code.module';
import { WebhookModule } from "./webhook/webhook.module";
import { StellarModule } from "./stellar/stellar.module";
@Module({
imports: [
ConfigModule.forRoot({
Expand Down Expand Up @@ -76,6 +77,7 @@ import { WebhookModule } from "./webhook/webhook.module";
ConferenceGalleryModule,
PromoCodeModule,
AuditLogModule,
StellarModule,
],
controllers: [AppController],
providers: [AppService, PdfService, JwtStrategy],
Expand Down
291 changes: 291 additions & 0 deletions src/stellar/price.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { of, throwError } from "rxjs";
import { PriceService } from "./price.service";
import { ServiceUnavailableException } from "@nestjs/common";

describe("PriceService", () => {
let service: PriceService;
let httpService: HttpService;
let cacheManager: Cache;
let configService: ConfigService;

const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};

const mockConfigService = {
get: jest.fn(),
};

const mockHttpService = {
get: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PriceService,
{
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: HttpService,
useValue: mockHttpService,
},
],
}).compile();

service = module.get<PriceService>(PriceService);
httpService = module.get<HttpService>(HttpService);
cacheManager = module.get<Cache>(CACHE_MANAGER);
configService = module.get<ConfigService>(ConfigService);

jest.clearAllMocks();
});

describe("getXLMUSDRate", () => {
it("should return cached rate when available (cache hit)", async () => {
const cachedRate = 0.1234;
mockCacheManager.get.mockResolvedValue(cachedRate);

const result = await service.getXLMUSDRate();

expect(result).toBe(cachedRate);
expect(mockCacheManager.get).toHaveBeenCalledWith("xlm_usd_rate");
expect(mockHttpService.get).not.toHaveBeenCalled();
});

it("should fetch from CoinGecko API on cache miss and cache the result", async () => {
const apiRate = 0.15;
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockReturnValue("coingecko");
mockHttpService.get.mockReturnValue(
of({
data: {
stellar: {
usd: apiRate,
},
},
}),
);

const result = await service.getXLMUSDRate();

expect(result).toBe(apiRate);
expect(mockCacheManager.get).toHaveBeenCalledWith("xlm_usd_rate");
expect(mockHttpService.get).toHaveBeenCalledWith(
"https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd",
);
expect(mockCacheManager.set).toHaveBeenCalledWith(
"xlm_usd_rate",
apiRate,
60000,
);
});

it("should throw ServiceUnavailableException when API fails", async () => {
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockReturnValue("coingecko");
mockHttpService.get.mockReturnValue(
throwError(() => new Error("Network error")),
);

await expect(service.getXLMUSDRate()).rejects.toThrow(
ServiceUnavailableException,
);
});

it("should throw ServiceUnavailableException when API returns invalid rate", async () => {
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockReturnValue("coingecko");
mockHttpService.get.mockReturnValue(
of({
data: {
stellar: {
usd: -1,
},
},
}),
);

await expect(service.getXLMUSDRate()).rejects.toThrow(
ServiceUnavailableException,
);
});

it("should use custom API when configured", async () => {
const customUrl = "https://custom.api.com/xlm-price";
const apiRate = 0.2;
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockImplementation((key: string) => {
if (key === "XLM_PRICE_API_SOURCE") return "custom";
if (key === "XLM_PRICE_API_URL") return customUrl;
return undefined;
});
mockHttpService.get.mockReturnValue(
of({
data: { price: apiRate },
}),
);

const result = await service.getXLMUSDRate();

expect(result).toBe(apiRate);
expect(mockHttpService.get).toHaveBeenCalledWith(customUrl);
});

it("should throw error when custom API URL is not configured", async () => {
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockImplementation((key: string) => {
if (key === "XLM_PRICE_API_SOURCE") return "custom";
if (key === "XLM_PRICE_API_URL") return undefined;
return undefined;
});

await expect(service.getXLMUSDRate()).rejects.toThrow(
ServiceUnavailableException,
);
});
});

describe("convertUSDToXLM", () => {
it("should convert USD to XLM and round up to 7 decimal places", async () => {
const usdAmount = 100;
const rate = 0.1; // 1 XLM = $0.10, so $100 = 1000 XLM
mockCacheManager.get.mockResolvedValue(rate);

const result = await service.convertUSDToXLM(usdAmount);

expect(result).toBe(1000);
});

it("should round up to avoid underpayment (ceiling)", async () => {
const usdAmount = 1;
const rate = 0.3; // 1 XLM = $0.30, so $1 = 3.3333333... XLM
mockCacheManager.get.mockResolvedValue(rate);

const result = await service.convertUSDToXLM(usdAmount);

// Should round UP to 3.3333334
expect(result).toBe(3.3333334);
});

it("should return 0 when USD amount is 0", async () => {
const result = await service.convertUSDToXLM(0);

expect(result).toBe(0);
expect(mockCacheManager.get).not.toHaveBeenCalled();
});

it("should throw error when USD amount is negative", async () => {
await expect(service.convertUSDToXLM(-100)).rejects.toThrow(
"USD amount cannot be negative",
);
});

it("should handle small amounts with precision", async () => {
const usdAmount = 0.01;
const rate = 0.1; // 1 XLM = $0.10, so $0.01 = 0.1 XLM
mockCacheManager.get.mockResolvedValue(rate);

const result = await service.convertUSDToXLM(usdAmount);

expect(result).toBe(0.1);
});

it("should propagate ServiceUnavailableException when price feed fails", async () => {
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockReturnValue("coingecko");
mockHttpService.get.mockReturnValue(
throwError(() => new Error("Network error")),
);

await expect(service.convertUSDToXLM(100)).rejects.toThrow(
ServiceUnavailableException,
);
});
});

describe("Custom API response formats", () => {
beforeEach(() => {
mockCacheManager.get.mockResolvedValue(null);
mockConfigService.get.mockImplementation((key: string) => {
if (key === "XLM_PRICE_API_SOURCE") return "custom";
if (key === "XLM_PRICE_API_URL") return "https://custom.api.com/price";
return undefined;
});
});

it("should handle 'rate' field in custom API response", async () => {
mockHttpService.get.mockReturnValue(
of({ data: { rate: 0.25 } }),
);

const result = await service.getXLMUSDRate();

expect(result).toBe(0.25);
});

it("should handle 'usd' field in custom API response", async () => {
mockHttpService.get.mockReturnValue(
of({ data: { usd: 0.35 } }),
);

const result = await service.getXLMUSDRate();

expect(result).toBe(0.35);
});

it("should handle 'value' field in custom API response", async () => {
mockHttpService.get.mockReturnValue(
of({ data: { value: 0.45 } }),
);

const result = await service.getXLMUSDRate();

expect(result).toBe(0.45);
});

it("should handle plain number response", async () => {
mockHttpService.get.mockReturnValue(of({ data: 0.55 }));

const result = await service.getXLMUSDRate();

expect(result).toBe(0.55);
});

it("should handle plain string response", async () => {
mockHttpService.get.mockReturnValue(of({ data: "0.65" }));

const result = await service.getXLMUSDRate();

expect(result).toBe(0.65);
});

it("should throw error for invalid response format", async () => {
mockHttpService.get.mockReturnValue(of({ data: null }));

await expect(service.getXLMUSDRate()).rejects.toThrow(
ServiceUnavailableException,
);
});

it("should throw error for NaN in response", async () => {
mockHttpService.get.mockReturnValue(of({ data: { price: "invalid" } }));

await expect(service.getXLMUSDRate()).rejects.toThrow(
ServiceUnavailableException,
);
});
});
});
Loading