Currency Api Dashboard

TypeScript

Modern currency exchange rate dashboard with real-time market data, conversion tools, analytics, historical trends, and responsive admin interface.

Stars
18
Forks
1
Downloads
N/A
Open Issues
0
Files main

Repository Files

Loading file structure...
tests/Feature/CurrencyApiTest.php
<?php

use App\Models\Currency;
use App\Models\Tokens;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    // Refresh database and cache for each test
    Currency::truncate();
    Tokens::truncate();
    User::truncate();
    Cache::flush();
});

test('api middleware blocks request without token', function () {
    $response = $this->getJson('/api/v1');
    $response->assertStatus(401)
        ->assertJsonPath('status', false)
        ->assertJsonPath('message', 'Sorry, But you forgot to provide a token.');
});

test('api middleware blocks request with invalid token', function () {
    $response = $this->withToken('invalid_token_123')->getJson('/api/v1');
    $response->assertStatus(401)
        ->assertJsonPath('status', false)
        ->assertJsonPath('message', 'Sorry, But your token is not valid or expired. Please request a new token or check your token.');
});

test('api middleware allows request with valid token and returns rates relative to default currency', function () {
    $user = User::factory()->create();
    $token = Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => 'EUR',
        'active' => true,
    ]);

    // Seed some currency rates
    Currency::create(['code' => 'USD', 'name' => 'United States Dollar', 'rate' => 1.0, 'active' => true, 'flag' => '🇺🇸', 'flag_png' => 'https://flagcdn.com/w320/us.png', 'flag_svg' => 'https://flagcdn.com/us.svg']);
    Currency::create(['code' => 'EUR', 'name' => 'Euro', 'rate' => 0.92, 'active' => true, 'flag' => '🇪🇺', 'flag_png' => 'https://flagcdn.com/w320/eu.png', 'flag_svg' => 'https://flagcdn.com/eu.svg']);
    Currency::create(['code' => 'INR', 'name' => 'Indian Rupee', 'rate' => 83.2, 'active' => true, 'flag' => '🇮🇳', 'flag_png' => 'https://flagcdn.com/w320/in.png', 'flag_svg' => 'https://flagcdn.com/in.svg']);

    // Force refresh cache
    Cache::put('currencies:rates', [
        'USD' => 1.0,
        'EUR' => 0.92,
        'INR' => 83.2,
    ], 3600);

    $response = $this->withToken('oxr_test_token_123')->getJson('/api/v1');

    $response->assertStatus(200)
        ->assertJsonPath('base', 'EUR')
        ->assertJsonStructure(['disclaimer', 'license', 'timestamp', 'base', 'rates']);

    // Check rate relative to EUR
    // EUR relative to EUR should be 1.0
    // USD relative to EUR should be 1.0 / 0.92 = 1.08695652
    $rates = $response->json('rates');
    expect($rates['EUR']['rate'])->toEqual(1.0);
    expect($rates['USD']['rate'])->toEqual(round(1.0 / 0.92, 8));
    expect($rates['INR']['rate'])->toEqual(round(83.2 / 0.92, 8));

    expect($rates['EUR']['flag'])->toEqual('🇪🇺');
    expect($rates['EUR']['flag_png'])->toEqual('https://flagcdn.com/w320/eu.png');
    expect($rates['EUR']['flag_svg'])->toEqual('https://flagcdn.com/eu.svg');
});

test('api allows request with custom base currency code', function () {
    $user = User::factory()->create();
    $token = Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => 'USD',
        'active' => true,
    ]);

    Currency::create(['code' => 'USD', 'name' => 'United States Dollar', 'rate' => 1.0, 'active' => true]);
    Currency::create(['code' => 'INR', 'name' => 'Indian Rupee', 'rate' => 83.2, 'active' => true]);

    Cache::put('currencies:rates', [
        'USD' => 1.0,
        'INR' => 83.2,
    ], 3600);

    // Request with custom base INR
    $response = $this->withToken('oxr_test_token_123')->getJson('/api/v1/INR');

    $response->assertStatus(200)
        ->assertJsonPath('base', 'INR');

    // USD relative to INR should be 1.0 / 83.2 = 0.01201923
    $rates = $response->json('rates');
    expect($rates['USD']['rate'])->toEqual(round(1.0 / 83.2, 8));
    expect($rates['INR']['rate'])->toEqual(1.0);
});

