From e44657bf7e36a2d2b6de96de6eca7b303bd5c494 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 11 Apr 2026 04:00:00 +0200 Subject: [PATCH] feat(exchange-rate): make providers extendible via module Registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exchange rate providers are now pluggable via the module Registry. The four built-in drivers (currency_converter, currency_freak, currency_layer, open_exchange_rate) move from a static config array into App\\Providers\\DriverRegistryProvider, which calls Registry::registerExchangeRateDriver() for each during app boot with metadata the frontend needs: label (i18n key), website (help-text URL), and config_fields (schema for driver-specific driver_config JSON). The Currency Converter's server-type selector and dedicated URL field — previously hardcoded in ExchangeRateProviderModal.vue — are now just another config_fields entry with a visible_when rule that shows the URL input only when type=DEDICATED. Any module that wants to ship a custom driver gets the same treatment for free: declare config_fields in the registration, and the host app's modal renders them automatically. ExchangeRateDriverFactory::make() falls back to Registry::driverMeta() when a name isn't in the local built-in map, and availableDrivers() merges both sources. ConfigController handles the exchange_rate_drivers key specially by mapping Registry::allDrivers('exchange_rate') to enriched option objects, so the config-file route still works for every other key. The static exchange_rate_drivers + currency_converter_servers arrays in config/invoiceshelf.php are deleted. Unit tests cover the new Registry::register/flushDrivers, the factory merging built-ins with Registry-contributed drivers, and the factory rejecting unknown names. A feature test exercises the end-to-end /api/v1/config?key=exchange_rate_drivers response shape. NOTE: this commit depends on invoiceshelf/modules package commit e44d951 which adds the Registry driver API. The package needs to be released and pinned in composer.json before a fresh composer install on this commit will work. --- .../Company/General/ConfigController.php | 35 ++- app/Providers/DriverRegistryProvider.php | 65 +++++ .../ExchangeRateDriverFactory.php | 46 +++- bootstrap/providers.php | 2 + config/invoiceshelf.php | 23 +- .../api/services/exchange-rate.service.ts | 35 ++- .../components/ExchangeRateProviderModal.vue | 256 +++++++++--------- .../ExchangeRateDriverListTest.php | 81 ++++++ tests/Unit/ExchangeRateDriverFactoryTest.php | 67 +++++ tests/Unit/RegistryDriverTest.php | 68 +++++ 10 files changed, 511 insertions(+), 167 deletions(-) create mode 100644 app/Providers/DriverRegistryProvider.php create mode 100644 tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php create mode 100644 tests/Unit/ExchangeRateDriverFactoryTest.php create mode 100644 tests/Unit/RegistryDriverTest.php diff --git a/app/Http/Controllers/Company/General/ConfigController.php b/app/Http/Controllers/Company/General/ConfigController.php index c813d686..5959784f 100644 --- a/app/Http/Controllers/Company/General/ConfigController.php +++ b/app/Http/Controllers/Company/General/ConfigController.php @@ -3,20 +3,47 @@ namespace App\Http\Controllers\Company\General; use App\Http\Controllers\Controller; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\Response; +use InvoiceShelf\Modules\Registry; class ConfigController extends Controller { /** * Handle the incoming request. - * - * @return Response */ - public function __invoke(Request $request) + public function __invoke(Request $request): JsonResponse { + if ($request->key === 'exchange_rate_drivers') { + return response()->json([ + 'exchange_rate_drivers' => $this->exchangeRateDrivers(), + ]); + } + return response()->json([ $request->key => config('invoiceshelf.'.$request->key), ]); } + + /** + * Build the exchange rate driver list from the module Registry. + * + * Returns enriched objects (with label, website, and config_fields) so the + * frontend can render driver-specific configuration forms without hardcoding + * any per-driver UI. + * + * @return array> + */ + protected function exchangeRateDrivers(): array + { + return collect(Registry::allDrivers('exchange_rate')) + ->map(fn (array $meta, string $name) => [ + 'value' => $name, + 'label' => $meta['label'] ?? $name, + 'website' => $meta['website'] ?? '', + 'config_fields' => $meta['config_fields'] ?? [], + ]) + ->values() + ->all(); + } } diff --git a/app/Providers/DriverRegistryProvider.php b/app/Providers/DriverRegistryProvider.php new file mode 100644 index 00000000..aecc4d8e --- /dev/null +++ b/app/Providers/DriverRegistryProvider.php @@ -0,0 +1,65 @@ +registerExchangeRateDrivers(); + } + + protected function registerExchangeRateDrivers(): void + { + Registry::registerExchangeRateDriver('currency_converter', [ + 'class' => CurrencyConverterDriver::class, + 'label' => 'settings.exchange_rate.currency_converter', + 'website' => 'https://www.currencyconverterapi.com', + 'config_fields' => [ + [ + 'key' => 'type', + 'type' => 'select', + 'label' => 'settings.exchange_rate.server', + 'options' => [ + ['label' => 'settings.preferences.premium', 'value' => 'PREMIUM'], + ['label' => 'settings.preferences.prepaid', 'value' => 'PREPAID'], + ['label' => 'settings.preferences.free', 'value' => 'FREE'], + ['label' => 'settings.preferences.dedicated', 'value' => 'DEDICATED'], + ], + 'default' => 'FREE', + ], + [ + 'key' => 'url', + 'type' => 'text', + 'label' => 'settings.exchange_rate.url', + 'visible_when' => ['type' => 'DEDICATED'], + ], + ], + ]); + + Registry::registerExchangeRateDriver('currency_freak', [ + 'class' => CurrencyFreakDriver::class, + 'label' => 'settings.exchange_rate.currency_freak', + 'website' => 'https://currencyfreaks.com', + ]); + + Registry::registerExchangeRateDriver('currency_layer', [ + 'class' => CurrencyLayerDriver::class, + 'label' => 'settings.exchange_rate.currency_layer', + 'website' => 'https://currencylayer.com', + ]); + + Registry::registerExchangeRateDriver('open_exchange_rate', [ + 'class' => OpenExchangeRateDriver::class, + 'label' => 'settings.exchange_rate.open_exchange_rate', + 'website' => 'https://openexchangerates.org', + ]); + } +} diff --git a/app/Services/ExchangeRate/ExchangeRateDriverFactory.php b/app/Services/ExchangeRate/ExchangeRateDriverFactory.php index 766b883d..265cde40 100644 --- a/app/Services/ExchangeRate/ExchangeRateDriverFactory.php +++ b/app/Services/ExchangeRate/ExchangeRateDriverFactory.php @@ -3,10 +3,20 @@ namespace App\Services\ExchangeRate; use InvalidArgumentException; +use InvoiceShelf\Modules\Registry; class ExchangeRateDriverFactory { - /** @var array> */ + /** + * Built-in driver fallback map. + * + * Kept as a backstop so that direct calls to register() (without going through + * the module Registry) continue to work. Built-in drivers are also registered + * via the Registry by DriverRegistryProvider — that registration is the + * canonical source for driver metadata (label, website, config_fields). + * + * @var array> + */ protected static array $drivers = [ 'currency_freak' => CurrencyFreakDriver::class, 'currency_layer' => CurrencyLayerDriver::class, @@ -15,7 +25,11 @@ class ExchangeRateDriverFactory ]; /** - * Register a custom exchange rate driver (for module extensibility). + * Register a custom exchange rate driver directly with the factory. + * + * Modules should prefer Registry::registerExchangeRateDriver() instead, which + * carries metadata (label, website, config_fields) the frontend needs to render + * a configuration form for the driver. */ public static function register(string $name, string $driverClass): void { @@ -24,7 +38,7 @@ class ExchangeRateDriverFactory public static function make(string $driver, string $apiKey, array $config = []): ExchangeRateDriver { - $class = static::$drivers[$driver] ?? null; + $class = static::resolveDriverClass($driver); if (! $class) { throw new InvalidArgumentException("Unknown exchange rate driver: {$driver}"); @@ -34,10 +48,32 @@ class ExchangeRateDriverFactory } /** - * Get all registered driver names. + * Get all known driver names — both built-in/factory-registered and Registry-registered. + * + * @return array */ public static function availableDrivers(): array { - return array_keys(static::$drivers); + $local = array_keys(static::$drivers); + $registry = array_keys(Registry::allDrivers('exchange_rate')); + + return array_values(array_unique(array_merge($local, $registry))); + } + + /** + * Resolve a driver name to its concrete class. + * + * Checks the local $drivers map first (built-ins and factory::register() calls), + * then falls back to the module Registry. + */ + protected static function resolveDriverClass(string $driver): ?string + { + if (isset(static::$drivers[$driver])) { + return static::$drivers[$driver]; + } + + $meta = Registry::driverMeta('exchange_rate', $driver); + + return $meta['class'] ?? null; } } diff --git a/bootstrap/providers.php b/bootstrap/providers.php index afc762e0..13cbe3c7 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,7 @@ use App\Providers\AppConfigProvider; use App\Providers\AppServiceProvider; +use App\Providers\DriverRegistryProvider; use App\Providers\DropboxServiceProvider; use App\Providers\PdfServiceProvider; use App\Providers\RouteServiceProvider; @@ -15,5 +16,6 @@ return [ DropboxServiceProvider::class, ViewServiceProvider::class, PdfServiceProvider::class, + DriverRegistryProvider::class, AppConfigProvider::class, ]; diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 95499423..1f8b15dc 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -550,24 +550,13 @@ return [ ], /* - * List of exchange rate provider (currency converter server's) + * Exchange rate drivers and Currency Converter server options used to live here as + * static arrays. Both have moved into the module Registry — built-in drivers are + * registered by App\Providers\DriverRegistryProvider, and custom drivers can be + * registered by modules via Registry::registerExchangeRateDriver(). The driver + * list is served to the frontend by ConfigController via the same + * /api/v1/config?key=exchange_rate_drivers endpoint. */ - 'currency_converter_servers' => [ - ['key' => 'settings.preferences.premium', 'value' => 'PREMIUM'], - ['key' => 'settings.preferences.prepaid', 'value' => 'PREPAID'], - ['key' => 'settings.preferences.free', 'value' => 'FREE'], - ['key' => 'settings.preferences.dedicated', 'value' => 'DEDICATED'], - ], - - /* - * List of exchange rate drivers - */ - 'exchange_rate_drivers' => [ - ['key' => 'settings.exchange_rate.currency_converter', 'value' => 'currency_converter'], - ['key' => 'settings.exchange_rate.currency_freak', 'value' => 'currency_freak'], - ['key' => 'settings.exchange_rate.currency_layer', 'value' => 'currency_layer'], - ['key' => 'settings.exchange_rate.open_exchange_rate', 'value' => 'open_exchange_rate'], - ], /* * List of Custom field supported models diff --git a/resources/scripts/api/services/exchange-rate.service.ts b/resources/scripts/api/services/exchange-rate.service.ts index 7718591b..dd61d126 100644 --- a/resources/scripts/api/services/exchange-rate.service.ts +++ b/resources/scripts/api/services/exchange-rate.service.ts @@ -40,17 +40,29 @@ export interface BulkUpdatePayload { }> } -export interface ConfigOption { - key: string +export interface DriverConfigFieldOption { + label: string value: string } -export interface ConfigDriversResponse { - exchange_rate_drivers: ConfigOption[] +export interface DriverConfigField { + key: string + type: 'text' | 'select' + label: string + options?: DriverConfigFieldOption[] + default?: string + visible_when?: Record } -export interface ConfigServersResponse { - currency_converter_servers: ConfigOption[] +export interface ExchangeRateDriverOption { + value: string + label: string + website?: string + config_fields?: DriverConfigField[] +} + +export interface ConfigDriversResponse { + exchange_rate_drivers: ExchangeRateDriverOption[] } export interface SupportedCurrenciesParams { @@ -133,15 +145,12 @@ export const exchangeRateService = { }, // Config - // Backend returns { exchange_rate_drivers: Array<{ key, value }> } + // Backend returns { exchange_rate_drivers: ExchangeRateDriverOption[] } where each option + // includes the metadata needed to render a driver-specific config form (label, website, + // config_fields). The list is built dynamically from the module Registry, so module- + // contributed drivers appear here automatically. async getDrivers(): Promise { const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } }) return data }, - - // Backend returns { currency_converter_servers: Array<{ key, value }> } - async getCurrencyConverterServers(): Promise { - const { data } = await client.get(API.CONFIG, { params: { key: 'currency_converter_servers' } }) - return data - }, } diff --git a/resources/scripts/features/company/settings/components/ExchangeRateProviderModal.vue b/resources/scripts/features/company/settings/components/ExchangeRateProviderModal.vue index dbf030fd..d4153305 100644 --- a/resources/scripts/features/company/settings/components/ExchangeRateProviderModal.vue +++ b/resources/scripts/features/company/settings/components/ExchangeRateProviderModal.vue @@ -2,21 +2,16 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import useVuelidate from '@vuelidate/core' -import { required, helpers, requiredIf, url } from '@vuelidate/validators' +import { required, helpers } from '@vuelidate/validators' import { useModalStore } from '@/scripts/stores/modal.store' import { useNotificationStore } from '@/scripts/stores/notification.store' import { exchangeRateService } from '@/scripts/api/services/exchange-rate.service' +import type { + DriverConfigField, + ExchangeRateDriverOption, +} from '@/scripts/api/services/exchange-rate.service' import { useDebounceFn } from '@vueuse/core' -interface DriverOption { - key: string - value: string -} - -interface ServerOption { - value: string -} - interface ExchangeRateForm { id: number | null driver: string @@ -25,11 +20,6 @@ interface ExchangeRateForm { currencies: string[] } -interface CurrencyConverterForm { - type: string - url: string -} - const { t } = useI18n() const modalStore = useModalStore() const notificationStore = useNotificationStore() @@ -40,21 +30,19 @@ const isFetchingCurrencies = ref(false) const isEdit = ref(false) const currenciesAlreadyInUsed = ref([]) const supportedCurrencies = ref([]) -const serverOptions = ref([]) -const drivers = ref([]) +const drivers = ref([]) const currentExchangeRate = ref({ id: null, - driver: 'currency_converter', + driver: '', key: null, active: true, currencies: [], }) -const currencyConverter = ref({ - type: '', - url: '', -}) +// Generic key/value bag for driver-specific config (driver_config JSON column). +// Populated from each driver's `config_fields` metadata; reset when driver changes. +const driverConfig = ref>({}) const modalActive = computed( () => @@ -62,36 +50,41 @@ const modalActive = computed( modalStore.componentName === 'ExchangeRateProviderModal' ) -const isCurrencyConverter = computed( - () => currentExchangeRate.value.driver === 'currency_converter' +const selectedDriver = computed(() => + drivers.value.find((d) => d.value === currentExchangeRate.value.driver) ) -const isDedicatedServer = computed( - () => currencyConverter.value.type === 'DEDICATED' +const driverSite = computed(() => selectedDriver.value?.website ?? '') + +const driverConfigFields = computed( + () => selectedDriver.value?.config_fields ?? [] ) -const driverSite = computed(() => { - switch (currentExchangeRate.value.driver) { - case 'currency_converter': - return 'https://www.currencyconverterapi.com' - case 'currency_freak': - return 'https://currencyfreaks.com' - case 'currency_layer': - return 'https://currencylayer.com' - case 'open_exchange_rate': - return 'https://openexchangerates.org' - default: - return '' - } -}) +const visibleConfigFields = computed(() => + driverConfigFields.value.filter((field) => isFieldVisible(field)) +) const driversLists = computed(() => - drivers.value.map((item) => ({ - ...item, - key: t(item.key), + drivers.value.map((driver) => ({ + value: driver.value, + label: t(driver.label), })) ) +function isFieldVisible(field: DriverConfigField): boolean { + if (!field.visible_when) return true + return Object.entries(field.visible_when).every( + ([key, value]) => driverConfig.value[key] === value + ) +} + +function fieldOptions(field: DriverConfigField) { + return (field.options ?? []).map((option) => ({ + value: option.value, + label: t(option.label), + })) +} + const rules = computed(() => ({ driver: { required: helpers.withMessage(t('validation.required'), required), @@ -102,29 +95,10 @@ const rules = computed(() => ({ currencies: { required: helpers.withMessage(t('validation.required'), required), }, - converterType: { - required: helpers.withMessage( - t('validation.required'), - requiredIf(isCurrencyConverter) - ), - }, - converterUrl: { - required: helpers.withMessage( - t('validation.required'), - requiredIf(isDedicatedServer) - ), - url: helpers.withMessage(t('validation.invalid_url'), url), - }, })) const v$ = useVuelidate(rules, currentExchangeRate) -watch(isCurrencyConverter, (newVal) => { - if (newVal) { - fetchServers() - } -}, { immediate: true }) - const fetchCurrenciesDebounced = useDebounceFn(() => { fetchCurrencies() }, 500) @@ -133,9 +107,11 @@ watch(() => currentExchangeRate.value.key, (newVal) => { if (newVal) fetchCurrenciesDebounced() }) -watch(() => currencyConverter.value.type, (newVal) => { - if (newVal) fetchCurrenciesDebounced() -}) +// Refetch supported currencies whenever any driver_config field changes — +// some drivers (e.g. Currency Converter) need the config to construct the API URL. +watch(driverConfig, () => { + if (currentExchangeRate.value.key) fetchCurrenciesDebounced() +}, { deep: true }) function dismiss(): void { currenciesAlreadyInUsed.value = [] @@ -154,18 +130,31 @@ function resetCurrency(): void { currentExchangeRate.value.key = null currentExchangeRate.value.currencies = [] supportedCurrencies.value = [] + resetDriverConfig() +} + +// Rebuild driver_config from the selected driver's field defaults whenever the +// driver changes. Without this, fields from a previous driver would linger. +function resetDriverConfig(): void { + const fresh: Record = {} + for (const field of driverConfigFields.value) { + if (field.default !== undefined) { + fresh[field.key] = field.default + } + } + driverConfig.value = fresh } function resetModalData(): void { supportedCurrencies.value = [] currentExchangeRate.value = { id: null, - driver: 'currency_converter', + driver: drivers.value[0]?.value ?? '', key: null, active: true, currencies: [], } - currencyConverter.value = { type: '', url: '' } + resetDriverConfig() currenciesAlreadyInUsed.value = [] isEdit.value = false } @@ -173,56 +162,57 @@ function resetModalData(): void { async function fetchInitialData(): Promise { isFetchingInitialData.value = true - const driversRes = await exchangeRateService.getDrivers() - if (driversRes.exchange_rate_drivers) { - drivers.value = (driversRes.exchange_rate_drivers as unknown as DriverOption[]) - } + try { + const driversRes = await exchangeRateService.getDrivers() + drivers.value = driversRes.exchange_rate_drivers ?? [] - if (modalStore.data && typeof modalStore.data === 'number') { - isEdit.value = true - const response = await exchangeRateService.getProvider(modalStore.data) - if (response.data) { - const provider = response.data - currentExchangeRate.value = { - id: provider.id, - driver: provider.driver, - key: provider.key, - active: provider.active, - currencies: provider.currencies ?? [], + if (modalStore.data && typeof modalStore.data === 'number') { + isEdit.value = true + const response = await exchangeRateService.getProvider(modalStore.data) + if (response.data) { + const provider = response.data + currentExchangeRate.value = { + id: provider.id, + driver: provider.driver, + key: provider.key, + active: provider.active, + currencies: provider.currencies ?? [], + } + // Hydrate driverConfig from the persisted JSON column. Field defaults from + // the schema fill any missing keys. + const persisted = (provider as { driver_config?: Record }).driver_config ?? {} + const merged: Record = {} + for (const field of driverConfigFields.value) { + merged[field.key] = persisted[field.key] ?? field.default ?? '' + } + driverConfig.value = merged } + } else { + currentExchangeRate.value.driver = drivers.value[0]?.value ?? '' + resetDriverConfig() } - } else { - currentExchangeRate.value.driver = 'currency_converter' + } finally { + isFetchingInitialData.value = false } - - isFetchingInitialData.value = false -} - -async function fetchServers(): Promise { - const res = await exchangeRateService.getCurrencyConverterServers() - serverOptions.value = (res as Record).currency_converter_servers ?? [] - currencyConverter.value.type = 'FREE' } async function fetchCurrencies(): Promise { const { driver, key } = currentExchangeRate.value if (!driver || !key) return - if (isCurrencyConverter.value && !currencyConverter.value.type) return + // If any visible config field is empty, hold off — the driver likely needs it + // to talk to its API. + for (const field of visibleConfigFields.value) { + if (!driverConfig.value[field.key]) return + } isFetchingCurrencies.value = true try { - const driverConfig: Record = {} - if (currencyConverter.value.type) { - driverConfig.type = currencyConverter.value.type - } - if (currencyConverter.value.url) { - driverConfig.url = currencyConverter.value.url - } + const config = buildDriverConfigPayload() const res = await exchangeRateService.getSupportedCurrencies({ driver, key, - driver_config: Object.keys(driverConfig).length ? driverConfig : undefined, + driver_config: Object.keys(config).length ? config : undefined, }) supportedCurrencies.value = res.supportedCurrencies ?? [] } finally { @@ -230,6 +220,19 @@ async function fetchCurrencies(): Promise { } } +// Strip values for fields that aren't currently visible (e.g. the URL field +// when the server type isn't DEDICATED) so we never persist stale config. +function buildDriverConfigPayload(): Record { + const payload: Record = {} + for (const field of visibleConfigFields.value) { + const value = driverConfig.value[field.key] + if (value !== undefined && value !== '') { + payload[field.key] = value + } + } + return payload +} + async function submitExchangeRate(): Promise { v$.value.$touch() if (v$.value.$invalid) return @@ -238,11 +241,9 @@ async function submitExchangeRate(): Promise { ...currentExchangeRate.value, } - if (isCurrencyConverter.value) { - data.driver_config = { ...currencyConverter.value } - if (!isDedicatedServer.value) { - (data.driver_config as CurrencyConverterForm).url = '' - } + const config = buildDriverConfigPayload() + if (Object.keys(config).length) { + data.driver_config = config } isSaving.value = true @@ -317,30 +318,41 @@ function closeExchangeRateModal(): void { :content-loading="isFetchingInitialData" value-prop="value" :can-deselect="true" - label="key" + label="label" :searchable="true" :invalid="v$.driver.$error" - track-by="key" + track-by="label" @update:model-value="resetCurrency" /> + + - - - - 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->withHeaders([ + 'company' => $user->companies()->first()->id, + ]); + Sanctum::actingAs($user, ['*']); +}); + +test('config endpoint returns built-in exchange rate drivers from the Registry', function () { + $response = getJson('/api/v1/config?key=exchange_rate_drivers')->assertOk(); + + $drivers = collect($response->json('exchange_rate_drivers')); + + expect($drivers->pluck('value')->all()) + ->toContain('currency_converter') + ->toContain('currency_freak') + ->toContain('currency_layer') + ->toContain('open_exchange_rate'); +}); + +test('built-in drivers carry label, website, and config_fields metadata', function () { + $response = getJson('/api/v1/config?key=exchange_rate_drivers')->assertOk(); + + $drivers = collect($response->json('exchange_rate_drivers')); + + $converter = $drivers->firstWhere('value', 'currency_converter'); + expect($converter['label'])->toBe('settings.exchange_rate.currency_converter') + ->and($converter['website'])->toBe('https://www.currencyconverterapi.com') + ->and($converter['config_fields'])->toBeArray()->not->toBeEmpty(); + + // The Currency Converter driver declares two config_fields (server type + URL). + // The URL field is conditionally visible when type=DEDICATED. + $urlField = collect($converter['config_fields'])->firstWhere('key', 'url'); + expect($urlField['visible_when'])->toBe(['type' => 'DEDICATED']); + + $freak = $drivers->firstWhere('value', 'currency_freak'); + expect($freak['website'])->toBe('https://currencyfreaks.com') + ->and($freak['config_fields'])->toBe([]); +}); + +test('module-registered exchange rate drivers appear in the config endpoint response', function () { + Registry::registerExchangeRateDriver('test_module_driver', [ + 'class' => 'Modules\\Test\\Drivers\\TestDriver', + 'label' => 'test_module::drivers.test', + 'website' => 'https://test.example.com', + ]); + + try { + $response = getJson('/api/v1/config?key=exchange_rate_drivers')->assertOk(); + + $drivers = collect($response->json('exchange_rate_drivers')); + $custom = $drivers->firstWhere('value', 'test_module_driver'); + + expect($custom)->not->toBeNull() + ->and($custom['label'])->toBe('test_module::drivers.test') + ->and($custom['website'])->toBe('https://test.example.com'); + } finally { + // Clean up our test-only registration without wiping the built-ins, + // which would break sibling tests in the same process. + unset(Registry::$drivers['exchange_rate']['test_module_driver']); + } +}); + +test('non-driver config keys still fall through to config files', function () { + // Sanity check that the controller refactor didn't break unrelated keys. + $response = getJson('/api/v1/config?key=custom_field_models')->assertOk(); + + expect($response->json('custom_field_models'))->toBeArray()->not->toBeEmpty(); +}); diff --git a/tests/Unit/ExchangeRateDriverFactoryTest.php b/tests/Unit/ExchangeRateDriverFactoryTest.php new file mode 100644 index 00000000..7a57507b --- /dev/null +++ b/tests/Unit/ExchangeRateDriverFactoryTest.php @@ -0,0 +1,67 @@ +toBeInstanceOf(CurrencyFreakDriver::class); +}); + +test('make resolves Registry-only drivers via metadata', function () { + $fakeClass = new class('', []) extends ExchangeRateDriver + { + public function getExchangeRate(string $baseCurrency, string $targetCurrency): array + { + return []; + } + + public function getSupportedCurrencies(): array + { + return []; + } + + public function validateConnection(): array + { + return []; + } + }; + + Registry::registerExchangeRateDriver('registry_only_driver', [ + 'class' => $fakeClass::class, + 'label' => 'test.label', + ]); + + try { + $driver = ExchangeRateDriverFactory::make('registry_only_driver', 'fake-key'); + expect($driver)->toBeInstanceOf(ExchangeRateDriver::class); + } finally { + unset(Registry::$drivers['exchange_rate']['registry_only_driver']); + } +}); + +test('make throws for unknown drivers', function () { + expect(fn () => ExchangeRateDriverFactory::make('definitely_not_a_real_driver', 'k')) + ->toThrow(InvalidArgumentException::class); +}); + +test('availableDrivers merges built-in and Registry-registered drivers', function () { + Registry::registerExchangeRateDriver('extra_driver', [ + 'class' => CurrencyFreakDriver::class, + 'label' => 'extra.label', + ]); + + try { + $available = ExchangeRateDriverFactory::availableDrivers(); + + expect($available) + ->toContain('currency_freak') + ->toContain('currency_converter') + ->toContain('extra_driver'); + } finally { + unset(Registry::$drivers['exchange_rate']['extra_driver']); + } +}); diff --git a/tests/Unit/RegistryDriverTest.php b/tests/Unit/RegistryDriverTest.php new file mode 100644 index 00000000..f486d097 --- /dev/null +++ b/tests/Unit/RegistryDriverTest.php @@ -0,0 +1,68 @@ + 'FakeDriver', + 'label' => 'fake.label', + ]); + + expect(Registry::driverMeta('exchange_rate', 'fake_provider')) + ->toEqual([ + 'class' => 'FakeDriver', + 'label' => 'fake.label', + ]); +}); + +test('registerExchangeRateDriver delegates to registerDriver with the exchange_rate type', function () { + Registry::flushDrivers(); + + Registry::registerExchangeRateDriver('fake_provider', [ + 'class' => 'FakeDriver', + 'label' => 'fake.label', + ]); + + expect(Registry::driverMeta('exchange_rate', 'fake_provider')) + ->not->toBeNull() + ->and(Registry::allDrivers('exchange_rate')) + ->toHaveKey('fake_provider'); +}); + +test('allDrivers returns an empty array for unknown types', function () { + Registry::flushDrivers(); + + expect(Registry::allDrivers('nonexistent'))->toBe([]); +}); + +test('driverMeta returns null for unknown drivers', function () { + Registry::flushDrivers(); + + expect(Registry::driverMeta('exchange_rate', 'nonexistent'))->toBeNull(); +}); + +test('flushDrivers clears all driver registrations', function () { + Registry::registerExchangeRateDriver('a', ['class' => 'A', 'label' => 'a']); + Registry::registerExchangeRateDriver('b', ['class' => 'B', 'label' => 'b']); + + Registry::flushDrivers(); + + expect(Registry::allDrivers('exchange_rate'))->toBe([]); +}); + +test('flush does not clear driver registrations', function () { + Registry::registerExchangeRateDriver('persists', [ + 'class' => 'PersistDriver', + 'label' => 'persist.label', + ]); + + Registry::flush(); + + expect(Registry::driverMeta('exchange_rate', 'persists'))->not->toBeNull(); +});