From 71577ffb35c0334059ba9ff748f24a7a51e9826b Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 7 Oct 2025 10:09:09 +0300 Subject: [PATCH] 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 --- gen/model/{{pascalCase name}}Mapper.php | 2 +- lib/Controller/ApiController.php | 188 +++- lib/Db/CustomCurrency.php | 51 ++ lib/Db/CustomCurrencyMapper.php | 52 ++ ...201.php => Version1Date20250925012201.php} | 2 +- lib/Migration/Version2Date20251005011020.php | 75 ++ lib/Service/FetchCurrenciesService.php | 422 +++++++-- openapi-administration.json | 824 +++++++++++++++++ openapi-full.json | 857 +++++++++++++++++- openapi.json | 33 +- package.json | 3 +- pnpm-lock.yaml | 8 + scaffold.config.cjs | 2 +- src/AdminSettings.vue | 211 +++++ tests/unit/Controller/ApiTest.php | 414 ++++++++- .../Service/FetchCurrenciesServiceTest.php | 657 ++++++++++++++ tsconfig.app.json | 3 + vite.config.ts | 1 + 18 files changed, 3705 insertions(+), 100 deletions(-) create mode 100644 lib/Db/CustomCurrency.php create mode 100644 lib/Db/CustomCurrencyMapper.php rename lib/Migration/{Version0Date20250925012201.php => Version1Date20250925012201.php} (98%) create mode 100644 lib/Migration/Version2Date20251005011020.php diff --git a/gen/model/{{pascalCase name}}Mapper.php b/gen/model/{{pascalCase name}}Mapper.php index 916a0ef..3402d59 100755 --- a/gen/model/{{pascalCase name}}Mapper.php +++ b/gen/model/{{pascalCase name}}Mapper.php @@ -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 { diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 5e98b9b..ec272d8 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -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 * }, 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 + * }, 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|DataResponse + * + * 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|DataResponse + * + * 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|DataResponse + * + * 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); + } + } } diff --git a/lib/Db/CustomCurrency.php b/lib/Db/CustomCurrency.php new file mode 100644 index 0000000..fd36507 --- /dev/null +++ b/lib/Db/CustomCurrency.php @@ -0,0 +1,51 @@ + +// 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(), + ]; + } +} diff --git a/lib/Db/CustomCurrencyMapper.php b/lib/Db/CustomCurrencyMapper.php new file mode 100644 index 0000000..4bb8584 --- /dev/null +++ b/lib/Db/CustomCurrencyMapper.php @@ -0,0 +1,52 @@ + +// 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 + */ +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 + */ + public function findAll(): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName()); + return $this->findEntities($qb); + } +} diff --git a/lib/Migration/Version0Date20250925012201.php b/lib/Migration/Version1Date20250925012201.php similarity index 98% rename from lib/Migration/Version0Date20250925012201.php rename to lib/Migration/Version1Date20250925012201.php index 37a08c3..84b45cd 100644 --- a/lib/Migration/Version0Date20250925012201.php +++ b/lib/Migration/Version1Date20250925012201.php @@ -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 diff --git a/lib/Migration/Version2Date20251005011020.php b/lib/Migration/Version2Date20251005011020.php new file mode 100644 index 0000000..82b6769 --- /dev/null +++ b/lib/Migration/Version2Date20251005011020.php @@ -0,0 +1,75 @@ + +// 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 { + } +} diff --git a/lib/Service/FetchCurrenciesService.php b/lib/Service/FetchCurrenciesService.php index 6834099..e37ea8a 100644 --- a/lib/Service/FetchCurrenciesService.php +++ b/lib/Service/FetchCurrenciesService.php @@ -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 - */ + /** @var array */ public array $symbols = []; + /** @var array */ + private array $symbolPreference = []; + + /** + * Cache for API responses to avoid duplicate fetches + * @var array + */ + private array $apiCache = []; + + /** + * Cache for custom currencies + * @var array|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 $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); diff --git a/openapi-administration.json b/openapi-administration.json index e669b07..50adf14 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -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": [] diff --git a/openapi-full.json b/openapi-full.json index 3917976..3d1fbba 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -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" + } } } } diff --git a/openapi.json b/openapi.json index b930918..f324b40 100644 --- a/openapi.json +++ b/openapi.json @@ -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" + } } } } diff --git a/package.json b/package.json index d415f1b..54e2172 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9995d95..104f0fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/scaffold.config.cjs b/scaffold.config.cjs index ae9ff5a..79dd1fc 100644 --- a/scaffold.config.cjs +++ b/scaffold.config.cjs @@ -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 } diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index cc284b2..05be5eb 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -6,6 +6,90 @@

+ + +

+ + +

+
+
+
+ + +
+ +
+ +
+ +
+ + +
+
+ + + + +
+ + + + {{ strings.addCurrency }} + +
+ +
+ + {{ strings.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: '
', cStart: '', cEnd: '' }, + 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; + } + } + } + } + } } diff --git a/tests/unit/Controller/ApiTest.php b/tests/unit/Controller/ApiTest.php index 3941388..c2e93fc 100644 --- a/tests/unit/Controller/ApiTest.php +++ b/tests/unit/Controller/ApiTest.php @@ -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 $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()); + } } diff --git a/tests/unit/Service/FetchCurrenciesServiceTest.php b/tests/unit/Service/FetchCurrenciesServiceTest.php index c887d47..d6280ef 100644 --- a/tests/unit/Service/FetchCurrenciesServiceTest.php +++ b/tests/unit/Service/FetchCurrenciesServiceTest.php @@ -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 + */ + 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 + */ + 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']); + } } diff --git a/tsconfig.app.json b/tsconfig.app.json index 433b0fd..8c5e73e 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -13,6 +13,9 @@ "module": "ESNext", "moduleResolution": "Bundler", "paths": { + "@icons/*": [ + "node_modules/vue-material-design-icons/*" + ], "@/*": [ "src/*" ] diff --git a/vite.config.ts b/vite.config.ts index 7267470..0d0a2ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ export default createAppConfig( resolve: { alias: { '@': path.resolve(__dirname, 'src'), + '@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'), }, }, build: {