feat: add custom currencies (#182)

* feat: add custom currency table & endpoints

* refactor: fix migration versions

* feat: add custom currencies logic to fetch service

* feat: add custom currencies UI to admin settings

* feat: add custom currencies to user settings history

* chore: update admin settings help info

* refactor: use NcTextField instead of input
This commit is contained in:
2025-10-07 10:09:09 +03:00
committed by GitHub
parent 33be5cd1fa
commit 71577ffb35
18 changed files with 3705 additions and 100 deletions

View File

@@ -24,6 +24,7 @@ class {{pascalCase name}}Mapper extends QBMapper {
}
/**
* @param string $id
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
@@ -40,7 +41,6 @@ class {{pascalCase name}}Mapper extends QBMapper {
}
/**
* @param string $projectId
* @return array<{{pascalCase name}}>
*/
public function findAll(): array {

View File

@@ -12,6 +12,8 @@ use OCA\AutoCurrency\AppInfo;
use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
use OCA\AutoCurrency\Db\CospendProjectMapper;
use OCA\AutoCurrency\Db\CurrencyMapper;
use OCA\AutoCurrency\Db\CustomCurrency;
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
use OCA\AutoCurrency\Service\FetchCurrenciesService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -49,6 +51,7 @@ class ApiController extends OCSController {
private CurrencyMapper $currencyMapper,
private CospendProjectMapper $projectMapper,
private AutocurrencyRateHistoryMapper $historyMapper,
private CustomCurrencyMapper $customCurrencyMapper,
) {
parent::__construct($appName, $request);
$this->config = $config;
@@ -84,11 +87,11 @@ class ApiController extends OCSController {
* Get current user settings
*
* @return DataResponse<Http::STATUS_OK, array{
* supported_currencies: array{
* supported_currencies: list<array{
* code: string,
* symbol: string,
* name: string
* }
* }>
* }, array{}>
*
* 200: Data returned
@@ -96,12 +99,23 @@ class ApiController extends OCSController {
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/user-settings')]
public function getUserSettings(): DataResponse {
// Get standard currencies
$supported = array_map(
fn ($sym): array
=> ['name' => $sym['name'], 'code' => $sym['code'], 'symbol' => $sym['symbol']],
array_values($this->service->symbols)
);
// Add custom currencies
$customCurrencies = $this->customCurrencyMapper->findAll();
foreach ($customCurrencies as $custom) {
$supported[] = [
'name' => $custom->getCode(),
'code' => $custom->getCode(),
'symbol' => $custom->getSymbol() ?: $custom->getCode(),
];
}
return new DataResponse(
['supported_currencies' => $supported]
);
@@ -155,15 +169,29 @@ class ApiController extends OCSController {
$userId = $this->userSession->getUser()->getUID();
$projects = $this->projectMapper->findAllByUser($userId);
// Build a map of custom currency codes for quick lookup
$customCurrenciesMap = [];
foreach ($this->customCurrencyMapper->findAll() as $cc) {
$customCurrenciesMap[strtolower($cc->getCode())] = true;
}
$list = [];
foreach ($projects as $p) {
$name = (string)$p->getName();
$id = (string)$p->getId();
$currencyName = (string)$p->getCurrencyName();
$currencies = $this->currencyMapper->findAll($id);
$currencyNames = array_map(function ($c) {
$currencyNames = array_map(function ($c) use ($customCurrenciesMap) {
$currencyCode = strtolower((string)$c->getName());
// Check if it's a custom currency first
if (isset($customCurrenciesMap[$currencyCode])) {
return $currencyCode;
}
// Otherwise try to resolve as standard currency
$resolved = $this->service->getCurrencyName((string)$c->getName());
return $resolved ?? strtolower((string)$c->getName());
return $resolved ?? $currencyCode;
}, $currencies);
$list[] = [
@@ -267,4 +295,156 @@ class ApiController extends OCSController {
'points' => $points,
]);
}
/**
* Get all custom currencies
*
* @return DataResponse<Http::STATUS_OK, array{
* currencies: list<array{
* id: int,
* code: string,
* symbol: string,
* api_endpoint: string,
* api_key: string,
* json_path: string
* }>
* }, array{}>
*
* 200: Data returned
*/
#[ApiRoute(verb: 'GET', url: '/api/custom-currencies')]
public function getCustomCurrencies(): DataResponse {
$currencies = $this->customCurrencyMapper->findAll();
$serialized = array_map(fn ($c) => $c->jsonSerialize(), $currencies);
return new DataResponse(['currencies' => $serialized]);
}
/**
* Create a new custom currency
*
* @param array{
* code: string,
* symbol?: string,
* api_endpoint: string,
* json_path: string,
* api_key?: string
* } $data Data to create
* @return DataResponse<Http::STATUS_CREATED, array{
* id: int,
* code: string,
* symbol: string,
* api_endpoint: string,
* api_key: string,
* json_path: string
* }, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 201: Currency created
* 400: Bad request
* 500: Internal server error
*/
#[ApiRoute(verb: 'POST', url: '/api/custom-currencies')]
public function createCustomCurrency(mixed $data): DataResponse {
$requiredFields = ['code', 'api_endpoint', 'json_path'];
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || !is_string($data[$field]) || trim($data[$field]) === '') {
return new DataResponse(['error' => "Field '$field' is required"], Http::STATUS_BAD_REQUEST);
}
}
$currency = new CustomCurrency();
$currency->setCode(trim((string)$data['code']));
if (isset($data['symbol']) && is_string($data['symbol'])) {
$currency->setSymbol(trim((string)$data['symbol']));
} else {
$currency->setSymbol('');
}
$currency->setApiEndpoint(trim((string)$data['api_endpoint']));
$currency->setJsonPath(trim((string)$data['json_path']));
if (isset($data['api_key']) && is_string($data['api_key'])) {
$currency->setApiKey(trim((string)$data['api_key']));
} else {
$currency->setApiKey('');
}
try {
$this->customCurrencyMapper->insert($currency);
return new DataResponse($currency, Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Failed to create custom currency: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create custom currency'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a custom currency
*
* @param int $id Currency ID
* @return DataResponse<Http::STATUS_OK, array{status: non-empty-string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Currency deleted
* 500: Internal server error
*/
#[ApiRoute(verb: 'DELETE', url: '/api/custom-currencies/{id}')]
public function deleteCustomCurrency(int $id): DataResponse {
try {
$currency = $this->customCurrencyMapper->find((string)$id);
$this->customCurrencyMapper->delete($currency);
return new DataResponse(['status' => 'OK']);
} catch (\Exception $e) {
$this->logger->error('Failed to delete custom currency: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to delete custom currency'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a custom currency
*
* @param int $id Currency ID
* @param array{
* code?: string,
* symbol?: string,
* api_endpoint?: string,
* json_path?: string,
* api_key?: string
* } $data Data to update
* @return DataResponse<Http::STATUS_OK, array{
* id: int,
* code: string,
* symbol: string,
* api_endpoint: string,
* api_key: string,
* json_path: string
* }, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Currency updated
* 500: Internal server error
*/
#[ApiRoute(verb: 'PUT', url: '/api/custom-currencies/{id}')]
public function updateCustomCurrency(int $id, mixed $data): DataResponse {
try {
$currency = $this->customCurrencyMapper->find((string)$id);
if (isset($data['code']) && is_string($data['code']) && trim((string)$data['code']) !== '') {
$currency->setCode(trim((string)$data['code']));
}
if (isset($data['symbol']) && is_string($data['symbol']) && trim((string)$data['symbol']) !== '') {
$currency->setSymbol(trim((string)$data['symbol']));
}
if (isset($data['api_endpoint']) && is_string($data['api_endpoint']) && trim((string)$data['api_endpoint']) !== '') {
$currency->setApiEndpoint(trim((string)$data['api_endpoint']));
}
if (isset($data['json_path']) && is_string($data['json_path']) && trim((string)$data['json_path']) !== '') {
$currency->setJsonPath(trim((string)$data['json_path']));
}
if (array_key_exists('api_key', $data)) {
if (is_string($data['api_key'])) {
$currency->setApiKey(trim((string)$data['api_key']));
} else {
$currency->setApiKey('');
}
}
$this->customCurrencyMapper->update($currency);
return new DataResponse($currency);
} catch (\Exception $e) {
$this->logger->error('Failed to update custom currency: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to update custom currency'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

51
lib/Db/CustomCurrency.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\AutoCurrency\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method string getCode()
* @method void setCode(string $value)
* @method string getSymbol()
* @method void setSymbol(string $value)
* @method string getApiEndpoint()
* @method void setApiEndpoint(string $value)
* @method string getApiKey()
* @method void setApiKey(string $value)
* @method string getJsonPath()
* @method void setJsonPath(string $value)
*/
class CustomCurrency extends Entity implements JsonSerializable {
protected $code = '';
protected $symbol = '';
protected $apiEndpoint = '';
protected $apiKey = '';
protected $jsonPath = '';
public function __construct() {
$this->addType('code', 'string');
$this->addType('symbol', 'string');
$this->addType('apiEndpoint', 'string');
$this->addType('apiKey', 'string');
$this->addType('jsonPath', 'string');
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'code' => $this->getCode(),
'symbol' => $this->getSymbol(),
'api_endpoint' => $this->getApiEndpoint(),
'api_key' => $this->getApiKey(),
'json_path' => $this->getJsonPath(),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\AutoCurrency\Db;
use OCA\AutoCurrency\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<CustomCurrency>
*/
class CustomCurrencyMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('custom'), CustomCurrency::class);
}
/**
* @param string $id
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(string $id): CustomCurrency {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR))
);
return $this->findEntity($qb);
}
/**
* @return array<CustomCurrency>
*/
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
}

View File

@@ -12,7 +12,7 @@ use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version0Date20250925012201 extends SimpleMigrationStep {
class Version1Date20250925012201 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\AutoCurrency\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version2Date20251005011020 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('autocurrency_custom')) {
return null;
}
$table = $schema->createTable('autocurrency_custom');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('code', 'string', [
'notnull' => true,
'length' => 10,
]);
$table->addColumn('symbol', 'string', [
'notnull' => false,
'length' => 10,
]);
$table->addColumn('api_endpoint', 'string', [
'notnull' => true,
'length' => 255,
]);
$table->addColumn('api_key', 'string', [
'notnull' => false,
'length' => 255,
]);
$table->addColumn('json_path', 'string', [
'notnull' => true,
'length' => 255,
]);
$table->setPrimaryKey(['id']);
return $schema;
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
}

View File

@@ -17,6 +17,8 @@ use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
use OCA\AutoCurrency\Db\CospendProjectMapper;
use OCA\AutoCurrency\Db\Currency;
use OCA\AutoCurrency\Db\CurrencyMapper;
use OCA\AutoCurrency\Db\CustomCurrency;
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\IAppConfig;
@@ -27,22 +29,37 @@ class FetchCurrenciesService {
private static $EXCHANGE_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{base}.json';
private static $SYMBOLS_FILE = __DIR__ . '/symbols.json';
/**
* @var array<string, mixed>
*/
/** @var array<string, mixed> */
public array $symbols = [];
/** @var array<string, mixed> */
private array $symbolPreference = [];
/**
* Cache for API responses to avoid duplicate fetches
* @var array<string, array>
*/
private array $apiCache = [];
/**
* Cache for custom currencies
* @var array<string, CustomCurrency>|null
*/
private ?array $customCurrenciesCache = null;
public function __construct(
private IAppConfig $config,
private CurrencyMapper $currencyMapper,
private CospendProjectMapper $projectMapper,
private AutocurrencyRateHistoryMapper $historyMapper,
private CustomCurrencyMapper $customCurrencyMapper,
private LoggerInterface $logger,
) {
$this->config = $config;
$this->currencyMapper = $currencyMapper;
$this->projectMapper = $projectMapper;
$this->historyMapper = $historyMapper; // ⬅️ NEW
$this->historyMapper = $historyMapper;
$this->customCurrencyMapper = $customCurrencyMapper;
$this->logger = $logger;
$this->loadSymbols();
}
@@ -50,67 +67,353 @@ class FetchCurrenciesService {
public function fetchCurrencyRates(): void {
$this->logger->info('Starting cron job to fetch currencies');
$projects = $this->projectMapper->findAll();
$currencyMap = [];
$this->logger->info('Found ' . count($projects) . ' projects');
foreach ($projects as $project) {
$currencyName = $project->getCurrencyName();
if (!$currencyName) {
$this->logger->warning('Currency name not found for project ' . $project->id);
continue;
}
$base = $this->getCurrencyName($currencyName);
$lbase = strtolower($base);
if (isset($currencyMap[$base])) {
$json = $currencyMap[$base];
} else {
// request currency exchange rates from the API
$this->logger->info('Fetching exchange rates for base currency ' . $base);
$fp = fopen(str_replace('{base}', $lbase, FetchCurrenciesService::$EXCHANGE_URL), 'r');
$data = stream_get_contents($fp);
fclose($fp);
$json = json_decode($data, true);
$this->logger->info('Fetched exchange rates for base currency: ' . json_encode($json));
if ($json[$lbase] == null) {
$this->logger->error(new \Error('Failed to fetch exchange rates for base currency ' . $base));
continue;
}
$currencyMap[$lbase] = $json;
}
$currencies = $this->findAllCurrencies($project->id);
foreach ($currencies as $currency) {
$cur = $this->getCurrencyName($currency->getName());
if ($cur === null) {
$this->logger->error('Currency not found: ' . $currency->getName());
continue;
}
$lcur = strtolower($cur);
$baseRate = $json[$lbase][$lcur];
$newRate = 1.0 / $baseRate;
$currency->setExchangeRate($newRate);
$this->logger->info('Setting exchange rate for currency ' . $cur . ' to ' . $newRate);
$this->currencyMapper->update($currency);
$this->writeHistory(
projectId: (string)$project->id,
projectName: $this->safeProjectName($project),
baseCurrency: $lbase,
currencyName: $lcur,
rate: $newRate,
currencyId: (int)$currency->getId()
);
}
$this->processProject($project);
}
$lastUpdate = date('c');
$this->config->setValueString(AppInfo\Application::APP_ID, 'last_update', $lastUpdate);
}
/**
* Process a single project - fetch and update all its currency rates
*/
private function processProject(object $project): void {
$currencyName = $project->getCurrencyName();
if (!$currencyName) {
$this->logger->warning('Currency name not found for project ' . $project->id);
return;
}
$base = $this->getCurrencyName($currencyName);
$lbase = strtolower($base);
$this->logger->info('Processing project ' . $project->id . ' with base currency ' . $base);
$currencies = $this->findAllCurrencies($project->id);
foreach ($currencies as $currency) {
$this->processCurrency($currency, $project, $lbase);
}
}
/**
* Process a single currency for a project
*/
private function processCurrency(Currency $currency, object $project, string $baseCurrency): void {
$currencyCode = strtolower((string)$currency->getName());
// Check if this currency has a custom configuration first
$customCurrency = $this->findCustomCurrency($currencyCode);
$source = null;
if ($customCurrency !== null) {
// Custom currency found - use custom logic
$this->logger->info("Using custom currency configuration for $currencyCode");
$result = $this->fetchCustomCurrencyRate($customCurrency, $baseCurrency);
if ($result === null) {
$this->logger->warning("Failed to fetch custom rate for $currencyCode, falling back to standard API");
$newRate = $this->fetchStandardRate($baseCurrency, $currencyCode);
$source = $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency);
} else {
$newRate = $result['rate'];
$source = $result['source'];
}
$lcur = $currencyCode;
} else {
// Not a custom currency - validate against standard symbols
$cur = $this->getCurrencyName($currency->getName());
if ($cur === null) {
$this->logger->error('Currency not found: ' . $currency->getName());
return;
}
$lcur = strtolower($cur);
// Use standard API
$newRate = $this->fetchStandardRate($baseCurrency, $lcur);
$source = $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency);
}
if ($newRate === null) {
$this->logger->error("Failed to fetch rate for $currencyCode");
return;
}
$this->updateCurrencyRate($currency, $project, $baseCurrency, $lcur, $newRate, $source);
}
/**
* Fetch exchange rate using the standard API
*/
private function fetchStandardRate(string $baseCurrency, string $targetCurrency): ?float {
try {
$json = $this->fetchStandardRates($baseCurrency);
if (!isset($json[$baseCurrency][$targetCurrency])) {
$this->logger->error("Rate not found for $targetCurrency in base $baseCurrency");
return null;
}
$baseRate = $json[$baseCurrency][$targetCurrency];
return 1.0 / $baseRate;
} catch (\Throwable $e) {
$this->logger->error('Error fetching standard rate: ' . $e->getMessage());
return null;
}
}
/**
* Fetch all exchange rates for a base currency from the standard API (with caching)
* @return array|mixed
*/
private function fetchStandardRates(string $baseCurrency): array {
if (isset($this->apiCache[$baseCurrency])) {
return $this->apiCache[$baseCurrency];
}
$this->logger->info('Fetching exchange rates for base currency ' . $baseCurrency);
$url = $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency);
$fp = fopen($url, 'r');
if (!$fp) {
throw new \RuntimeException("Failed to open URL: $url");
}
$data = stream_get_contents($fp);
fclose($fp);
$json = json_decode($data, true);
if (!isset($json[$baseCurrency])) {
throw new \RuntimeException("Failed to fetch exchange rates for base currency $baseCurrency");
}
$this->apiCache[$baseCurrency] = $json;
return $json;
}
/**
* Update a currency's exchange rate in the database and history
*/
private function updateCurrencyRate(
Currency $currency,
object $project,
string $baseCurrency,
string $currencyName,
float $rate,
?string $source,
): void {
$currency->setExchangeRate($rate);
$this->logger->info("Setting exchange rate for currency $currencyName to $rate");
$this->currencyMapper->update($currency);
$this->writeHistory(
projectId: (string)$project->id,
projectName: $this->safeProjectName($project),
baseCurrency: $baseCurrency,
currencyName: $currencyName,
rate: $rate,
currencyId: (int)$currency->getId(),
source: $source
);
}
/**
* Find a custom currency by its code
*/
private function findCustomCurrency(string $currencyCode): ?CustomCurrency {
if ($this->customCurrenciesCache === null) {
$this->customCurrenciesCache = [];
$customCurrencies = $this->customCurrencyMapper->findAll();
foreach ($customCurrencies as $cc) {
$code = strtolower($cc->getCode());
$this->customCurrenciesCache[$code] = $cc;
}
$this->logger->debug('Loaded ' . count($this->customCurrenciesCache) . ' custom currencies');
}
return $this->customCurrenciesCache[strtolower($currencyCode)] ?? null;
}
/**
* Fetch exchange rate from a custom currency endpoint
* @return array{rate: float, source: string}|null
*/
private function fetchCustomCurrencyRate(CustomCurrency $customCurrency, string $baseCurrency): ?array {
try {
$endpoint = $this->replaceTokens($customCurrency->getApiEndpoint(), $baseCurrency);
$this->logger->debug("Fetching custom rate from: $endpoint");
$hasBaseToken = strpos($customCurrency->getApiEndpoint(), '{base}') !== false
|| strpos($customCurrency->getJsonPath(), '{base}') !== false;
$apiKey = $customCurrency->getApiKey();
$response = $this->fetchApiResponse($endpoint, $apiKey);
$jsonPath = $this->replaceTokens($customCurrency->getJsonPath(), $baseCurrency);
$rawRate = $this->extractJsonPath($response, $jsonPath);
if ($rawRate === null) {
$this->logger->error("Failed to extract rate from JSON path: $jsonPath");
return null;
}
$rate = (float)$rawRate;
// If {base} token wasn't used, we assume USD and may need to convert
if (!$hasBaseToken && strtolower($baseCurrency) !== 'usd') {
$this->logger->info("Custom currency endpoint doesn't use {base} token, assuming USD rate");
$rate = $this->convertFromUsdToBase($rate, $baseCurrency);
}
return [
'rate' => $rate,
'source' => $endpoint,
];
} catch (\Throwable $e) {
$this->logger->error('Error fetching custom currency rate: ' . $e->getMessage());
return null;
}
}
/**
* Replace tokens in a string (e.g., {base} with actual base currency)
*/
private function replaceTokens(string $text, string $baseCurrency): string {
return str_replace('{base}', strtolower($baseCurrency), $text);
}
/**
* Fetch API response with caching
* @return array|mixed
*/
private function fetchApiResponse(string $url, ?string $apiKey = null): array {
// Create a cache key that includes the API key (hashed for security)
$cacheKey = $apiKey ? $url . ':' . md5($apiKey) : $url;
if (isset($this->apiCache[$cacheKey])) {
$this->logger->debug("Using cached response for: $url");
return $this->apiCache[$cacheKey];
}
$this->logger->debug("Fetching API response from: $url");
// Set up stream context with headers if API key is provided
$opts = [
'http' => [
'method' => 'GET',
'header' => '',
],
];
if ($apiKey && $apiKey !== '') {
$opts['http']['header'] = "Authorization: Bearer $apiKey\r\n";
$this->logger->debug('Using API key for authentication');
}
$context = stream_context_create($opts);
$fp = fopen($url, 'r', false, $context);
if (!$fp) {
throw new \RuntimeException("Failed to open URL: $url");
}
$data = stream_get_contents($fp);
fclose($fp);
$json = json_decode($data, true);
if ($json === null) {
throw new \RuntimeException("Failed to decode JSON from: $url");
}
$this->apiCache[$cacheKey] = $json;
return $json;
}
/**
* Extract value from JSON using a simple JSON path notation
* Supports both formats:
* - With $. prefix: $.key, $.key.subkey, $.key[0]
* - Without prefix: key, key.subkey, key[0], rates.{base}.btc
* @param array<int,mixed> $data
*/
private function extractJsonPath(array $data, string $path): mixed {
$path = preg_replace('/^\$\.?/', '', $path);
if ($path === '') {
return $data;
}
// Split path by dots and brackets
$parts = preg_split('/\.|\[|\]/', $path, -1, PREG_SPLIT_NO_EMPTY);
$current = $data;
foreach ($parts as $part) {
if (is_array($current)) {
if (is_numeric($part)) {
// Array index
$index = (int)$part;
if (!isset($current[$index])) {
return null;
}
$current = $current[$index];
} else {
// Object key
if (!isset($current[$part])) {
return null;
}
$current = $current[$part];
}
} else {
return null;
}
}
return $current;
}
/**
* Convert a rate from USD to the actual base currency
* If we have a rate in USD but need it in EUR, we need to convert it
*/
private function convertFromUsdToBase(float $usdRate, string $baseCurrency): float {
try {
$this->logger->info("Converting rate from USD to $baseCurrency");
// Fetch USD exchange rates
$usdRates = $this->fetchStandardRates('usd');
if (!isset($usdRates['usd'][$baseCurrency])) {
$this->logger->error("Cannot convert: USD to $baseCurrency rate not found");
return $usdRate; // Return original rate as fallback
}
// Get the USD -> baseCurrency rate
$usdToBase = $usdRates['usd'][$baseCurrency];
// Check for zero to avoid division by zero
if ($usdToBase == 0) {
$this->logger->error("Cannot convert: USD to $baseCurrency rate is zero");
return $usdRate; // Return original rate as fallback
}
// Convert: if 1 CustomCurrency = X USD, and 1 USD = Y BaseCurrency
// then 1 CustomCurrency = X * Y BaseCurrency
// Example: 1 XMR = 320 USD, and 1 USD = 3.5 ILS → 1 XMR = 320 * 3.5 = 1120 ILS
$convertedRate = $usdRate * $usdToBase;
$this->logger->info("Converted rate from USD: $usdRate to $baseCurrency: $convertedRate");
return $convertedRate;
} catch (\Throwable $e) {
$this->logger->error('Error converting from USD to base: ' . $e->getMessage());
return $usdRate; // Return original rate as fallback
}
}
/**
* Insert a rate history row. If a duplicate occurs due to the UNIQUE constraint
* (project_id, currency_name, fetched_at), we quietly ignore it.
@@ -122,10 +425,11 @@ class FetchCurrenciesService {
string $currencyName,
float $rate,
int $currencyId,
?string $source = null,
): void {
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
// 1) Check for an existing sample at the same timestamp
// Check for an existing sample at the same timestamp
$existing = $this->historyMapper->findByProjectAndBase(
projectId: $projectId,
baseCurrency: $baseCurrency,
@@ -148,11 +452,11 @@ class FetchCurrenciesService {
return;
}
// 2) Insert new history row
// Insert new history row
try {
$entity = new AutocurrencyRateHistory();
// Keep rate as string to avoid float precision issues with DECIMAL in DB
// NOTE Keep rate as string to avoid float precision issues with DECIMAL in DB
$rateStr = sprintf('%.10F', $rate);
$entity->setProjectId($projectId);
@@ -161,7 +465,7 @@ class FetchCurrenciesService {
$entity->setCurrencyName($currencyName);
$entity->setRate($rateStr);
$entity->setFetchedAt($now->format(DATE_ATOM));
$entity->setSource(str_replace('{base}', $baseCurrency, self::$EXCHANGE_URL));
$entity->setSource($source ?? $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency));
$entity->setCurrencyId($currencyId);
$this->historyMapper->insert($entity);

View File

@@ -459,6 +459,830 @@
}
}
}
},
"/ocs/v2.php/apps/autocurrency/api/custom-currencies": {
"get": {
"operationId": "api-get-custom-currencies",
"summary": "Get all custom currencies",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Data returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"currencies"
],
"properties": {
"currencies": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"code",
"symbol",
"api_endpoint",
"api_key",
"json_path"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"api_key": {
"type": "string"
},
"json_path": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"post": {
"operationId": "api-create-custom-currency",
"summary": "Create a new custom currency",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"description": "Data to create",
"required": [
"code",
"api_endpoint",
"json_path"
],
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"json_path": {
"type": "string"
},
"api_key": {
"type": "string"
}
}
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"201": {
"description": "Currency created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"id",
"code",
"symbol",
"api_endpoint",
"api_key",
"json_path"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"api_key": {
"type": "string"
},
"json_path": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/autocurrency/api/custom-currencies/{id}": {
"delete": {
"operationId": "api-delete-custom-currency",
"summary": "Delete a custom currency",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "Currency ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Currency deleted",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"status"
],
"properties": {
"status": {
"type": "string",
"minLength": 1
}
}
}
}
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"put": {
"operationId": "api-update-custom-currency",
"summary": "Update a custom currency",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"description": "Data to update",
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"json_path": {
"type": "string"
},
"api_key": {
"type": "string"
}
}
}
}
}
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"description": "Currency ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Currency updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"id",
"code",
"symbol",
"api_endpoint",
"api_key",
"json_path"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"api_key": {
"type": "string"
},
"json_path": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
}
},
"tags": []

View File

@@ -460,6 +460,830 @@
}
}
},
"/ocs/v2.php/apps/autocurrency/api/custom-currencies": {
"get": {
"operationId": "api-get-custom-currencies",
"summary": "Get all custom currencies",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Data returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"currencies"
],
"properties": {
"currencies": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"code",
"symbol",
"api_endpoint",
"api_key",
"json_path"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"api_key": {
"type": "string"
},
"json_path": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"post": {
"operationId": "api-create-custom-currency",
"summary": "Create a new custom currency",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"description": "Data to create",
"required": [
"code",
"api_endpoint",
"json_path"
],
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"json_path": {
"type": "string"
},
"api_key": {
"type": "string"
}
}
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"201": {
"description": "Currency created",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"id",
"code",
"symbol",
"api_endpoint",
"api_key",
"json_path"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"api_key": {
"type": "string"
},
"json_path": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/autocurrency/api/custom-currencies/{id}": {
"delete": {
"operationId": "api-delete-custom-currency",
"summary": "Delete a custom currency",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "Currency ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Currency deleted",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"status"
],
"properties": {
"status": {
"type": "string",
"minLength": 1
}
}
}
}
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"put": {
"operationId": "api-update-custom-currency",
"summary": "Update a custom currency",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"description": "Data to update",
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"json_path": {
"type": "string"
},
"api_key": {
"type": "string"
}
}
}
}
}
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"description": "Currency ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Currency updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"id",
"code",
"symbol",
"api_endpoint",
"api_key",
"json_path"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"api_endpoint": {
"type": "string"
},
"api_key": {
"type": "string"
},
"json_path": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/autocurrency/api/user-settings": {
"get": {
"operationId": "api-get-user-settings",
@@ -515,21 +1339,24 @@
],
"properties": {
"supported_currencies": {
"type": "object",
"required": [
"code",
"symbol",
"name"
],
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"name": {
"type": "string"
"type": "array",
"items": {
"type": "object",
"required": [
"code",
"symbol",
"name"
],
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}

View File

@@ -102,21 +102,24 @@
],
"properties": {
"supported_currencies": {
"type": "object",
"required": [
"code",
"symbol",
"name"
],
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"name": {
"type": "string"
"type": "array",
"items": {
"type": "object",
"required": [
"code",
"symbol",
"name"
],
"properties": {
"code": {
"type": "string"
},
"symbol": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}

View File

@@ -27,7 +27,8 @@
"chart.js": "^4.5.0",
"date-fns": "^4.1.0",
"linkifyjs": "^4.3.2",
"vue": "^3.5.22"
"vue": "^3.5.22",
"vue-material-design-icons": "^5.3.1"
},
"devDependencies": {
"@eslint/js": "^9.37.0",

8
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
vue:
specifier: ^3.5.22
version: 3.5.22(typescript@5.9.3)
vue-material-design-icons:
specifier: ^5.3.1
version: 5.3.1
devDependencies:
'@eslint/js':
specifier: ^9.37.0
@@ -3842,6 +3845,9 @@ packages:
peerDependencies:
vue: ^2.6.0
vue-material-design-icons@5.3.1:
resolution: {integrity: sha512-6UNEyhlTzlCeT8ZeX5WbpUGFTTPSbOoTQeoASTv7X4Ylh0pe8vltj+36VMK56KM0gG8EQVoMK/Qw/6evalg8lA==}
vue-resize@2.0.0-alpha.1:
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
peerDependencies:
@@ -8275,6 +8281,8 @@ snapshots:
dependencies:
vue: 3.5.22(typescript@5.9.3)
vue-material-design-icons@5.3.1: {}
vue-resize@2.0.0-alpha.1(vue@3.5.22(typescript@5.9.3)):
dependencies:
vue: 3.5.22(typescript@5.9.3)

View File

@@ -11,7 +11,7 @@ function getLatestMigration() {
const migrationFiles = files.sort((a, b) => a.localeCompare(b))
const latestMigration = migrationFiles[migrationFiles.length - 1]
const matches = /Version(\d+)/.exec(latestMigration)
const version = matches ? Number(matches[1]) + 1 : 0
const version = (matches ? Number(matches[1]) : 0) + 1
return version
}

View File

@@ -6,6 +6,90 @@
<p v-html="strings.instructionsHelp" />
</NcNoteCard>
<NcAppSettingsSection :name="strings.customCurrenciesHeader">
<NcNoteCard type="info">
<p v-html="strings.customCurrenciesHelp" />
</NcNoteCard>
<div class="custom-currencies-list">
<div
v-for="(currency, index) in customCurrencies"
:key="currency.tempId || currency.id"
class="currency-item"
>
<div class="currency-fields">
<div class="field-row">
<NcTextField
v-model="currency.code"
:label="strings.currencyCode"
:placeholder="strings.currencyCodePlaceholder"
required
:disabled="loading"
/>
<NcTextField
v-model="currency.symbol"
:label="strings.currencySymbol"
:placeholder="strings.currencySymbolPlaceholder"
:disabled="loading"
/>
</div>
<div class="field-row">
<NcTextField
v-model="currency.api_endpoint"
:label="strings.apiEndpoint"
type="url"
:placeholder="strings.apiEndpointPlaceholder"
required
:disabled="loading"
/>
</div>
<div class="field-row">
<NcTextField
v-model="currency.api_key"
:label="strings.apiKey"
type="password"
:placeholder="strings.apiKeyPlaceholder"
:disabled="loading"
/>
<NcTextField
v-model="currency.json_path"
:label="strings.jsonPath"
:placeholder="strings.jsonPathPlaceholder"
required
:disabled="loading"
/>
</div>
</div>
<NcButton
type="error"
@click="removeCurrency(index)"
:disabled="loading"
:aria-label="strings.deleteCurrency"
>
<template #icon>
<Delete :size="20" />
</template>
</NcButton>
</div>
<NcButton @click="addCurrency" :disabled="loading">
<template #icon>
<Plus :size="20" />
</template>
{{ strings.addCurrency }}
</NcButton>
</div>
<div class="submit-buttons">
<NcButton type="primary" @click="saveCustomCurrencies" :disabled="loading">
{{ strings.save }}
</NcButton>
</div>
</NcAppSettingsSection>
<NcAppSettingsSection :name="strings.cronSettingsHeader">
<section>
<form @submit.prevent="save">
@@ -44,6 +128,9 @@ import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import Plus from '@icons/Plus.vue'
import Delete from '@icons/Delete.vue'
import { APP_ID } from '@/consts'
import { generateUrl } from '@nextcloud/router'
@@ -59,12 +146,18 @@ export default {
NcDateTime,
NcSelect,
NcNoteCard,
NcTextField,
Plus,
Delete,
},
data() {
return {
loading: true,
interval: null,
lastUpdate: null,
customCurrencies: [],
originalCustomCurrencies: [],
tempIdCounter: 0,
intervalOptions: [
{ label: t(APP_ID, 'Every hour'), value: 1 },
{ label: n(APP_ID, 'Every %n hour', 'Every %n hours', 3), value: 3 },
@@ -78,6 +171,26 @@ export default {
],
strings: {
title: t(APP_ID, 'Auto Currency for Cospend'),
customCurrenciesHeader: t(APP_ID, 'Custom Currencies'),
customCurrenciesHelp: t(
APP_ID,
`Define custom currencies with their own API endpoints.{br}Use {cStart}{base}{cEnd} in the endpoint URL or JSON path to substitute the project's base currency.{br}The API should return a rate in the base currency (or USD if {cStart}{base}{cEnd} is not used).{br}The API key will be passed in the {cStart}Authorization{cEnd} header as {cStart}Bearer{cEnd} if provided.`,
{ br: '<br />', cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
currencyCode: t(APP_ID, 'Currency Code'),
currencyCodePlaceholder: t(APP_ID, 'e.g., BTC'),
currencySymbol: t(APP_ID, 'Symbol (optional)'),
currencySymbolPlaceholder: t(APP_ID, 'e.g., ₿'),
apiEndpoint: t(APP_ID, 'API Endpoint'),
apiEndpointPlaceholder: t(APP_ID, 'e.g., https://api.example.com/rates/{base}'),
apiKey: t(APP_ID, 'API Key (optional)'),
apiKeyPlaceholder: t(APP_ID, 'Leave empty if not required'),
jsonPath: t(APP_ID, 'JSON Path'),
jsonPathPlaceholder: t(APP_ID, 'e.g., $.rates.{base} or data[0].rate'),
addCurrency: t(APP_ID, 'Add Currency'),
deleteCurrency: t(APP_ID, 'Delete Currency'),
cronSettingsHeader: t(APP_ID, 'Cron Settings'),
instructionsHelp: t(
APP_ID,
@@ -101,6 +214,7 @@ export default {
},
created() {
this.fetchSettings()
this.fetchCustomCurrencies()
},
methods: {
async fetchSettings() {
@@ -156,6 +270,70 @@ export default {
console.error('Failed to update Auto Currency settings', e)
}
},
async fetchCustomCurrencies() {
try {
this.loading = true
const resp = await ocs.get('/custom-currencies')
this.customCurrencies = resp.data.currencies.map((c) => ({ ...c }))
this.originalCustomCurrencies = JSON.parse(JSON.stringify(resp.data.currencies))
this.loading = false
console.debug('[DEBUG] Custom currencies fetched', this.customCurrencies)
} catch (e) {
console.error('Failed to fetch custom currencies', e)
this.loading = false
}
},
addCurrency() {
this.customCurrencies.push({
tempId: `temp-${this.tempIdCounter++}`,
code: '',
symbol: '',
api_endpoint: '',
api_key: '',
json_path: '',
})
},
removeCurrency(index) {
this.customCurrencies.splice(index, 1)
},
async saveCustomCurrencies() {
try {
this.loading = true
// Determine what needs to be created, updated, or deleted
const toCreate = this.customCurrencies.filter((c) => c.tempId)
const toUpdate = this.customCurrencies.filter((c) => c.id && !c.tempId)
const toDelete = this.originalCustomCurrencies.filter(
(orig) => !this.customCurrencies.some((curr) => curr.id === orig.id),
)
// Delete removed currencies
for (const currency of toDelete) {
await ocs.delete(`/custom-currencies/${currency.id}`)
console.debug('[DEBUG] Deleted currency', currency.id)
}
// Create new currencies
for (const currency of toCreate) {
const { tempId, ...data } = currency
await ocs.post('/custom-currencies', { data })
console.debug('[DEBUG] Created currency', data.code)
}
// Update existing currencies
for (const currency of toUpdate) {
const { id, ...data } = currency
await ocs.put(`/custom-currencies/${id}`, { data })
console.debug('[DEBUG] Updated currency', id)
}
// Refresh the list
await this.fetchCustomCurrencies()
} catch (e) {
console.error('Failed to save custom currencies', e)
this.loading = false
}
},
},
computed: {
intervals() {
@@ -186,5 +364,38 @@ export default {
flex-direction: column;
gap: 8px;
}
.custom-currencies-list {
margin-top: 16px;
.currency-item {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
margin-bottom: 12px;
background-color: var(--color-background-hover);
.currency-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
.field-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
> * {
flex: 1;
min-width: 200px;
}
}
}
}
}
}
</style>

View File

@@ -36,6 +36,8 @@ use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
use OCA\AutoCurrency\Db\CospendProjectMapper;
use OCA\AutoCurrency\Db\Currency;
use OCA\AutoCurrency\Db\CurrencyMapper;
use OCA\AutoCurrency\Db\CustomCurrency;
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
use OCA\AutoCurrency\Service\FetchCurrenciesService;
use OCA\Cospend\Db\Project;
use OCP\IAppConfig;
@@ -54,6 +56,7 @@ final class ApiControllerTest extends TestCase {
/** @var CurrencyMapper&MockObject */ private $currencyMapper;
/** @var CospendProjectMapper&MockObject */ private $projectMapper;
/** @var AutocurrencyRateHistoryMapper&MockObject */ private $historyMapper;
/** @var CustomCurrencyMapper&MockObject */ private $customCurrencyMapper;
/** @var LoggerInterface&MockObject */ private $logger;
/** @var FetchCurrenciesService */ private $service;
@@ -66,7 +69,7 @@ final class ApiControllerTest extends TestCase {
/**
* Build controller with optional overrides:
* - 'config', 'request', 'l10n', 'currencyMapper', 'projectMapper', 'historyMapper', 'logger'
* - 'config', 'request', 'l10n', 'currencyMapper', 'projectMapper', 'historyMapper', 'customCurrencyMapper', 'logger'
* - 'serviceMethods' => methods to partial-mock on FetchCurrenciesService
* - 'symbols' => fixture array for $service->symbols
* @param array<string,mixed> $opts
@@ -80,11 +83,14 @@ final class ApiControllerTest extends TestCase {
$this->currencyMapper = $opts['currencyMapper'] ?? $this->createMock(CurrencyMapper::class);
$this->projectMapper = $opts['projectMapper'] ?? $this->createMock(CospendProjectMapper::class);
$this->historyMapper = $opts['historyMapper'] ?? $this->createMock(AutocurrencyRateHistoryMapper::class);
$this->customCurrencyMapper = $opts['customCurrencyMapper'] ?? $this->createMock(CustomCurrencyMapper::class);
$this->logger = $opts['logger'] ?? $this->createMock(LoggerInterface::class);
$this->customCurrencyMapper->method('findAll')->willReturn([]);
if (!empty($opts['serviceMethods'])) {
$this->service = $this->getMockBuilder(FetchCurrenciesService::class)
->setConstructorArgs([$this->config, $this->currencyMapper, $this->projectMapper, $this->historyMapper, $this->logger])
->setConstructorArgs([$this->config, $this->currencyMapper, $this->projectMapper, $this->historyMapper, $this->customCurrencyMapper, $this->logger])
->onlyMethods($opts['serviceMethods'])
->getMock();
} else {
@@ -93,6 +99,7 @@ final class ApiControllerTest extends TestCase {
$this->currencyMapper,
$this->projectMapper,
$this->historyMapper,
$this->customCurrencyMapper,
$this->logger
);
}
@@ -112,7 +119,8 @@ final class ApiControllerTest extends TestCase {
$this->service,
$this->currencyMapper,
$this->projectMapper,
$this->historyMapper
$this->historyMapper,
$this->customCurrencyMapper
);
}
@@ -176,6 +184,46 @@ final class ApiControllerTest extends TestCase {
);
}
public function testGetUserSettings_IncludesCustomCurrencies(): void {
$c1 = new CustomCurrency();
$c1->setCode('BTC');
$c1->setSymbol('₿');
$c1->setApiEndpoint('https://api.example.com/btc');
$c1->setApiKey('key123');
$c1->setJsonPath('$.rate');
$c2 = new CustomCurrency();
$c2->setCode('ETH');
$c2->setSymbol('');
$c2->setApiEndpoint('https://api.example.com/eth');
$c2->setApiKey('');
$c2->setJsonPath('$.price');
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
$customCurrencyMapper->expects($this->once())
->method('findAll')
->willReturn([$c1, $c2]);
$controller = $this->buildController([
'symbols' => [
['code' => 'usd', 'symbol' => '$', 'name' => 'US Dollar'],
],
'customCurrencyMapper' => $customCurrencyMapper,
]);
$data = $controller->getUserSettings()->getData();
$this->assertCount(3, $data['supported_currencies']);
$this->assertSame(
[
['name' => 'US Dollar', 'code' => 'usd', 'symbol' => '$'],
['name' => 'BTC', 'code' => 'BTC', 'symbol' => '₿'],
['name' => 'ETH', 'code' => 'ETH', 'symbol' => 'ETH'],
],
$data['supported_currencies']
);
}
public function testRunCron_CallsServiceAndReturnsOk(): void {
$controller = $this->buildController(['serviceMethods' => ['fetchCurrencyRates']]);
$this->service->expects($this->once())->method('fetchCurrencyRates');
@@ -272,6 +320,69 @@ final class ApiControllerTest extends TestCase {
);
}
public function testGetProjects_IncludesCustomCurrencies(): void {
$btcCustom = new CustomCurrency();
$btcCustom->setCode('BTC');
$btcCustom->setSymbol('₿');
$btcCustom->setApiEndpoint('https://api.example.com/btc');
$btcCustom->setApiKey('key123');
$btcCustom->setJsonPath('$.rate');
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
$customCurrencyMapper->method('findAll')
->willReturn([$btcCustom]);
$controller = $this->buildController(['customCurrencyMapper' => $customCurrencyMapper]);
$user = $this->createConfiguredMock(\OCP\IUser::class, ['getUID' => 'u1']);
$this->userSession->method('getUser')->willReturn($user);
$p1 = $this->getMockBuilder(\OCA\Cospend\Db\Project::class)
->disableOriginalConstructor()
->addMethods(['getId', 'getName', 'getCurrencyName'])
->getMock();
$p1->method('getId')->willReturn('p1');
$p1->method('getName')->willReturn('Crypto Trip');
$p1->method('getCurrencyName')->willReturn('usd');
$this->projectMapper->method('findAllByUser')->willReturn([$p1]);
$cUSD = new Currency();
$cBTC = new Currency();
// Set up currencies
if (method_exists($cUSD, 'setName')) {
$cUSD->setName('USD');
$cBTC->setName('BTC');
} else {
$rp = new \ReflectionProperty($cUSD, 'name');
$rp->setAccessible(true);
$rp->setValue($cUSD, 'USD');
$rp = new \ReflectionProperty($cBTC, 'name');
$rp->setAccessible(true);
$rp->setValue($cBTC, 'BTC');
}
$this->currencyMapper->method('findAll')
->willReturn([$cUSD, $cBTC]);
$data = $controller->getProjects()->getData();
$this->assertSame(
[
'projects' => [
[
'id' => 'p1',
'name' => 'Crypto Trip',
'baseCurrency' => 'usd',
'currencies' => ['usd', 'btc'],
],
],
],
$data
);
}
public function testGetHistory_EndOfDayTo_AndMapping(): void {
$controller = $this->buildController();
@@ -360,4 +471,301 @@ final class ApiControllerTest extends TestCase {
$this->assertSame(400, $resp->getStatus());
$this->assertSame(['error' => 'projectId is required'], $resp->getData());
}
public function testGetCustomCurrencies_ReturnsAllCurrencies(): void {
$c1 = new CustomCurrency();
$c1->setCode('BTC');
$c1->setSymbol('₿');
$c1->setApiEndpoint('https://api.example.com/btc');
$c1->setApiKey('key123');
$c1->setJsonPath('$.rate');
$c2 = new CustomCurrency();
$c2->setCode('ETH');
$c2->setSymbol('Ξ');
$c2->setApiEndpoint('https://api.example.com/eth');
$c2->setApiKey('');
$c2->setJsonPath('$.price');
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
$customCurrencyMapper->expects($this->once())
->method('findAll')
->willReturn([$c1, $c2]);
$controller = $this->buildController(['customCurrencyMapper' => $customCurrencyMapper]);
$resp = $controller->getCustomCurrencies();
$data = $resp->getData();
$this->assertArrayHasKey('currencies', $data);
$currencies = $data['currencies'];
$this->assertCount(2, $currencies);
// Verify the entities are serialized
$this->assertSame('BTC', $currencies[0]['code']);
$this->assertSame('₿', $currencies[0]['symbol']);
$this->assertSame('ETH', $currencies[1]['code']);
}
public function testCreateCustomCurrency_Success_WithAllFields(): void {
$controller = $this->buildController();
$inputData = [
'code' => 'BTC',
'symbol' => '₿',
'api_endpoint' => 'https://api.example.com/btc',
'api_key' => 'secret123',
'json_path' => '$.rate',
];
$this->customCurrencyMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($currency) {
$this->assertInstanceOf(CustomCurrency::class, $currency);
$this->assertSame('BTC', $currency->getCode());
$this->assertSame('₿', $currency->getSymbol());
$this->assertSame('https://api.example.com/btc', $currency->getApiEndpoint());
$this->assertSame('secret123', $currency->getApiKey());
$this->assertSame('$.rate', $currency->getJsonPath());
return $currency;
});
$resp = $controller->createCustomCurrency($inputData);
$this->assertSame(201, $resp->getStatus());
}
public function testCreateCustomCurrency_Success_WithoutApiKey(): void {
$controller = $this->buildController();
$inputData = [
'code' => 'ETH',
'symbol' => 'Ξ',
'api_endpoint' => 'https://api.example.com/eth',
'json_path' => '$.price',
];
$this->customCurrencyMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($currency) {
$this->assertSame('', $currency->getApiKey());
return $currency;
});
$resp = $controller->createCustomCurrency($inputData);
$this->assertSame(201, $resp->getStatus());
}
public function testCreateCustomCurrency_BadRequest_MissingCode(): void {
$controller = $this->buildController();
$inputData = [
'symbol' => '₿',
'api_endpoint' => 'https://api.example.com/btc',
'json_path' => '$.rate',
];
$resp = $controller->createCustomCurrency($inputData);
$this->assertSame(400, $resp->getStatus());
$this->assertArrayHasKey('error', $resp->getData());
$this->assertStringContainsString('code', $resp->getData()['error']);
}
public function testCreateCustomCurrency_Success_WithoutSymbol(): void {
$controller = $this->buildController();
$inputData = [
'code' => 'BTC',
'api_endpoint' => 'https://api.example.com/btc',
'json_path' => '$.rate',
];
$this->customCurrencyMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($currency) {
$this->assertSame('', $currency->getSymbol());
return $currency;
});
$resp = $controller->createCustomCurrency($inputData);
$this->assertSame(201, $resp->getStatus());
}
public function testCreateCustomCurrency_InternalError_OnException(): void {
$controller = $this->buildController();
$inputData = [
'code' => 'BTC',
'symbol' => '₿',
'api_endpoint' => 'https://api.example.com/btc',
'json_path' => '$.rate',
];
$this->customCurrencyMapper->expects($this->once())
->method('insert')
->willThrowException(new \Exception('Database error'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Failed to create custom currency'));
$resp = $controller->createCustomCurrency($inputData);
$this->assertSame(500, $resp->getStatus());
$this->assertArrayHasKey('error', $resp->getData());
}
public function testDeleteCustomCurrency_Success(): void {
$controller = $this->buildController();
$currency = new CustomCurrency();
$currency->setCode('BTC');
$this->customCurrencyMapper->expects($this->once())
->method('find')
->with('1')
->willReturn($currency);
$this->customCurrencyMapper->expects($this->once())
->method('delete')
->with($currency);
$resp = $controller->deleteCustomCurrency(1);
$this->assertSame(200, $resp->getStatus());
$this->assertSame(['status' => 'OK'], $resp->getData());
}
public function testDeleteCustomCurrency_InternalError_OnException(): void {
$controller = $this->buildController();
$this->customCurrencyMapper->expects($this->once())
->method('find')
->with('1')
->willThrowException(new \Exception('Not found'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Failed to delete custom currency'));
$resp = $controller->deleteCustomCurrency(1);
$this->assertSame(500, $resp->getStatus());
$this->assertArrayHasKey('error', $resp->getData());
}
public function testUpdateCustomCurrency_Success_AllFields(): void {
$controller = $this->buildController();
$currency = new CustomCurrency();
$currency->setCode('BTC');
$currency->setSymbol('₿');
$currency->setApiEndpoint('https://api.example.com/btc');
$currency->setApiKey('oldkey');
$currency->setJsonPath('$.old');
$this->customCurrencyMapper->expects($this->once())
->method('find')
->with('1')
->willReturn($currency);
$inputData = [
'code' => 'ETH',
'symbol' => 'Ξ',
'api_endpoint' => 'https://api.example.com/eth',
'api_key' => 'newkey',
'json_path' => '$.new',
];
$this->customCurrencyMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($c) {
$this->assertSame('ETH', $c->getCode());
$this->assertSame('Ξ', $c->getSymbol());
$this->assertSame('https://api.example.com/eth', $c->getApiEndpoint());
$this->assertSame('newkey', $c->getApiKey());
$this->assertSame('$.new', $c->getJsonPath());
return $c;
});
$resp = $controller->updateCustomCurrency(1, $inputData);
$this->assertSame(200, $resp->getStatus());
}
public function testUpdateCustomCurrency_Success_PartialUpdate(): void {
$controller = $this->buildController();
$currency = new CustomCurrency();
$currency->setCode('BTC');
$currency->setSymbol('₿');
$currency->setApiEndpoint('https://api.example.com/btc');
$currency->setApiKey('key123');
$currency->setJsonPath('$.rate');
$this->customCurrencyMapper->expects($this->once())
->method('find')
->with('1')
->willReturn($currency);
$inputData = [
'code' => 'BTCUSD',
];
$this->customCurrencyMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($c) {
$this->assertSame('BTCUSD', $c->getCode());
$this->assertSame('₿', $c->getSymbol()); // unchanged
$this->assertSame('https://api.example.com/btc', $c->getApiEndpoint()); // unchanged
$this->assertSame('key123', $c->getApiKey()); // unchanged
$this->assertSame('$.rate', $c->getJsonPath()); // unchanged
return $c;
});
$resp = $controller->updateCustomCurrency(1, $inputData);
$this->assertSame(200, $resp->getStatus());
}
public function testUpdateCustomCurrency_Success_ClearApiKey(): void {
$controller = $this->buildController();
$currency = new CustomCurrency();
$currency->setCode('BTC');
$currency->setSymbol('₿');
$currency->setApiEndpoint('https://api.example.com/btc');
$currency->setApiKey('oldkey');
$currency->setJsonPath('$.rate');
$this->customCurrencyMapper->expects($this->once())
->method('find')
->with('1')
->willReturn($currency);
$inputData = [
'api_key' => null,
];
$this->customCurrencyMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($c) {
$this->assertSame('', $c->getApiKey());
return $c;
});
$resp = $controller->updateCustomCurrency(1, $inputData);
$this->assertSame(200, $resp->getStatus());
}
public function testUpdateCustomCurrency_InternalError_OnException(): void {
$controller = $this->buildController();
$this->customCurrencyMapper->expects($this->once())
->method('find')
->with('1')
->willThrowException(new \Exception('Not found'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Failed to update custom currency'));
$resp = $controller->updateCustomCurrency(1, ['code' => 'ETH']);
$this->assertSame(500, $resp->getStatus());
$this->assertArrayHasKey('error', $resp->getData());
}
}

View File

@@ -10,6 +10,8 @@ namespace OCA\AutoCurrency\Tests\Service;
use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
use OCA\AutoCurrency\Db\CospendProjectMapper;
use OCA\AutoCurrency\Db\CurrencyMapper;
use OCA\AutoCurrency\Db\CustomCurrency;
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
use OCA\AutoCurrency\Service\FetchCurrenciesService;
use OCP\IAppConfig;
use PHPUnit\Framework\TestCase;
@@ -24,13 +26,17 @@ final class CurrencyResolverTest extends TestCase {
$currencyMapper = $this->createMock(CurrencyMapper::class);
$projectMapper = $this->createMock(CospendProjectMapper::class);
$historyMapper = $this->createMock(AutocurrencyRateHistoryMapper::class);
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
$logger = $this->createMock(LoggerInterface::class);
$customCurrencyMapper->method('findAll')->willReturn([]);
$this->resolver = new FetchCurrenciesService(
$config,
$currencyMapper,
$projectMapper,
$historyMapper,
$customCurrencyMapper,
$logger
);
@@ -105,4 +111,655 @@ final class CurrencyResolverTest extends TestCase {
['$U$', 'uyu'], // still contains "$U" → prefer that over bare "$"
];
}
/**
* Test JSON path extraction with various formats
* @dataProvider provideJsonPathCases
*/
public function testExtractJsonPath(array $data, string $path, mixed $expected): void {
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('extractJsonPath');
$method->setAccessible(true);
$result = $method->invoke($this->resolver, $data, $path);
$this->assertSame($expected, $result);
}
/**
* @return array<int,mixed>
*/
public static function provideJsonPathCases(): array {
return [
// Basic paths with $. prefix
[['key' => 'value'], '$.key', 'value'],
[['key' => 'value'], '$key', 'value'],
// Basic paths without prefix
[['key' => 'value'], 'key', 'value'],
[['data' => ['rate' => 123.45]], 'data.rate', 123.45],
// Nested paths
[['data' => ['rates' => ['btc' => 50000]]], '$.data.rates.btc', 50000],
[['data' => ['rates' => ['btc' => 50000]]], 'data.rates.btc', 50000],
// Array index
[['items' => [10, 20, 30]], '$.items[0]', 10],
[['items' => [10, 20, 30]], 'items[1]', 20],
// Complex nested
[['result' => ['currencies' => ['usd' => 1.0, 'eur' => 0.85]]], '$.result.currencies.usd', 1.0],
[['result' => ['currencies' => ['usd' => 1.0, 'eur' => 0.85]]], 'result.currencies.eur', 0.85],
// Non-existent paths
[['key' => 'value'], '$.missing', null],
[['key' => 'value'], 'missing.nested', null],
// Empty path returns whole data
[['key' => 'value'], '$', ['key' => 'value']],
[['key' => 'value'], '$.', ['key' => 'value']],
];
}
/**
* Test token replacement
* @dataProvider provideTokenReplacementCases
*/
public function testReplaceTokens(string $text, string $base, string $expected): void {
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('replaceTokens');
$method->setAccessible(true);
$result = $method->invoke($this->resolver, $text, $base);
$this->assertSame($expected, $result);
}
/**
* @return array<int,mixed>
*/
public static function provideTokenReplacementCases(): array {
return [
// Basic replacement
['https://api.example.com/{base}', 'USD', 'https://api.example.com/usd'],
['https://api.example.com/{base}', 'EUR', 'https://api.example.com/eur'],
// Multiple occurrences
['/{base}/rates/{base}', 'GBP', '/gbp/rates/gbp'],
// JSON path with token
['$.rates.{base}.btc', 'USD', '$.rates.usd.btc'],
['data.{base}.price', 'EUR', 'data.eur.price'],
// No token
['https://api.example.com/price', 'USD', 'https://api.example.com/price'],
['$.rate', 'EUR', '$.rate'],
// Case conversion (base is lowercased)
['https://api.example.com/{base}', 'usd', 'https://api.example.com/usd'],
];
}
/**
* Test that {base} token in endpoint is detected
*/
public function testHasBaseTokenInEndpoint(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/rate?base={base}');
$customCurrency->setJsonPath('$.rate');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API response
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/rate?base=eur' => ['rate' => 50000],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
// Should not attempt USD conversion because {base} was found
$this->assertIsArray($result);
$this->assertArrayHasKey('rate', $result);
$this->assertSame(50000.0, $result['rate']);
}
/**
* Test that {base} token in json_path is detected
*/
public function testHasBaseTokenInJsonPath(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/rates');
$customCurrency->setJsonPath('$.{base}');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API response
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/rates' => [
'usd' => 50000,
'eur' => 45000,
'gbp' => 40000,
],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
// Should extract from eur key without USD conversion
$this->assertIsArray($result);
$this->assertArrayHasKey('rate', $result);
$this->assertSame(45000.0, $result['rate']);
}
/**
* Test USD conversion when no {base} token is present
*/
public function testUsdConversionWhenNoBaseToken(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc/price');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API responses - fetchStandardRates caches by baseCurrency, not URL
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc/price' => ['usd' => 50000],
// Cache key is the baseCurrency string 'usd', not the URL
'usd' => [
'usd' => [
'eur' => 0.85,
'gbp' => 0.75,
],
],
]);
// Test with EUR base (should convert from USD)
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
$this->assertIsArray($result);
$this->assertArrayHasKey('rate', $result);
// 50000 USD * 0.85 EUR/USD = 42500 EUR
$expectedRate = 50000 * 0.85;
$this->assertEqualsWithDelta($expectedRate, $result['rate'], 0.01);
}
/**
* Test no USD conversion when base is already USD
*/
public function testNoUsdConversionWhenBaseIsUsd(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc/price');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API response
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc/price' => ['usd' => 50000],
]);
// Test with USD base (should NOT convert)
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertIsArray($result);
$this->assertArrayHasKey('rate', $result);
$this->assertSame(50000.0, $result['rate']);
}
/**
* Test JSON path extraction with complex nested structure
*/
public function testComplexJsonPathExtraction(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/rates');
$customCurrency->setJsonPath('data.rates.{base}.btc');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API response with nested structure
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/rates' => [
'data' => [
'rates' => [
'usd' => ['btc' => 0.00002],
'eur' => ['btc' => 0.000022],
'gbp' => ['btc' => 0.000025],
],
],
],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
$this->assertIsArray($result);
$this->assertArrayHasKey('rate', $result);
$this->assertSame(0.000022, $result['rate']);
}
/**
* Test that extraction returns null for missing path
*/
public function testJsonPathExtractionReturnsNullForMissingPath(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/price');
$customCurrency->setJsonPath('$.nonexistent.path');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API response
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/price' => ['rate' => 50000],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
// Should return null when path doesn't exist
$this->assertNull($result);
}
// ========== API Response Format Variations ==========
/**
* Test that rate as string is converted to float
*/
public function testRateAsStringIsConvertedToFloat(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/price');
$customCurrency->setJsonPath('$.rate');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API response with string rate
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/price' => ['rate' => '50000.5'],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertIsArray($result);
$this->assertIsFloat($result['rate']);
$this->assertSame(50000.5, $result['rate']);
}
/**
* Test handling of rate in scientific notation
*/
public function testScientificNotationRate(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('SHIB');
$customCurrency->setApiEndpoint('https://api.example.com/shib');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock response with very small number in scientific notation
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/shib' => ['usd' => 1.23e-5],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertIsArray($result);
$this->assertSame(1.23e-5, $result['rate']);
}
/**
* Test that null rate returns null result
*/
public function testNullRateReturnsNull(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/price');
$customCurrency->setJsonPath('$.rate');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock response with null rate
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/price' => ['rate' => null],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
// extractJsonPath will return null, which should cause fetchCustomCurrencyRate to return null
$this->assertNull($result);
}
/**
* Test empty JSON response
*/
public function testEmptyJsonObjectReturnsNull(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/empty');
$customCurrency->setJsonPath('$.rate');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock empty response
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/empty' => [],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertNull($result);
}
/**
* Test nested object with null value
*/
public function testNestedNullValueReturnsNull(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/price');
$customCurrency->setJsonPath('$.data.rate');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock response with nested null
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/price' => ['data' => ['rate' => null]],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertNull($result);
}
// ========== Conversion Edge Cases ==========
/**
* Test USD conversion fails gracefully when conversion rate is missing
*/
public function testUsdConversionFailsGracefullyWhenRateMissing(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API responses - standard API missing the target currency
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc' => ['usd' => 50000],
// Cache key is baseCurrency 'usd', WITHOUT JPY rate
'usd' => [
'usd' => [
'eur' => 0.85,
'gbp' => 0.75,
// JPY missing!
],
],
]);
// Test with JPY base (conversion should fail, return original rate)
$result = $method->invoke($this->resolver, $customCurrency, 'jpy');
$this->assertIsArray($result);
// Should fallback to original USD rate
$this->assertSame(50000.0, $result['rate']);
}
/**
* Test USD conversion handles zero rate gracefully
*/
public function testUsdConversionHandlesZeroRate(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API responses with zero conversion rate
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc' => ['usd' => 50000],
'usd' => [
'usd' => [
'xyz' => 0, // Zero rate!
],
],
]);
// Test with XYZ base - should handle division by zero
$result = $method->invoke($this->resolver, $customCurrency, 'xyz');
$this->assertIsArray($result);
// Should return the original USD rate as fallback when conversion rate is zero
$this->assertSame(50000.0, $result['rate']);
}
/**
* Test handling of very small decimal rates (crypto-like)
*/
public function testHandlesVerySmallDecimalRates(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('SHIB');
$customCurrency->setApiEndpoint('https://api.example.com/shib');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Very small rate
$verySmallRate = 0.00000123456789;
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/shib' => ['usd' => $verySmallRate],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertIsArray($result);
$this->assertEqualsWithDelta($verySmallRate, $result['rate'], 1e-15);
}
/**
* Test handling of very large rates
*/
public function testHandlesVeryLargeRates(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc/{base}'); // Add {base} to prevent USD conversion
$customCurrency->setJsonPath('$.price');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Very large rate
$veryLargeRate = 1234567890.123456;
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc/vnd' => ['price' => $veryLargeRate],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'vnd');
$this->assertIsArray($result);
$this->assertEqualsWithDelta($veryLargeRate, $result['rate'], 0.001);
}
/**
* Test negative rate (should still convert to float, even if unusual)
*/
public function testHandlesNegativeRate(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('TEST');
$customCurrency->setApiEndpoint('https://api.example.com/test');
$customCurrency->setJsonPath('$.rate');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/test' => ['rate' => -100.5],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertIsArray($result);
$this->assertSame(-100.5, $result['rate']);
}
/**
* Test integer rate is converted to float
*/
public function testIntegerRateConvertedToFloat(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc' => ['usd' => 50000], // Integer
]);
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
$this->assertIsArray($result);
$this->assertIsFloat($result['rate']);
$this->assertSame(50000.0, $result['rate']);
}
/**
* Test USD conversion with very large conversion rate (like Vietnamese Dong)
*/
public function testUsdConversionWithVeryLargeConversionRate(): void {
$customCurrency = new CustomCurrency();
$customCurrency->setCode('BTC');
$customCurrency->setApiEndpoint('https://api.example.com/btc');
$customCurrency->setJsonPath('$.usd');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock the API responses - using Vietnamese Dong as example of high-value currency
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc' => ['usd' => 50000],
'usd' => [
'usd' => [
'vnd' => 24000, // 1 USD = 24,000 VND
],
],
]);
$result = $method->invoke($this->resolver, $customCurrency, 'vnd');
$this->assertIsArray($result);
// 50000 USD * 24000 VND/USD = 1,200,000,000 VND
$expectedRate = 50000 * 24000;
$this->assertEqualsWithDelta($expectedRate, $result['rate'], 1);
}
/**
* Test that API key is properly included in cache key
*/
public function testApiKeyCacheIsolation(): void {
$customCurrency1 = new CustomCurrency();
$customCurrency1->setCode('BTC');
$customCurrency1->setApiEndpoint('https://api.example.com/btc');
$customCurrency1->setJsonPath('$.rate');
$customCurrency1->setApiKey('key123');
$customCurrency2 = new CustomCurrency();
$customCurrency2->setCode('BTC');
$customCurrency2->setApiEndpoint('https://api.example.com/btc');
$customCurrency2->setJsonPath('$.rate');
$customCurrency2->setApiKey('key456');
$ref = new ReflectionClass($this->resolver);
$method = $ref->getMethod('fetchCustomCurrencyRate');
$method->setAccessible(true);
// Mock responses with different cache keys (URL + hashed API key)
$apiCache = $ref->getProperty('apiCache');
$apiCache->setAccessible(true);
$apiCache->setValue($this->resolver, [
'https://api.example.com/btc:' . md5('key123') => ['rate' => 50000],
'https://api.example.com/btc:' . md5('key456') => ['rate' => 51000],
]);
$result1 = $method->invoke($this->resolver, $customCurrency1, 'usd');
$result2 = $method->invoke($this->resolver, $customCurrency2, 'usd');
$this->assertIsArray($result1);
$this->assertIsArray($result2);
// Different API keys should use different cache entries
$this->assertSame(50000.0, $result1['rate']);
$this->assertSame(51000.0, $result2['rate']);
}
}

View File

@@ -13,6 +13,9 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@icons/*": [
"node_modules/vue-material-design-icons/*"
],
"@/*": [
"src/*"
]

View File

@@ -13,6 +13,7 @@ export default createAppConfig(
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
},
},
build: {