test('api returns 400 error for unsupported custom base currency', function () {
    $user = User::factory()->create();
    Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => 'USD',
        'active' => true,
    ]);

    Currency::create(['code' => 'USD', 'name' => 'United States Dollar', 'rate' => 1.0, 'active' => true]);
    Cache::put('currencies:rates', ['USD' => 1.0], 3600);

    $response = $this->withToken('oxr_test_token_123')->getJson('/api/v1/XYZ');

    $response->assertStatus(400)
        ->assertJsonPath('status', false)
        ->assertJsonPath('message', "Currency code 'XYZ' is not supported or active.");
});

test('api converts currency values correctly', function () {
    $user = User::factory()->create();
    Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => 'USD',
        'active' => true,
    ]);

    Currency::create(['code' => 'USD', 'name' => 'United States Dollar', 'rate' => 1.0, 'active' => true]);
    Currency::create(['code' => 'EUR', 'name' => 'Euro', 'rate' => 0.92, 'active' => true]);
    Currency::create(['code' => 'INR', 'name' => 'Indian Rupee', 'rate' => 83.2, 'active' => true]);

    Cache::put('currencies:rates', [
        'USD' => 1.0,
        'EUR' => 0.92,
        'INR' => 83.2,
    ], 3600);

    // Convert 100 EUR to INR
    // 100 EUR = 100 / 0.92 USD = 108.6956 USD
    // 108.6956 USD * 83.2 = 9043.4782 INR
    $response = $this->withToken('oxr_test_token_123')
        ->postJson('/api/v1/EUR/INR', ['amount' => 100]);

    $response->assertStatus(200)
        ->assertJsonPath('status', true)
        ->assertJsonPath('from', 'EUR')
        ->assertJsonPath('to', 'INR')
        ->assertJsonPath('amount', 100)
        ->assertJsonPath('converted', round((100 / 0.92) * 83.2, 4));
});

test('sync command fetches real rates from openexchangerates and saves/caches them', function () {
    // Fake the external HTTP requests
    Http::fake([
        'openexchangerates.org/api/currencies.json' => Http::response([
            'USD' => 'United States Dollar',
            'EUR' => 'Euro',
            'INR' => 'Indian Rupee',
            'BTC' => 'Bitcoin',
        ], 200),
        'openexchangerates.org/api/latest.json*' => Http::response([
            'rates' => [
                'USD' => 1.0,
                'EUR' => 0.915,
                'INR' => 83.45,
                'BTC' => 0.000015,
            ],
        ], 200),
        'restcountries.com/v3.1/all*' => Http::response([
            [
                'cca2' => 'US',
                'name' => ['common' => 'United States'],
                'currencies' => ['USD' => ['name' => 'United States dollar', 'symbol' => '$']],
                'flags' => ['png' => 'https://flagcdn.com/w320/us.png', 'svg' => 'https://flagcdn.com/us.svg'],
                'flag' => '🇺🇸',
            ],
            [
                'cca2' => 'IN',
                'name' => ['common' => 'India'],
                'currencies' => ['INR' => ['name' => 'Indian rupee', 'symbol' => '₹']],
                'flags' => ['png' => 'https://flagcdn.com/w320/in.png', 'svg' => 'https://flagcdn.com/in.svg'],
                'flag' => '🇮🇳',
            ],
            [
                'cca2' => 'BT',
                'name' => ['common' => 'Bhutan'],
                'currencies' => [
                    'BTN' => ['name' => 'Bhutanese ngultrum', 'symbol' => 'Nu.'],
                    'INR' => ['name' => 'Indian rupee', 'symbol' => '₹'],
                ],
                'flags' => ['png' => 'https://flagcdn.com/w320/bt.png', 'svg' => 'https://flagcdn.com/bt.svg'],
                'flag' => '🇧🇹',
            ],
        ], 200),
    ]);

    $exitCode = Artisan::call('app:sync-currencies');
    expect($exitCode)->toEqual(0);

    // Verify database was updated
    $this->assertDatabaseHas('currencies', [
        'code' => 'EUR',
        'rate' => 0.915,
        'name' => 'Euro',
        'country' => 'Eurozone (Austria, Belgium, Cyprus, Estonia, Finland, France, Germany, Greece, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Portugal, Slovakia, Slovenia, Spain)',
        'flag' => '🇪🇺',
        'flag_png' => 'https://flagcdn.com/w320/eu.png',
        'flag_svg' => 'https://flagcdn.com/eu.svg',
    ]);

    $this->assertDatabaseHas('currencies', [
        'code' => 'INR',
        'rate' => 83.45,
        'name' => 'Indian Rupee',
        'country' => 'India, Bhutan',
        'flag' => '🇮🇳',
        'flag_png' => 'https://flagcdn.com/w320/in.png',
        'flag_svg' => 'https://flagcdn.com/in.svg',
    ]);

    // Verify that BTC (which has no flag) was skipped and is missing from database
    $this->assertDatabaseMissing('currencies', [
        'code' => 'BTC',
    ]);

    // Verify cache was populated
    $cachedRates = Cache::get('currencies:rates');
    expect($cachedRates)->toBeArray();
    expect($cachedRates['EUR'])->toEqual(0.915);
    expect($cachedRates['INR'])->toEqual(83.45);
    expect(isset($cachedRates['BTC']))->toBeFalse();
});

