feat(exchange-rate): make providers extendible via module Registry

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.
This commit is contained in:
Darko Gjorgjijoski
2026-04-11 04:00:00 +02:00
parent 112cc56922
commit e44657bf7e
10 changed files with 511 additions and 167 deletions

View File

@@ -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<int, array<string, mixed>>
*/
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();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Services\ExchangeRate\CurrencyConverterDriver;
use App\Services\ExchangeRate\CurrencyFreakDriver;
use App\Services\ExchangeRate\CurrencyLayerDriver;
use App\Services\ExchangeRate\OpenExchangeRateDriver;
use Illuminate\Support\ServiceProvider;
use InvoiceShelf\Modules\Registry;
class DriverRegistryProvider extends ServiceProvider
{
public function boot(): void
{
$this->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',
]);
}
}

View File

@@ -3,10 +3,20 @@
namespace App\Services\ExchangeRate;
use InvalidArgumentException;
use InvoiceShelf\Modules\Registry;
class ExchangeRateDriverFactory
{
/** @var array<string, class-string<ExchangeRateDriver>> */
/**
* 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<string, class-string<ExchangeRateDriver>>
*/
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<int, string>
*/
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;
}
}

View File

@@ -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,
];

View File

@@ -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

View File

@@ -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<string, string>
}
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<ConfigDriversResponse> {
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<ConfigServersResponse> {
const { data } = await client.get(API.CONFIG, { params: { key: 'currency_converter_servers' } })
return data
},
}

View File

@@ -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<boolean>(false)
const isEdit = ref<boolean>(false)
const currenciesAlreadyInUsed = ref<string[]>([])
const supportedCurrencies = ref<string[]>([])
const serverOptions = ref<ServerOption[]>([])
const drivers = ref<DriverOption[]>([])
const drivers = ref<ExchangeRateDriverOption[]>([])
const currentExchangeRate = ref<ExchangeRateForm>({
id: null,
driver: 'currency_converter',
driver: '',
key: null,
active: true,
currencies: [],
})
const currencyConverter = ref<CurrencyConverterForm>({
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<Record<string, string>>({})
const modalActive = computed<boolean>(
() =>
@@ -62,36 +50,41 @@ const modalActive = computed<boolean>(
modalStore.componentName === 'ExchangeRateProviderModal'
)
const isCurrencyConverter = computed<boolean>(
() => currentExchangeRate.value.driver === 'currency_converter'
const selectedDriver = computed<ExchangeRateDriverOption | undefined>(() =>
drivers.value.find((d) => d.value === currentExchangeRate.value.driver)
)
const isDedicatedServer = computed<boolean>(
() => currencyConverter.value.type === 'DEDICATED'
const driverSite = computed<string>(() => selectedDriver.value?.website ?? '')
const driverConfigFields = computed<DriverConfigField[]>(
() => selectedDriver.value?.config_fields ?? []
)
const driverSite = computed<string>(() => {
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<DriverConfigField[]>(() =>
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<string, string> = {}
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<void> {
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<string, string> }).driver_config ?? {}
const merged: Record<string, string> = {}
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<void> {
const res = await exchangeRateService.getCurrencyConverterServers()
serverOptions.value = (res as Record<string, ServerOption[]>).currency_converter_servers ?? []
currencyConverter.value.type = 'FREE'
}
async function fetchCurrencies(): Promise<void> {
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<string, string> = {}
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<void> {
}
}
// 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<string, string> {
const payload: Record<string, string> = {}
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<void> {
v$.value.$touch()
if (v$.value.$invalid) return
@@ -238,11 +241,9 @@ async function submitExchangeRate(): Promise<void> {
...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"
/>
</BaseInputGroup>
<!-- Driver-specific config fields rendered from metadata -->
<BaseInputGroup
v-if="isCurrencyConverter"
required
:label="$t('settings.exchange_rate.server')"
v-for="field in visibleConfigFields"
:key="field.key"
:label="$t(field.label)"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currencyConverter.type"
v-if="field.type === 'select'"
v-model="driverConfig[field.key]"
:options="fieldOptions(field)"
:content-loading="isFetchingInitialData"
value-prop="value"
searchable
:options="serverOptions"
label="value"
track-by="value"
label="label"
:can-deselect="false"
:searchable="true"
track-by="label"
@update:model-value="resetCurrency"
/>
<BaseInput
v-else
v-model="driverConfig[field.key]"
:content-loading="isFetchingInitialData"
type="text"
:name="field.key"
/>
</BaseInputGroup>
<BaseInputGroup
@@ -383,18 +395,6 @@ function closeExchangeRateModal(): void {
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isDedicatedServer"
:label="$t('settings.exchange_rate.url')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="currencyConverter.url"
:content-loading="isFetchingInitialData"
type="url"
/>
</BaseInputGroup>
<BaseSwitch
v-model="currentExchangeRate.active"
class="flex"

View File

@@ -0,0 +1,81 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use InvoiceShelf\Modules\Registry;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\getJson;
beforeEach(function () {
Artisan::call('db:seed', ['--class' => '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();
});

View File

@@ -0,0 +1,67 @@
<?php
use App\Services\ExchangeRate\CurrencyFreakDriver;
use App\Services\ExchangeRate\ExchangeRateDriver;
use App\Services\ExchangeRate\ExchangeRateDriverFactory;
use InvoiceShelf\Modules\Registry;
test('make resolves built-in drivers from the factory map', function () {
$driver = ExchangeRateDriverFactory::make('currency_freak', 'fake-key');
expect($driver)->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']);
}
});

View File

@@ -0,0 +1,68 @@
<?php
use InvoiceShelf\Modules\Registry;
afterEach(function () {
Registry::flushDrivers();
});
test('registerDriver stores driver metadata under its type', function () {
Registry::flushDrivers();
Registry::registerDriver('exchange_rate', 'fake_provider', [
'class' => '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();
});