mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 11:14:06 +00:00
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:
@@ -3,20 +3,47 @@
|
|||||||
namespace App\Http\Controllers\Company\General;
|
namespace App\Http\Controllers\Company\General;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use InvoiceShelf\Modules\Registry;
|
||||||
|
|
||||||
class ConfigController extends Controller
|
class ConfigController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle the incoming request.
|
* 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([
|
return response()->json([
|
||||||
$request->key => config('invoiceshelf.'.$request->key),
|
$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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
app/Providers/DriverRegistryProvider.php
Normal file
65
app/Providers/DriverRegistryProvider.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,20 @@
|
|||||||
namespace App\Services\ExchangeRate;
|
namespace App\Services\ExchangeRate;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use InvoiceShelf\Modules\Registry;
|
||||||
|
|
||||||
class ExchangeRateDriverFactory
|
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 = [
|
protected static array $drivers = [
|
||||||
'currency_freak' => CurrencyFreakDriver::class,
|
'currency_freak' => CurrencyFreakDriver::class,
|
||||||
'currency_layer' => CurrencyLayerDriver::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
|
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
|
public static function make(string $driver, string $apiKey, array $config = []): ExchangeRateDriver
|
||||||
{
|
{
|
||||||
$class = static::$drivers[$driver] ?? null;
|
$class = static::resolveDriverClass($driver);
|
||||||
|
|
||||||
if (! $class) {
|
if (! $class) {
|
||||||
throw new InvalidArgumentException("Unknown exchange rate driver: {$driver}");
|
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
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Providers\AppConfigProvider;
|
use App\Providers\AppConfigProvider;
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\DriverRegistryProvider;
|
||||||
use App\Providers\DropboxServiceProvider;
|
use App\Providers\DropboxServiceProvider;
|
||||||
use App\Providers\PdfServiceProvider;
|
use App\Providers\PdfServiceProvider;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
@@ -15,5 +16,6 @@ return [
|
|||||||
DropboxServiceProvider::class,
|
DropboxServiceProvider::class,
|
||||||
ViewServiceProvider::class,
|
ViewServiceProvider::class,
|
||||||
PdfServiceProvider::class,
|
PdfServiceProvider::class,
|
||||||
|
DriverRegistryProvider::class,
|
||||||
AppConfigProvider::class,
|
AppConfigProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
* List of Custom field supported models
|
||||||
|
|||||||
@@ -40,17 +40,29 @@ export interface BulkUpdatePayload {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigOption {
|
export interface DriverConfigFieldOption {
|
||||||
key: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigDriversResponse {
|
export interface DriverConfigField {
|
||||||
exchange_rate_drivers: ConfigOption[]
|
key: string
|
||||||
|
type: 'text' | 'select'
|
||||||
|
label: string
|
||||||
|
options?: DriverConfigFieldOption[]
|
||||||
|
default?: string
|
||||||
|
visible_when?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigServersResponse {
|
export interface ExchangeRateDriverOption {
|
||||||
currency_converter_servers: ConfigOption[]
|
value: string
|
||||||
|
label: string
|
||||||
|
website?: string
|
||||||
|
config_fields?: DriverConfigField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDriversResponse {
|
||||||
|
exchange_rate_drivers: ExchangeRateDriverOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SupportedCurrenciesParams {
|
export interface SupportedCurrenciesParams {
|
||||||
@@ -133,15 +145,12 @@ export const exchangeRateService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Config
|
// 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> {
|
async getDrivers(): Promise<ConfigDriversResponse> {
|
||||||
const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } })
|
const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } })
|
||||||
return data
|
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
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,16 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import useVuelidate from '@vuelidate/core'
|
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 { useModalStore } from '@/scripts/stores/modal.store'
|
||||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||||
import { exchangeRateService } from '@/scripts/api/services/exchange-rate.service'
|
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'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
|
||||||
interface DriverOption {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerOption {
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExchangeRateForm {
|
interface ExchangeRateForm {
|
||||||
id: number | null
|
id: number | null
|
||||||
driver: string
|
driver: string
|
||||||
@@ -25,11 +20,6 @@ interface ExchangeRateForm {
|
|||||||
currencies: string[]
|
currencies: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurrencyConverterForm {
|
|
||||||
type: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const modalStore = useModalStore()
|
const modalStore = useModalStore()
|
||||||
const notificationStore = useNotificationStore()
|
const notificationStore = useNotificationStore()
|
||||||
@@ -40,21 +30,19 @@ const isFetchingCurrencies = ref<boolean>(false)
|
|||||||
const isEdit = ref<boolean>(false)
|
const isEdit = ref<boolean>(false)
|
||||||
const currenciesAlreadyInUsed = ref<string[]>([])
|
const currenciesAlreadyInUsed = ref<string[]>([])
|
||||||
const supportedCurrencies = ref<string[]>([])
|
const supportedCurrencies = ref<string[]>([])
|
||||||
const serverOptions = ref<ServerOption[]>([])
|
const drivers = ref<ExchangeRateDriverOption[]>([])
|
||||||
const drivers = ref<DriverOption[]>([])
|
|
||||||
|
|
||||||
const currentExchangeRate = ref<ExchangeRateForm>({
|
const currentExchangeRate = ref<ExchangeRateForm>({
|
||||||
id: null,
|
id: null,
|
||||||
driver: 'currency_converter',
|
driver: '',
|
||||||
key: null,
|
key: null,
|
||||||
active: true,
|
active: true,
|
||||||
currencies: [],
|
currencies: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const currencyConverter = ref<CurrencyConverterForm>({
|
// Generic key/value bag for driver-specific config (driver_config JSON column).
|
||||||
type: '',
|
// Populated from each driver's `config_fields` metadata; reset when driver changes.
|
||||||
url: '',
|
const driverConfig = ref<Record<string, string>>({})
|
||||||
})
|
|
||||||
|
|
||||||
const modalActive = computed<boolean>(
|
const modalActive = computed<boolean>(
|
||||||
() =>
|
() =>
|
||||||
@@ -62,36 +50,41 @@ const modalActive = computed<boolean>(
|
|||||||
modalStore.componentName === 'ExchangeRateProviderModal'
|
modalStore.componentName === 'ExchangeRateProviderModal'
|
||||||
)
|
)
|
||||||
|
|
||||||
const isCurrencyConverter = computed<boolean>(
|
const selectedDriver = computed<ExchangeRateDriverOption | undefined>(() =>
|
||||||
() => currentExchangeRate.value.driver === 'currency_converter'
|
drivers.value.find((d) => d.value === currentExchangeRate.value.driver)
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDedicatedServer = computed<boolean>(
|
const driverSite = computed<string>(() => selectedDriver.value?.website ?? '')
|
||||||
() => currencyConverter.value.type === 'DEDICATED'
|
|
||||||
|
const driverConfigFields = computed<DriverConfigField[]>(
|
||||||
|
() => selectedDriver.value?.config_fields ?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
const driverSite = computed<string>(() => {
|
const visibleConfigFields = computed<DriverConfigField[]>(() =>
|
||||||
switch (currentExchangeRate.value.driver) {
|
driverConfigFields.value.filter((field) => isFieldVisible(field))
|
||||||
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 driversLists = computed(() =>
|
const driversLists = computed(() =>
|
||||||
drivers.value.map((item) => ({
|
drivers.value.map((driver) => ({
|
||||||
...item,
|
value: driver.value,
|
||||||
key: t(item.key),
|
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(() => ({
|
const rules = computed(() => ({
|
||||||
driver: {
|
driver: {
|
||||||
required: helpers.withMessage(t('validation.required'), required),
|
required: helpers.withMessage(t('validation.required'), required),
|
||||||
@@ -102,29 +95,10 @@ const rules = computed(() => ({
|
|||||||
currencies: {
|
currencies: {
|
||||||
required: helpers.withMessage(t('validation.required'), required),
|
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)
|
const v$ = useVuelidate(rules, currentExchangeRate)
|
||||||
|
|
||||||
watch(isCurrencyConverter, (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
fetchServers()
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const fetchCurrenciesDebounced = useDebounceFn(() => {
|
const fetchCurrenciesDebounced = useDebounceFn(() => {
|
||||||
fetchCurrencies()
|
fetchCurrencies()
|
||||||
}, 500)
|
}, 500)
|
||||||
@@ -133,9 +107,11 @@ watch(() => currentExchangeRate.value.key, (newVal) => {
|
|||||||
if (newVal) fetchCurrenciesDebounced()
|
if (newVal) fetchCurrenciesDebounced()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => currencyConverter.value.type, (newVal) => {
|
// Refetch supported currencies whenever any driver_config field changes —
|
||||||
if (newVal) fetchCurrenciesDebounced()
|
// 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 {
|
function dismiss(): void {
|
||||||
currenciesAlreadyInUsed.value = []
|
currenciesAlreadyInUsed.value = []
|
||||||
@@ -154,18 +130,31 @@ function resetCurrency(): void {
|
|||||||
currentExchangeRate.value.key = null
|
currentExchangeRate.value.key = null
|
||||||
currentExchangeRate.value.currencies = []
|
currentExchangeRate.value.currencies = []
|
||||||
supportedCurrencies.value = []
|
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 {
|
function resetModalData(): void {
|
||||||
supportedCurrencies.value = []
|
supportedCurrencies.value = []
|
||||||
currentExchangeRate.value = {
|
currentExchangeRate.value = {
|
||||||
id: null,
|
id: null,
|
||||||
driver: 'currency_converter',
|
driver: drivers.value[0]?.value ?? '',
|
||||||
key: null,
|
key: null,
|
||||||
active: true,
|
active: true,
|
||||||
currencies: [],
|
currencies: [],
|
||||||
}
|
}
|
||||||
currencyConverter.value = { type: '', url: '' }
|
resetDriverConfig()
|
||||||
currenciesAlreadyInUsed.value = []
|
currenciesAlreadyInUsed.value = []
|
||||||
isEdit.value = false
|
isEdit.value = false
|
||||||
}
|
}
|
||||||
@@ -173,56 +162,57 @@ function resetModalData(): void {
|
|||||||
async function fetchInitialData(): Promise<void> {
|
async function fetchInitialData(): Promise<void> {
|
||||||
isFetchingInitialData.value = true
|
isFetchingInitialData.value = true
|
||||||
|
|
||||||
const driversRes = await exchangeRateService.getDrivers()
|
try {
|
||||||
if (driversRes.exchange_rate_drivers) {
|
const driversRes = await exchangeRateService.getDrivers()
|
||||||
drivers.value = (driversRes.exchange_rate_drivers as unknown as DriverOption[])
|
drivers.value = driversRes.exchange_rate_drivers ?? []
|
||||||
}
|
|
||||||
|
|
||||||
if (modalStore.data && typeof modalStore.data === 'number') {
|
if (modalStore.data && typeof modalStore.data === 'number') {
|
||||||
isEdit.value = true
|
isEdit.value = true
|
||||||
const response = await exchangeRateService.getProvider(modalStore.data)
|
const response = await exchangeRateService.getProvider(modalStore.data)
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
const provider = response.data
|
const provider = response.data
|
||||||
currentExchangeRate.value = {
|
currentExchangeRate.value = {
|
||||||
id: provider.id,
|
id: provider.id,
|
||||||
driver: provider.driver,
|
driver: provider.driver,
|
||||||
key: provider.key,
|
key: provider.key,
|
||||||
active: provider.active,
|
active: provider.active,
|
||||||
currencies: provider.currencies ?? [],
|
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 {
|
} finally {
|
||||||
currentExchangeRate.value.driver = 'currency_converter'
|
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> {
|
async function fetchCurrencies(): Promise<void> {
|
||||||
const { driver, key } = currentExchangeRate.value
|
const { driver, key } = currentExchangeRate.value
|
||||||
if (!driver || !key) return
|
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
|
isFetchingCurrencies.value = true
|
||||||
try {
|
try {
|
||||||
const driverConfig: Record<string, string> = {}
|
const config = buildDriverConfigPayload()
|
||||||
if (currencyConverter.value.type) {
|
|
||||||
driverConfig.type = currencyConverter.value.type
|
|
||||||
}
|
|
||||||
if (currencyConverter.value.url) {
|
|
||||||
driverConfig.url = currencyConverter.value.url
|
|
||||||
}
|
|
||||||
const res = await exchangeRateService.getSupportedCurrencies({
|
const res = await exchangeRateService.getSupportedCurrencies({
|
||||||
driver,
|
driver,
|
||||||
key,
|
key,
|
||||||
driver_config: Object.keys(driverConfig).length ? driverConfig : undefined,
|
driver_config: Object.keys(config).length ? config : undefined,
|
||||||
})
|
})
|
||||||
supportedCurrencies.value = res.supportedCurrencies ?? []
|
supportedCurrencies.value = res.supportedCurrencies ?? []
|
||||||
} finally {
|
} 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> {
|
async function submitExchangeRate(): Promise<void> {
|
||||||
v$.value.$touch()
|
v$.value.$touch()
|
||||||
if (v$.value.$invalid) return
|
if (v$.value.$invalid) return
|
||||||
@@ -238,11 +241,9 @@ async function submitExchangeRate(): Promise<void> {
|
|||||||
...currentExchangeRate.value,
|
...currentExchangeRate.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrencyConverter.value) {
|
const config = buildDriverConfigPayload()
|
||||||
data.driver_config = { ...currencyConverter.value }
|
if (Object.keys(config).length) {
|
||||||
if (!isDedicatedServer.value) {
|
data.driver_config = config
|
||||||
(data.driver_config as CurrencyConverterForm).url = ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
@@ -317,30 +318,41 @@ function closeExchangeRateModal(): void {
|
|||||||
:content-loading="isFetchingInitialData"
|
:content-loading="isFetchingInitialData"
|
||||||
value-prop="value"
|
value-prop="value"
|
||||||
:can-deselect="true"
|
:can-deselect="true"
|
||||||
label="key"
|
label="label"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:invalid="v$.driver.$error"
|
:invalid="v$.driver.$error"
|
||||||
track-by="key"
|
track-by="label"
|
||||||
@update:model-value="resetCurrency"
|
@update:model-value="resetCurrency"
|
||||||
/>
|
/>
|
||||||
</BaseInputGroup>
|
</BaseInputGroup>
|
||||||
|
|
||||||
|
<!-- Driver-specific config fields rendered from metadata -->
|
||||||
<BaseInputGroup
|
<BaseInputGroup
|
||||||
v-if="isCurrencyConverter"
|
v-for="field in visibleConfigFields"
|
||||||
required
|
:key="field.key"
|
||||||
:label="$t('settings.exchange_rate.server')"
|
:label="$t(field.label)"
|
||||||
:content-loading="isFetchingInitialData"
|
:content-loading="isFetchingInitialData"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<BaseMultiselect
|
<BaseMultiselect
|
||||||
v-model="currencyConverter.type"
|
v-if="field.type === 'select'"
|
||||||
|
v-model="driverConfig[field.key]"
|
||||||
|
:options="fieldOptions(field)"
|
||||||
:content-loading="isFetchingInitialData"
|
:content-loading="isFetchingInitialData"
|
||||||
value-prop="value"
|
value-prop="value"
|
||||||
searchable
|
label="label"
|
||||||
:options="serverOptions"
|
:can-deselect="false"
|
||||||
label="value"
|
:searchable="true"
|
||||||
track-by="value"
|
track-by="label"
|
||||||
@update:model-value="resetCurrency"
|
@update:model-value="resetCurrency"
|
||||||
/>
|
/>
|
||||||
|
<BaseInput
|
||||||
|
v-else
|
||||||
|
v-model="driverConfig[field.key]"
|
||||||
|
:content-loading="isFetchingInitialData"
|
||||||
|
type="text"
|
||||||
|
:name="field.key"
|
||||||
|
/>
|
||||||
</BaseInputGroup>
|
</BaseInputGroup>
|
||||||
|
|
||||||
<BaseInputGroup
|
<BaseInputGroup
|
||||||
@@ -383,18 +395,6 @@ function closeExchangeRateModal(): void {
|
|||||||
/>
|
/>
|
||||||
</BaseInputGroup>
|
</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
|
<BaseSwitch
|
||||||
v-model="currentExchangeRate.active"
|
v-model="currentExchangeRate.active"
|
||||||
class="flex"
|
class="flex"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
67
tests/Unit/ExchangeRateDriverFactoryTest.php
Normal file
67
tests/Unit/ExchangeRateDriverFactoryTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
});
|
||||||
68
tests/Unit/RegistryDriverTest.php
Normal file
68
tests/Unit/RegistryDriverTest.php
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user