test('api returns rates relative to system default INR if token default currency is null', function () {
    $user = User::factory()->create();
    Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => null,
        'active' => true,
    ]);

    Currency::create(['code' => 'USD', 'name' => 'United States Dollar', 'rate' => 1.0, 'active' => true]);
    Currency::create(['code' => 'INR', 'name' => 'Indian Rupee', 'rate' => 83.2, 'active' => true]);

    Cache::put('currencies:rates', [
        'USD' => 1.0,
        'INR' => 83.2,
    ], 3600);

    $response = $this->withToken('oxr_test_token_123')->getJson('/api/v1');

    $response->assertStatus(200)
        ->assertJsonPath('base', 'INR');

    $rates = $response->json('rates');
    expect($rates['INR']['rate'])->toEqual(1.0);
    expect($rates['USD']['rate'])->toEqual(round(1.0 / 83.2, 8));
});

test('api geo endpoints return cached lists and merged data with valid token', function () {
    $user = User::factory()->create();
    Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => 'INR',
        'active' => true,
    ]);

    // Seed database to populate relationships and prevent self-healing sync trigger in test
    DB::table('countries')->insert([
        'id' => 101,
        'name' => 'India',
        'sortname' => 'IN',
        'currency_code' => 'INR',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    DB::table('states')->insert([
        'id' => 2,
        'name' => 'Andhra Pradesh',
        'country_id' => 101,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    DB::table('cities')->insert([
        'id' => 5,
        'name' => 'Hyderabad',
        'state_id' => 2,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    Currency::create([
        'code' => 'INR',
        'name' => 'Indian Rupee',
        'rate' => 83.2,
        'active' => true,
        'flag' => '🇮🇳',
        'flag_png' => 'https://flagcdn.com/w320/in.png',
        'flag_svg' => 'https://flagcdn.com/in.svg',
    ]);

    // 1. Seed Cache with Mock Geo Data
    Cache::put('geo:countries', [
        ['id' => 101, 'name' => 'India', 'sortname' => 'IN', 'currency_code' => 'INR'],
    ], 3600);

    Cache::put('geo:states_list', [
        ['id' => 2, 'name' => 'Andhra Pradesh', 'country_id' => 101],
    ], 3600);

    Cache::put('geo:cities_list', [
        ['id' => 5, 'name' => 'Hyderabad', 'state_id' => 2],
    ], 3600);

    Cache::put('geo:all_merged', [
        'india' => [
            'currency' => [
                'name' => 'Indian Rupee',
                'code' => 'INR',
                'symbol' => '₹',
                'flag' => '🇮🇳',
                'flag_png' => 'https://flagcdn.com/w320/in.png',
                'flag_svg' => 'https://flagcdn.com/in.svg',
                'rate' => 83.2,
                'country' => 'India, Bhutan',
                'active' => true,
            ],
            'states' => [
                'Andhra Pradesh' => [
                    'Hyderabad',
                ],
            ],
        ],
    ], 3600);

    // Call countries endpoint with token
    $countriesResponse = $this->withToken('oxr_test_token_123')->getJson('/api/v1/countries');
    $countriesResponse->assertStatus(200)
        ->assertJsonPath('status', true)
        ->assertJsonStructure(['status', 'countries']);

    $countries = $countriesResponse->json('countries');
    expect($countries[0])->toHaveKey('name');
    expect($countries[0])->toHaveKey('iso_code');
    expect($countries[0])->toHaveKey('currency');
    expect($countries[0]['currency']['code'])->toEqual('INR');
    expect($countries[0])->not->toHaveKey('id');
    expect($countries[0])->not->toHaveKey('created_at');
    expect($countries[0]['currency'])->not->toHaveKey('id');

    // Call states endpoint with token
    $statesResponse = $this->withToken('oxr_test_token_123')->getJson('/api/v1/countries/IN/states');
    $statesResponse->assertStatus(200)
        ->assertJsonPath('status', true)
        ->assertJsonPath('country_code', 'IN')
        ->assertJsonStructure(['status', 'country_code', 'states']);

    $states = $statesResponse->json('states');
    expect($states[0])->toHaveKey('name');
    expect($states[0])->toHaveKey('country');
    expect($states[0]['country']['name'])->toEqual('India');
    expect($states[0]['country']['iso_code'])->toEqual('IN');
    expect($states[0]['country']['currency']['code'])->toEqual('INR');
    expect($states[0])->not->toHaveKey('id');
    expect($states[0])->not->toHaveKey('country_id');

    // Call cities endpoint with token
    $citiesResponse = $this->withToken('oxr_test_token_123')->getJson('/api/v1/states/Andhra Pradesh/cities');
    $citiesResponse->assertStatus(200)
        ->assertJsonPath('status', true)
        ->assertJsonPath('state_name', 'Andhra Pradesh')
        ->assertJsonStructure(['status', 'state_name', 'cities']);

    $cities = $citiesResponse->json('cities');
    expect($cities[0])->toHaveKey('name');
    expect($cities[0])->toHaveKey('state');
    expect($cities[0]['state']['name'])->toEqual('Andhra Pradesh');
    expect($cities[0]['state']['country']['name'])->toEqual('India');
    expect($cities[0]['state']['country']['currency']['code'])->toEqual('INR');
    expect($cities[0])->not->toHaveKey('id');
    expect($cities[0])->not->toHaveKey('state_id');

    // Call allMerged endpoint with token
    $allMergedResponse = $this->withToken('oxr_test_token_123')->getJson('/api/v1/all');
    $allMergedResponse->assertStatus(200)
        ->assertJsonStructure(['india' => ['currency', 'states']]);
});

test('api geo endpoints require authorization token', function () {
    $this->getJson('/api/v1/countries')->assertStatus(401);
    $this->getJson('/api/v1/countries/IN/states')->assertStatus(401);
    $this->getJson('/api/v1/states/Andhra Pradesh/cities')->assertStatus(401);
    $this->getJson('/api/v1/all')->assertStatus(401);
});

test('api geo endpoints trigger self-healing sync when database is empty', function () {
    $user = User::factory()->create();
    Tokens::create([
        'user_id' => $user->id,
        'name' => 'Test Token',
        'token' => 'oxr_test_token_123',
        'default_currency' => 'INR',
        'active' => true,
    ]);

    // Truncate tables to make sure database is completely empty
    DB::table('cities')->delete();
    DB::table('states')->delete();
    DB::table('countries')->delete();
    Currency::truncate();
    Cache::flush();

    $geoPath = storage_path('app/geo');
    if (! is_dir($geoPath)) {
        mkdir($geoPath, 0755, true);
    }

    file_put_contents($geoPath.'/countries.json', json_encode([
        'countries' => [
            ['id' => 101, 'name' => 'India', 'sortname' => 'IN', 'phoneCode' => '91'],
        ],
    ]));
    file_put_contents($geoPath.'/states.json', json_encode([
        'states' => [
            ['id' => 2, 'name' => 'Andhra Pradesh', 'country_id' => 101],
        ],
    ]));
    file_put_contents($geoPath.'/cities.json', json_encode([
        'cities' => [
            ['id' => 5, 'name' => 'Hyderabad', 'state_id' => 2],
        ],
    ]));

    // Fake HTTP requests that the sync command will make
    Http::fake([
        'openexchangerates.org/api/currencies.json' => Http::response([
            'INR' => 'Indian Rupee',
        ], 200),
        'openexchangerates.org/api/latest.json*' => Http::response([
            'rates' => [
                'INR' => 83.45,
            ],
        ], 200),
        'restcountries.com/v3.1/all*' => Http::response([
            [
                'cca2' => 'IN',
                'name' => ['common' => 'India'],
                'currencies' => ['INR' => ['name' => 'Indian rupee', 'symbol' => '₹']],
                'flags' => ['png' => 'https://flagcdn.com/w320/in.png', 'svg' => 'https://flagcdn.com/in.svg'],
                'flag' => '🇮🇳',
            ],
        ], 200),
    ]);

    // Request countries endpoint with token. This should trigger the sync and succeed
    $response = $this->withToken('oxr_test_token_123')->getJson('/api/v1/countries');

    $response->assertStatus(200)
        ->assertJsonPath('status', true);

    // Verify database was populated
    expect(Currency::count())->toBeGreaterThan(0);
    expect(DB::table('countries')->count())->toBeGreaterThan(0);
});