From 90930cd22318580fc777bfe1ae420df2b2a42345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Wed, 5 Aug 2026 09:23:18 +0200 Subject: [PATCH] refactor(dashboard,history): replace ViewModels with Pinia stores and improve backend --- app/Features/Dashboard/Data/DashboardData.php | 6 +- .../Dashboard/Services/DashboardService.php | 22 +- .../History/Services/HistoryService.php | 11 +- app/Repositories/ZonneplanPriceRepository.php | 53 ++++- config/cache_prices.php | 24 ++ package-lock.json | 88 +++++++- package.json | 1 + resources/js/app.ts | 3 + .../features/dashboard/components/Price.vue | 2 +- .../js/features/dashboard/pages/Index.vue | 210 ++++++++---------- .../viewmodels/DashboardViewModel.ts | 26 --- .../js/features/history/components/Chart.vue | 7 +- resources/js/features/history/pages/Index.vue | 101 +++++++-- .../history/viewmodels/HistoryViewModel.ts | 30 --- resources/js/generated/generated.d.ts | 6 +- .../typescript-transformer-manifest.json | 2 +- .../js/lang/translations/nl/dashboard.json | 3 +- .../js/lang/translations/nl/history.json | 8 +- resources/js/lib/http.ts | 24 ++ resources/js/lib/utils.ts | 7 - resources/js/stores/dashboard.ts | 34 +++ resources/js/stores/history.ts | 41 ++++ resources/js/types/index.ts | 1 - tests/Feature/Api/DashboardApiTest.php | 156 +++++++++++++ tests/Feature/Api/HistoryApiTest.php | 179 +++++++++++++++ .../Unit/Clients/ZonneplanPriceClientTest.php | 143 ++++++++++++ .../ZonneplanPriceRepositoryTest.php | 189 ++++++++++++++++ tests/Unit/Services/DashboardServiceTest.php | 129 +++++++++++ tests/Unit/Services/HistoryServiceTest.php | 103 +++++++++ 29 files changed, 1372 insertions(+), 237 deletions(-) create mode 100644 config/cache_prices.php delete mode 100644 resources/js/features/dashboard/viewmodels/DashboardViewModel.ts delete mode 100644 resources/js/features/history/viewmodels/HistoryViewModel.ts create mode 100644 resources/js/lib/http.ts delete mode 100644 resources/js/lib/utils.ts create mode 100644 resources/js/stores/dashboard.ts create mode 100644 resources/js/stores/history.ts delete mode 100644 resources/js/types/index.ts create mode 100644 tests/Feature/Api/DashboardApiTest.php create mode 100644 tests/Feature/Api/HistoryApiTest.php create mode 100644 tests/Unit/Clients/ZonneplanPriceClientTest.php create mode 100644 tests/Unit/Repositories/ZonneplanPriceRepositoryTest.php create mode 100644 tests/Unit/Services/DashboardServiceTest.php create mode 100644 tests/Unit/Services/HistoryServiceTest.php diff --git a/app/Features/Dashboard/Data/DashboardData.php b/app/Features/Dashboard/Data/DashboardData.php index e49f53b..73cc70e 100644 --- a/app/Features/Dashboard/Data/DashboardData.php +++ b/app/Features/Dashboard/Data/DashboardData.php @@ -9,12 +9,10 @@ use Spatie\LaravelData\Data; class DashboardData extends Data { public function __construct( - public ElectricityData $electricity, + public ?ElectricityData $electricity, public ?ElectricityData $electricityCheapest, public ?ElectricityData $electricityDurable, public ?ElectricityData $electricityPriciest, - public GasData $gas, - public ?GasData $gasCheapest, - public ?GasData $gasPriciest, + public ?GasData $gas, ) {} } diff --git a/app/Features/Dashboard/Services/DashboardService.php b/app/Features/Dashboard/Services/DashboardService.php index 633e8f0..5772b6e 100644 --- a/app/Features/Dashboard/Services/DashboardService.php +++ b/app/Features/Dashboard/Services/DashboardService.php @@ -14,19 +14,25 @@ class DashboardService public function getDashboardData(): DashboardData { + $electricity = $this->repository->retrieveElectricity(); + $gas = $this->repository->retrieveGas(); + $now = Carbon::now(); - $electricity = $this->repository->retrieveElectricity(); - - $eNow = $electricity->whereBetween('startDate', [$now->copy()->subHour(), $now])->first(); + $eNow = $electricity->first( + fn ($item) => $item->startDate instanceof Carbon + && $item->startDate->gte($now->copy()->subHour()) + && $item->startDate->lte($now) + ); $eCheapest = $electricity->where('totalPriceTaxIncluded', $electricity->min('totalPriceTaxIncluded'))->first(); $eDurable = $electricity->where('sustainabilityScore', $electricity->max('sustainabilityScore'))->first(); $ePriciest = $electricity->where('totalPriceTaxIncluded', $electricity->max('totalPriceTaxIncluded'))->first(); - $gas = $this->repository->retrieveGas(); - $gNow = $gas->whereBetween('startDate', [$now->copy()->setHour(0), $now])->first(); - $gCheapest = $gas->where('totalPriceTaxIncluded', $gas->min('totalPriceTaxIncluded'))->first(); - $gPriciest = $gas->where('totalPriceTaxIncluded', $gas->max('totalPriceTaxIncluded'))->first(); + $gNow = $gas->first( + fn ($item) => $item->startDate instanceof Carbon + && $item->startDate->gte($now->copy()->startOfDay()) + && $item->startDate->lte($now) + ); return new DashboardData( $eNow, @@ -34,8 +40,6 @@ class DashboardService $eDurable, $ePriciest, $gNow, - $gCheapest, - $gPriciest ); } } diff --git a/app/Features/History/Services/HistoryService.php b/app/Features/History/Services/HistoryService.php index 6dec047..6241c5a 100644 --- a/app/Features/History/Services/HistoryService.php +++ b/app/Features/History/Services/HistoryService.php @@ -3,8 +3,6 @@ namespace App\Features\History\Services; use App\Contracts\PriceRepository; -use App\Data\ElectricityData; -use App\Data\GasData; use App\Features\History\Data\HistoryData; use Carbon\Carbon; @@ -16,9 +14,12 @@ class HistoryService public function getHistoryData(Carbon $date): HistoryData { - $electricity = ElectricityData::collect($this->repository->retrieveElectricityForDate($date)); - $gas = GasData::collect($this->repository->retrieveGasForDate($date)); + $electricity = $this->repository->retrieveElectricityForDate($date); + $gas = $this->repository->retrieveGasForDate($date); - return new HistoryData($electricity, $gas); + return new HistoryData( + electricity: $electricity, + gas: $gas + ); } } diff --git a/app/Repositories/ZonneplanPriceRepository.php b/app/Repositories/ZonneplanPriceRepository.php index 98aff5f..ab45c8c 100644 --- a/app/Repositories/ZonneplanPriceRepository.php +++ b/app/Repositories/ZonneplanPriceRepository.php @@ -13,39 +13,74 @@ use Override; class ZonneplanPriceRepository implements ContractsPriceRepository { + private int $cacheTtl; + private string $cachePrefix; + public function __construct( public PriceClient $client - ) {} + ) { + $this->cacheTtl = config('cache_prices.ttl', 3600); + $this->cachePrefix = config('cache_prices.prefix', 'zonneplan:'); + } #[Override] public function retrieveElectricity(): Collection { - $data = Cache::remember('electricity:upcomming', 3600, fn () => $this->client->getElectricityPrices()->toArray()); + $raw = $this->remember( + 'electricity:upcoming', + fn () => $this->client->getElectricityPrices()->toArray() + ); - return ElectricityData::collect($data, Collection::class); + return ElectricityData::collect($raw, Collection::class); } #[Override] public function retrieveGas(): Collection { - $data = Cache::remember('gas:upcomming', 3600, fn () => $this->client->getGasPrices()->toArray()); + $raw = $this->remember( + 'gas:upcoming', + fn () => $this->client->getGasPrices()->toArray() + ); - return GasData::collect($data, Collection::class); + return GasData::collect($raw, Collection::class); } #[Override] public function retrieveElectricityForDate(Carbon $date): Collection { - $data = Cache::remember('electricity:'.$date->format('Y-m-d'), 3600, fn () => $this->client->getElectricityPricesForDate($date)->toArray()); + $raw = $this->remember( + 'electricity:' . $date->format('Y-m-d'), + fn () => $this->client->getElectricityPricesForDate($date)->toArray() + ); - return GasData::collect($data, Collection::class); + return ElectricityData::collect($raw, Collection::class); } #[Override] public function retrieveGasForDate(Carbon $date): Collection { - $data = Cache::remember('gas:'.$date->format('Y-m-d'), 3600, fn () => $this->client->getGasPricesForDate($date)->toArray()); + $raw = $this->remember( + 'gas:' . $date->format('Y-m-d'), + fn () => $this->client->getGasPricesForDate($date)->toArray() + ); - return GasData::collect($data, Collection::class); + return GasData::collect($raw, Collection::class); + } + + /** + * Cache via JSON encoding to ensure clean array structure on retrieval. + * + * @param callable(): array $callback + * @return array + */ + private function remember(string $key, callable $callback): array + { + $cached = Cache::remember( + $this->cachePrefix . $key, + $this->cacheTtl, + fn () => json_encode($callback()) + ); + + return json_decode($cached, true); } } diff --git a/config/cache_prices.php b/config/cache_prices.php new file mode 100644 index 0000000..4055d75 --- /dev/null +++ b/config/cache_prices.php @@ -0,0 +1,24 @@ + env('CACHE_TTL', 3600), + + /* + |-------------------------------------------------------------------------- + | Cache prefix + |-------------------------------------------------------------------------- + | + | Prefix voor cache keys om conflicten te vermijden. + | + */ + 'prefix' => 'zonneplan:', +]; diff --git a/package-lock.json b/package-lock.json index 4eb946a..75b048f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "Zonneplan", "lockfileVersion": 3, "requires": true, "packages": { @@ -17,6 +17,7 @@ "i18next-vue": "^5.4.0", "laravel-vite-plugin": "^3.1", "moment": "^2.30.1", + "pinia": "^4.0.2", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", "typescript": "^5.2.2", @@ -1907,6 +1908,36 @@ "he": "^1.2.0" } }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT", + "peer": true + }, "node_modules/@vue/eslint-config-typescript": { "version": "14.9.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz", @@ -2308,6 +2339,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3968,6 +4009,13 @@ "he": "bin/he" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT", + "peer": true + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5153,6 +5201,12 @@ "semver": "bin/semver.js" } }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -5395,6 +5449,13 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT", + "peer": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5413,6 +5474,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz", + "integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==", + "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index ab1c66b..f3eae73 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "i18next-vue": "^5.4.0", "laravel-vite-plugin": "^3.1", "moment": "^2.30.1", + "pinia": "^4.0.2", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", "typescript": "^5.2.2", diff --git a/resources/js/app.ts b/resources/js/app.ts index 4e27e1d..c41ca45 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,10 +1,12 @@ import { createInertiaApp } from '@inertiajs/vue3'; import I18NextVue from 'i18next-vue'; +import { createPinia } from 'pinia'; import type { DefineComponent } from 'vue'; import i18next from './lang/init'; import Layout from './layouts/Layout.vue'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; +const pinia = createPinia(); createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), @@ -33,6 +35,7 @@ createInertiaApp({ return pages[`./pages/${name}.vue`]; }, withApp(app, options) { + app.use(pinia); app.use(I18NextVue, { i18next, ...options, diff --git a/resources/js/features/dashboard/components/Price.vue b/resources/js/features/dashboard/components/Price.vue index 3bb69ca..85f87a0 100644 --- a/resources/js/features/dashboard/components/Price.vue +++ b/resources/js/features/dashboard/components/Price.vue @@ -13,7 +13,7 @@ defineProps<{
{{ label }}
- €{{ price?.toPrecision(2).toString().replace('.', ',') }} + €{{ price?.toFixed(2).replace('.', ',') }}
{{ moment.isMoment(time) ? time.format('DD-MM-YYYY HH:mm') : time }} diff --git a/resources/js/features/dashboard/pages/Index.vue b/resources/js/features/dashboard/pages/Index.vue index 8e9c640..5460b93 100644 --- a/resources/js/features/dashboard/pages/Index.vue +++ b/resources/js/features/dashboard/pages/Index.vue @@ -3,14 +3,14 @@ import { t } from 'i18next'; import moment from 'moment'; import { onMounted } from 'vue'; import Price from '../components/Price.vue'; -import useDashboardViewModel from '../viewmodels/DashboardViewModel'; +import { useDashboardStore } from '@/stores/dashboard'; import Error from '@/components/Error.vue'; import Loading from '@/components/Loading.vue'; -const model = useDashboardViewModel(); +const store = useDashboardStore(); onMounted(() => { - model.getDashboardData(); + store.fetchDashboardData(); }); @@ -18,124 +18,94 @@ onMounted(() => {

{{ t('dashboard.title') }}

-
-
-

- {{ t('dashboard.electricity') }} -

-
- - - - + + +
+ {{ t('dashboard.no_data') }}
- - + diff --git a/resources/js/features/dashboard/viewmodels/DashboardViewModel.ts b/resources/js/features/dashboard/viewmodels/DashboardViewModel.ts deleted file mode 100644 index ee85494..0000000 --- a/resources/js/features/dashboard/viewmodels/DashboardViewModel.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { create } from 'axios'; -import { ref } from 'vue'; -import DashboardData = App.Features.Dashboard.Data.DashboardData; - -export default function useDashboardViewModel() { - const isLoading = ref(false); - const error = ref(); - const dashboardData = ref(); - - function getDashboardData() { - isLoading.value = true; - - const client = create(); - client - .get('/api/energy-prices') - .then((res) => { - dashboardData.value = res.data; - }) - .catch((err) => { - error.value = err.response.data.message; - }) - .finally(() => (isLoading.value = false)); - } - - return { isLoading, dashboardData, error, getDashboardData }; -} diff --git a/resources/js/features/history/components/Chart.vue b/resources/js/features/history/components/Chart.vue index c09101f..89d32c6 100644 --- a/resources/js/features/history/components/Chart.vue +++ b/resources/js/features/history/components/Chart.vue @@ -29,6 +29,7 @@ export interface PricePoint { const props = defineProps<{ data: PricePoint[]; + color?: string; }>(); const chartData = computed(() => ({ @@ -36,8 +37,10 @@ const chartData = computed(() => ({ datasets: [ { data: props.data.map((d) => d.value), - borderColor: '#4ba66a', - backgroundColor: 'rgba(75, 166, 106, 0.1)', + borderColor: props.color || '#4ba66a', + backgroundColor: props.color + ? `${props.color}1a` + : 'rgba(75, 166, 106, 0.1)', fill: true, tension: 0.4, pointRadius: 2, diff --git a/resources/js/features/history/pages/Index.vue b/resources/js/features/history/pages/Index.vue index 93b7103..b49ee10 100644 --- a/resources/js/features/history/pages/Index.vue +++ b/resources/js/features/history/pages/Index.vue @@ -1,37 +1,108 @@ diff --git a/resources/js/features/history/viewmodels/HistoryViewModel.ts b/resources/js/features/history/viewmodels/HistoryViewModel.ts deleted file mode 100644 index 314012e..0000000 --- a/resources/js/features/history/viewmodels/HistoryViewModel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { create } from 'axios'; -import { ref } from 'vue'; -import HistoryData = App.Features.History.Data.HistoryData; - -export default function useHistoryViewModel() { - const isLoading = ref(false); - const error = ref(); - const historyData = ref(); - - function getHistoryData(date: string) { - isLoading.value = true; - - const client = create(); - client - .get('/api/history', { - params: { - date, - }, - }) - .then((res) => { - historyData.value = res.data; - }) - .catch((err) => { - error.value = err.response.data.message; - }) - .finally(() => (isLoading.value = false)); - } - - return { isLoading, historyData, error, getHistoryData }; -} diff --git a/resources/js/generated/generated.d.ts b/resources/js/generated/generated.d.ts index 325387a..d8a3ded 100644 --- a/resources/js/generated/generated.d.ts +++ b/resources/js/generated/generated.d.ts @@ -28,13 +28,11 @@ declare namespace App { namespace Dashboard { namespace Data { export type DashboardData = { - electricity: App.Data.ElectricityData; + electricity: App.Data.ElectricityData | null; electricityCheapest: App.Data.ElectricityData | null; electricityDurable: App.Data.ElectricityData | null; electricityPriciest: App.Data.ElectricityData | null; - gas: App.Data.GasData; - gasCheapest: App.Data.GasData | null; - gasPriciest: App.Data.GasData | null; + gas: App.Data.GasData | null; }; } } diff --git a/resources/js/generated/typescript-transformer-manifest.json b/resources/js/generated/typescript-transformer-manifest.json index 1fed8d0..6fda39a 100644 --- a/resources/js/generated/typescript-transformer-manifest.json +++ b/resources/js/generated/typescript-transformer-manifest.json @@ -1,3 +1,3 @@ { - "generated.d.ts": "596cfad66b8ff3f8b6b98271630919ae" + "generated.d.ts": "50b70ce1a75284fa50634037e0073fb4" } \ No newline at end of file diff --git a/resources/js/lang/translations/nl/dashboard.json b/resources/js/lang/translations/nl/dashboard.json index 1b3e380..370ff10 100644 --- a/resources/js/lang/translations/nl/dashboard.json +++ b/resources/js/lang/translations/nl/dashboard.json @@ -8,5 +8,6 @@ "priciest_electricity_price": "Duurste elektriciteitsprijs", "current_gas_price": "Actuele gasprijs", "cheapest_gas_price": "Goedkoopste gasprijs", - "priciest_gas_price": "Duurste gasprijs" + "priciest_gas_price": "Duurste gasprijs", + "no_data": "Geen prijsgegevens beschikbaar" } diff --git a/resources/js/lang/translations/nl/history.json b/resources/js/lang/translations/nl/history.json index 29bf569..e04bc9c 100644 --- a/resources/js/lang/translations/nl/history.json +++ b/resources/js/lang/translations/nl/history.json @@ -1,3 +1,9 @@ { - "title": "Prijshistorie" + "title": "Prijshistorie", + "select_date": "Selecteer een datum", + "electricity_prices": "Elektriciteitsprijzen", + "gas_prices": "Gasprijzen", + "no_data": "Geen prijsgegevens beschikbaar voor deze datum", + "no_electricity_data": "Geen elektriciteitsprijsgegevens beschikbaar", + "no_gas_data": "Geen gasprijsgegevens beschikbaar" } diff --git a/resources/js/lib/http.ts b/resources/js/lib/http.ts new file mode 100644 index 0000000..bb70ce9 --- /dev/null +++ b/resources/js/lib/http.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; + +const http = axios.create({ + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +}); + +http.interceptors.response.use( + (response) => response, + (error) => { + const message = + error.response?.data?.message || + error.message || + 'Er is een onverwachte fout opgetreden'; + + console.error('[HTTP Error]', message); + + return Promise.reject(error); + }, +); + +export default http; diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts deleted file mode 100644 index 66bf229..0000000 --- a/resources/js/lib/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { clsx } from 'clsx'; -import type { ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/resources/js/stores/dashboard.ts b/resources/js/stores/dashboard.ts new file mode 100644 index 0000000..487d49b --- /dev/null +++ b/resources/js/stores/dashboard.ts @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import http from '@/lib/http'; +import DashboardData = App.Features.Dashboard.Data.DashboardData; + +export const useDashboardStore = defineStore('dashboard', () => { + const isLoading = ref(false); + const error = ref(); + const dashboardData = ref(); + + async function fetchDashboardData() { + isLoading.value = true; + error.value = undefined; + + try { + const { data } = await http.get('/api/energy-prices'); + dashboardData.value = data; + } catch (err: unknown) { + const axiosError = err as { response?: { data?: { message?: string } } }; + error.value = + axiosError.response?.data?.message || + 'Kon dashboard data niet ophalen'; + } finally { + isLoading.value = false; + } + } + + return { + isLoading, + error, + dashboardData, + fetchDashboardData, + }; +}); diff --git a/resources/js/stores/history.ts b/resources/js/stores/history.ts new file mode 100644 index 0000000..0fcf008 --- /dev/null +++ b/resources/js/stores/history.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import http from '@/lib/http'; +import HistoryData = App.Features.History.Data.HistoryData; + +export const useHistoryStore = defineStore('history', () => { + const isLoading = ref(false); + const error = ref(); + const historyData = ref(); + const selectedDate = ref(new Date().toISOString().split('T')[0]); + + async function fetchHistoryData(date: string) { + isLoading.value = true; + error.value = undefined; + selectedDate.value = date; + + try { + const { data } = await http.get('/api/history', { + params: { date }, + }); + historyData.value = data; + } catch (err: unknown) { + const axiosError = err as { + response?: { data?: { message?: string } }; + }; + error.value = + axiosError.response?.data?.message || + 'Kon prijshistorie niet ophalen'; + } finally { + isLoading.value = false; + } + } + + return { + isLoading, + error, + historyData, + selectedDate, + fetchHistoryData, + }; +}); diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts deleted file mode 100644 index 269586e..0000000 --- a/resources/js/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './auth'; diff --git a/tests/Feature/Api/DashboardApiTest.php b/tests/Feature/Api/DashboardApiTest.php new file mode 100644 index 0000000..10e11cf --- /dev/null +++ b/tests/Feature/Api/DashboardApiTest.php @@ -0,0 +1,156 @@ + 60]); + config(['cache_prices.prefix' => 'test:']); + } + + protected function tearDown(): void + { + Mockery::close(); + \Illuminate\Support\Facades\Cache::flush(); + parent::tearDown(); + } + + public function test_dashboard_endpoint_returns_electricity_and_gas_data(): void + { + $now = Carbon::now(); + + $electricityResponse = collect([ + $this->createElectricityArray($now->copy()->subMinutes(30), 15.0, 80), + $this->createElectricityArray($now->copy()->addHour(), 12.0, 90), + $this->createElectricityArray($now->copy()->addHours(2), 20.0, 60), + $this->createElectricityArray($now->copy()->addHours(3), 10.0, 95), + ]); + + $gasResponse = collect([ + $this->createGasArray($now->copy()->startOfDay(), 5.0), + $this->createGasArray($now->copy()->addDay(), 4.0), + $this->createGasArray($now->copy()->addDays(2), 6.0), + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPrices')->once()->andReturn($electricityResponse); + $client->shouldReceive('getGasPrices')->once()->andReturn($gasResponse); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/energy-prices'); + + $response->assertOk() + ->assertJsonStructure([ + 'electricity' => [ + 'startDate', + 'endDate', + 'period', + 'totalPriceTaxIncluded', + ], + 'electricityCheapest', + 'electricityDurable', + 'electricityPriciest', + 'gas' => [ + 'startDate', + 'endDate', + 'period', + 'totalPriceTaxIncluded', + ], + 'gasCheapest', + 'gasPriciest', + ]); + } + + public function test_dashboard_endpoint_returns_correct_prices(): void + { + $now = Carbon::now(); + + $electricityResponse = collect([ + $this->createElectricityArray($now->copy()->subMinutes(30), 15.0, 80), + $this->createElectricityArray($now->copy()->addHour(), 12.0, 90), + $this->createElectricityArray($now->copy()->addHours(2), 20.0, 60), + ]); + + $gasResponse = collect([ + $this->createGasArray($now->copy()->startOfDay(), 5.0), + $this->createGasArray($now->copy()->addDay(), 4.0), + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPrices')->once()->andReturn($electricityResponse); + $client->shouldReceive('getGasPrices')->once()->andReturn($gasResponse); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/energy-prices'); + + $response->assertOk(); + + $data = $response->json(); + $this->assertEquals(15.0, $data['electricity']['totalPriceTaxIncluded']); + $this->assertEquals(12.0, $data['electricityCheapest']['totalPriceTaxIncluded']); + $this->assertEquals(20.0, $data['electricityPriciest']['totalPriceTaxIncluded']); + $this->assertEquals(5.0, $data['gas']['totalPriceTaxIncluded']); + $this->assertEquals(4.0, $data['gasCheapest']['totalPriceTaxIncluded']); + } + + public function test_dashboard_endpoint_handles_empty_data(): void + { + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPrices')->once()->andReturn(collect()); + $client->shouldReceive('getGasPrices')->once()->andReturn(collect()); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/energy-prices'); + + $response->assertOk(); + + $data = $response->json(); + $this->assertNull($data['electricity'] ?? null); + $this->assertNull($data['gas'] ?? null); + } + + private function createElectricityArray(Carbon $startDate, float $price, int $sustainabilityScore): array + { + return [ + 'start_date' => $startDate->format('Y-m-d H:i:s'), + 'end_date' => $startDate->copy()->addHour()->format('Y-m-d H:i:s'), + 'period' => 'elec_hour', + 'market_price' => (int) ($price * 100), + 'total_price_tax_included' => $price, + 'price_incl_handling_vat' => (int) ($price * 100), + 'price_tax_with_vat' => (int) ($price * 20), + 'pricing_profile' => 'dynamic', + 'carbon_footprint_in_gram' => 500, + 'sustainability_score' => $sustainabilityScore, + 'start_date_datetime' => $startDate->format('Y-m-d H:i:s'), + ]; + } + + private function createGasArray(Carbon $startDate, float $price): array + { + return [ + 'start_date' => $startDate->format('Y-m-d H:i:s'), + 'end_date' => $startDate->copy()->addDay()->format('Y-m-d H:i:s'), + 'period' => 'gas_day', + 'market_price' => (int) ($price * 100), + 'total_price_tax_included' => $price, + 'price_incl_handling_vat' => (int) ($price * 100), + 'price_tax_with_vat' => (int) ($price * 20), + 'start_date_datetime' => $startDate->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/tests/Feature/Api/HistoryApiTest.php b/tests/Feature/Api/HistoryApiTest.php new file mode 100644 index 0000000..2e839ad --- /dev/null +++ b/tests/Feature/Api/HistoryApiTest.php @@ -0,0 +1,179 @@ + 60]); + config(['cache_prices.prefix' => 'test:']); + } + + protected function tearDown(): void + { + Mockery::close(); + \Illuminate\Support\Facades\Cache::flush(); + parent::tearDown(); + } + + public function test_history_endpoint_returns_electricity_and_gas_data(): void + { + $date = Carbon::today(); + + $electricityResponse = collect([ + $this->createElectricityArray($date->copy()->setHour(0), 15.0), + $this->createElectricityArray($date->copy()->setHour(1), 16.0), + ]); + + $gasResponse = collect([ + $this->createGasArray($date, 5.0), + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPricesForDate') + ->once() + ->andReturn($electricityResponse); + $client->shouldReceive('getGasPricesForDate') + ->once() + ->andReturn($gasResponse); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/history?date=' . $date->format('Y-m-d')); + + $response->assertOk() + ->assertJsonStructure([ + 'electricity' => [ + '*' => [ + 'startDate', + 'endDate', + 'period', + 'totalPriceTaxIncluded', + ], + ], + 'gas' => [ + '*' => [ + 'startDate', + 'endDate', + 'period', + 'totalPriceTaxIncluded', + ], + ], + ]); + } + + public function test_history_endpoint_returns_correct_data(): void + { + $date = Carbon::today(); + + $electricityResponse = collect([ + $this->createElectricityArray($date->copy()->setHour(0), 15.0), + $this->createElectricityArray($date->copy()->setHour(1), 16.0), + ]); + + $gasResponse = collect([ + $this->createGasArray($date, 5.0), + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPricesForDate') + ->once() + ->andReturn($electricityResponse); + $client->shouldReceive('getGasPricesForDate') + ->once() + ->andReturn($gasResponse); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/history?date=' . $date->format('Y-m-d')); + + $response->assertOk(); + + $data = $response->json(); + $this->assertCount(2, $data['electricity']); + $this->assertCount(1, $data['gas']); + $this->assertEquals(15.0, $data['electricity'][0]['totalPriceTaxIncluded']); + $this->assertEquals(5.0, $data['gas'][0]['totalPriceTaxIncluded']); + } + + public function test_history_endpoint_defaults_to_today(): void + { + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPricesForDate') + ->once() + ->andReturn(collect()); + $client->shouldReceive('getGasPricesForDate') + ->once() + ->andReturn(collect()); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/history'); + + $response->assertOk(); + } + + public function test_history_endpoint_handles_empty_data(): void + { + $date = Carbon::today(); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPricesForDate') + ->once() + ->andReturn(collect()); + $client->shouldReceive('getGasPricesForDate') + ->once() + ->andReturn(collect()); + + $this->app->bind(PriceClient::class, fn () => $client); + + $response = $this->getJson('/api/history?date=' . $date->format('Y-m-d')); + + $response->assertOk(); + + $data = $response->json(); + $this->assertEmpty($data['electricity']); + $this->assertEmpty($data['gas']); + } + + private function createElectricityArray(Carbon $startDate, float $price): array + { + return [ + 'start_date' => $startDate->format('Y-m-d H:i:s'), + 'end_date' => $startDate->copy()->addHour()->format('Y-m-d H:i:s'), + 'period' => 'elec_hour', + 'market_price' => (int) ($price * 100), + 'total_price_tax_included' => $price, + 'price_incl_handling_vat' => (int) ($price * 100), + 'price_tax_with_vat' => (int) ($price * 20), + 'pricing_profile' => 'dynamic', + 'carbon_footprint_in_gram' => 500, + 'sustainability_score' => 80, + 'start_date_datetime' => $startDate->format('Y-m-d H:i:s'), + ]; + } + + private function createGasArray(Carbon $startDate, float $price): array + { + return [ + 'start_date' => $startDate->format('Y-m-d H:i:s'), + 'end_date' => $startDate->copy()->addDay()->format('Y-m-d H:i:s'), + 'period' => 'gas_day', + 'market_price' => (int) ($price * 100), + 'total_price_tax_included' => $price, + 'price_incl_handling_vat' => (int) ($price * 100), + 'price_tax_with_vat' => (int) ($price * 20), + 'start_date_datetime' => $startDate->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/tests/Unit/Clients/ZonneplanPriceClientTest.php b/tests/Unit/Clients/ZonneplanPriceClientTest.php new file mode 100644 index 0000000..c661fcd --- /dev/null +++ b/tests/Unit/Clients/ZonneplanPriceClientTest.php @@ -0,0 +1,143 @@ +shouldReceive('throw')->once(); + $response->shouldReceive('json') + ->once() + ->with('data', []) + ->andReturn([ + ['start_date' => Carbon::now()->format('Y-m-d H:i:s'), 'total_price_tax_included' => 15.0], + ]); + $response->shouldReceive('status')->once()->andReturn(200); + + $http = Mockery::mock(Factory::class); + $http->shouldReceive('baseUrl')->once()->andReturnSelf(); + $http->shouldReceive('withQueryParameters')->once()->andReturnSelf(); + $http->shouldReceive('timeout')->once()->andReturnSelf(); + $http->shouldReceive('retry')->once()->andReturnSelf(); + $http->shouldReceive('get')->once()->andReturn($response); + + $client = new ZonneplanPriceClient($http); + $result = $client->getElectricityPrices(); + + $this->assertCount(1, $result); + } + + public function test_get_gas_prices_returns_collection(): void + { + $response = Mockery::mock(); + $response->shouldReceive('throw')->once(); + $response->shouldReceive('json') + ->once() + ->with('data', []) + ->andReturn([ + ['start_date' => Carbon::now()->format('Y-m-d H:i:s'), 'total_price_tax_included' => 5.0], + ]); + $response->shouldReceive('status')->once()->andReturn(200); + + $http = Mockery::mock(Factory::class); + $http->shouldReceive('baseUrl')->once()->andReturnSelf(); + $http->shouldReceive('withQueryParameters')->once()->andReturnSelf(); + $http->shouldReceive('timeout')->once()->andReturnSelf(); + $http->shouldReceive('retry')->once()->andReturnSelf(); + $http->shouldReceive('get')->once()->andReturn($response); + + $client = new ZonneplanPriceClient($http); + $result = $client->getGasPrices(); + + $this->assertCount(1, $result); + } + + public function test_get_electricity_prices_for_date_returns_collection(): void + { + $date = Carbon::today(); + $response = Mockery::mock(); + $response->shouldReceive('throw')->once(); + $response->shouldReceive('json') + ->once() + ->with('data', []) + ->andReturn([ + ['start_date' => $date->format('Y-m-d H:i:s'), 'total_price_tax_included' => 15.0], + ]); + $response->shouldReceive('status')->once()->andReturn(200); + + $http = Mockery::mock(Factory::class); + $http->shouldReceive('baseUrl')->once()->andReturnSelf(); + $http->shouldReceive('withQueryParameters')->once()->andReturnSelf(); + $http->shouldReceive('timeout')->once()->andReturnSelf(); + $http->shouldReceive('retry')->once()->andReturnSelf(); + $http->shouldReceive('get')->once()->andReturn($response); + + $client = new ZonneplanPriceClient($http); + $result = $client->getElectricityPricesForDate($date); + + $this->assertCount(1, $result); + } + + public function test_get_gas_prices_for_date_returns_collection(): void + { + $date = Carbon::today(); + $response = Mockery::mock(); + $response->shouldReceive('throw')->once(); + $response->shouldReceive('json') + ->once() + ->with('data', []) + ->andReturn([ + ['start_date' => $date->format('Y-m-d H:i:s'), 'total_price_tax_included' => 5.0], + ]); + $response->shouldReceive('status')->once()->andReturn(200); + + $http = Mockery::mock(Factory::class); + $http->shouldReceive('baseUrl')->once()->andReturnSelf(); + $http->shouldReceive('withQueryParameters')->once()->andReturnSelf(); + $http->shouldReceive('timeout')->once()->andReturnSelf(); + $http->shouldReceive('retry')->once()->andReturnSelf(); + $http->shouldReceive('get')->once()->andReturn($response); + + $client = new ZonneplanPriceClient($http); + $result = $client->getGasPricesForDate($date); + + $this->assertCount(1, $result); + } + + public function test_client_throws_exception_on_api_failure(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Kon energieprijzen niet ophalen'); + + $response = Mockery::mock(); + $response->shouldReceive('throw')->once()->andThrow( + new \Illuminate\Http\Client\ConnectionException('API Error') + ); + + $http = Mockery::mock(Factory::class); + $http->shouldReceive('baseUrl')->once()->andReturnSelf(); + $http->shouldReceive('withQueryParameters')->once()->andReturnSelf(); + $http->shouldReceive('timeout')->once()->andReturnSelf(); + $http->shouldReceive('retry')->once()->andReturnSelf(); + $http->shouldReceive('get')->once()->andReturn($response); + + $client = new ZonneplanPriceClient($http); + $client->getElectricityPrices(); + } +} diff --git a/tests/Unit/Repositories/ZonneplanPriceRepositoryTest.php b/tests/Unit/Repositories/ZonneplanPriceRepositoryTest.php new file mode 100644 index 0000000..2f7e31f --- /dev/null +++ b/tests/Unit/Repositories/ZonneplanPriceRepositoryTest.php @@ -0,0 +1,189 @@ + 60]); + config(['cache_prices.prefix' => 'test:']); + } + + protected function tearDown(): void + { + Mockery::close(); + Cache::flush(); + parent::tearDown(); + } + + public function test_retrieve_electricity_returns_collection_of_electricity_data(): void + { + $now = Carbon::now(); + $electricityResponse = collect([ + [ + 'start_date' => $now->format('Y-m-d H:i:s'), + 'end_date' => $now->copy()->addHour()->format('Y-m-d H:i:s'), + 'period' => 'elec_hour', + 'market_price' => 1500, + 'total_price_tax_included' => 15.50, + 'price_incl_handling_vat' => 1550, + 'price_tax_with_vat' => 300, + 'pricing_profile' => 'dynamic', + 'carbon_footprint_in_gram' => 500, + 'sustainability_score' => 80, + 'start_date_datetime' => $now->format('Y-m-d H:i:s'), + ], + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPrices') + ->once() + ->andReturn($electricityResponse); + + $repository = new ZonneplanPriceRepository($client); + $result = $repository->retrieveElectricity(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(1, $result); + $this->assertInstanceOf(ElectricityData::class, $result->first()); + } + + public function test_retrieve_gas_returns_collection_of_gas_data(): void + { + $now = Carbon::now(); + $gasResponse = collect([ + [ + 'start_date' => $now->format('Y-m-d H:i:s'), + 'end_date' => $now->copy()->addDay()->format('Y-m-d H:i:s'), + 'period' => 'gas_day', + 'market_price' => 500, + 'total_price_tax_included' => 5.50, + 'price_incl_handling_vat' => 550, + 'price_tax_with_vat' => 100, + 'start_date_datetime' => $now->format('Y-m-d H:i:s'), + ], + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getGasPrices') + ->once() + ->andReturn($gasResponse); + + $repository = new ZonneplanPriceRepository($client); + $result = $repository->retrieveGas(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(1, $result); + $this->assertInstanceOf(GasData::class, $result->first()); + } + + public function test_retrieve_electricity_for_date_returns_collection(): void + { + $date = Carbon::today(); + $electricityResponse = collect([ + [ + 'start_date' => $date->copy()->setHour(0)->format('Y-m-d H:i:s'), + 'end_date' => $date->copy()->setHour(1)->format('Y-m-d H:i:s'), + 'period' => 'elec_hour', + 'market_price' => 1500, + 'total_price_tax_included' => 15.50, + 'price_incl_handling_vat' => 1550, + 'price_tax_with_vat' => 300, + 'pricing_profile' => 'dynamic', + 'carbon_footprint_in_gram' => 500, + 'sustainability_score' => 80, + 'start_date_datetime' => $date->copy()->setHour(0)->format('Y-m-d H:i:s'), + ], + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPricesForDate') + ->once() + ->with($date) + ->andReturn($electricityResponse); + + $repository = new ZonneplanPriceRepository($client); + $result = $repository->retrieveElectricityForDate($date); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(1, $result); + $this->assertInstanceOf(ElectricityData::class, $result->first()); + } + + public function test_retrieve_gas_for_date_returns_collection(): void + { + $date = Carbon::today(); + $gasResponse = collect([ + [ + 'start_date' => $date->format('Y-m-d H:i:s'), + 'end_date' => $date->copy()->addDay()->format('Y-m-d H:i:s'), + 'period' => 'gas_day', + 'market_price' => 500, + 'total_price_tax_included' => 5.50, + 'price_incl_handling_vat' => 550, + 'price_tax_with_vat' => 100, + 'start_date_datetime' => $date->format('Y-m-d H:i:s'), + ], + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getGasPricesForDate') + ->once() + ->with($date) + ->andReturn($gasResponse); + + $repository = new ZonneplanPriceRepository($client); + $result = $repository->retrieveGasForDate($date); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(1, $result); + $this->assertInstanceOf(GasData::class, $result->first()); + } + + public function test_retrieve_electricity_caches_result(): void + { + $now = Carbon::now(); + $electricityResponse = collect([ + [ + 'start_date' => $now->format('Y-m-d H:i:s'), + 'end_date' => $now->copy()->addHour()->format('Y-m-d H:i:s'), + 'period' => 'elec_hour', + 'market_price' => 1500, + 'total_price_tax_included' => 15.50, + 'price_incl_handling_vat' => 1550, + 'price_tax_with_vat' => 300, + 'pricing_profile' => 'dynamic', + 'carbon_footprint_in_gram' => 500, + 'sustainability_score' => 80, + 'start_date_datetime' => $now->format('Y-m-d H:i:s'), + ], + ]); + + $client = Mockery::mock(PriceClient::class); + $client->shouldReceive('getElectricityPrices') + ->once() + ->andReturn($electricityResponse); + + $repository = new ZonneplanPriceRepository($client); + + $result1 = $repository->retrieveElectricity(); + $result2 = $repository->retrieveElectricity(); + + $this->assertEquals( + $result1->first()->totalPriceTaxIncluded, + $result2->first()->totalPriceTaxIncluded + ); + } +} diff --git a/tests/Unit/Services/DashboardServiceTest.php b/tests/Unit/Services/DashboardServiceTest.php new file mode 100644 index 0000000..2fb90f1 --- /dev/null +++ b/tests/Unit/Services/DashboardServiceTest.php @@ -0,0 +1,129 @@ +createElectricityData($now->copy()->subMinutes(30), 15.0, 80), + $this->createElectricityData($now->copy()->addHour(), 12.0, 90), + $this->createElectricityData($now->copy()->addHours(2), 20.0, 60), + $this->createElectricityData($now->copy()->addHours(3), 10.0, 95), + ]); + + $gasData = collect([ + $this->createGasData($now->copy()->startOfDay(), 5.0), + $this->createGasData($now->copy()->addDay(), 4.0), + $this->createGasData($now->copy()->addDays(2), 6.0), + ]); + + $repository = Mockery::mock(PriceRepository::class); + $repository->shouldReceive('retrieveElectricity')->once()->andReturn($electricityData); + $repository->shouldReceive('retrieveGas')->once()->andReturn($gasData); + + $service = new DashboardService($repository); + $result = $service->getDashboardData(); + + $this->assertEquals(15.0, $result->electricity->totalPriceTaxIncluded); + $this->assertEquals(10.0, $result->electricityCheapest->totalPriceTaxIncluded); + $this->assertEquals(95, $result->electricityDurable->sustainabilityScore); + $this->assertEquals(20.0, $result->electricityPriciest->totalPriceTaxIncluded); + + $this->assertEquals(5.0, $result->gas->totalPriceTaxIncluded); + $this->assertEquals(4.0, $result->gasCheapest->totalPriceTaxIncluded); + $this->assertEquals(6.0, $result->gasPriciest->totalPriceTaxIncluded); + } + + public function test_get_dashboard_data_returns_null_for_missing_current_price(): void + { + $now = Carbon::now(); + + $electricityData = collect([ + $this->createElectricityData($now->copy()->addHours(5), 15.0, 80), + ]); + + $gasData = collect([ + $this->createGasData($now->copy()->addDays(5), 5.0), + ]); + + $repository = Mockery::mock(PriceRepository::class); + $repository->shouldReceive('retrieveElectricity')->once()->andReturn($electricityData); + $repository->shouldReceive('retrieveGas')->once()->andReturn($gasData); + + $service = new DashboardService($repository); + $result = $service->getDashboardData(); + + $this->assertNull($result->electricity); + $this->assertNull($result->gas); + $this->assertNotNull($result->electricityCheapest); + $this->assertNotNull($result->gasCheapest); + } + + public function test_get_dashboard_data_with_empty_collections(): void + { + $repository = Mockery::mock(PriceRepository::class); + $repository->shouldReceive('retrieveElectricity')->once()->andReturn(collect()); + $repository->shouldReceive('retrieveGas')->once()->andReturn(collect()); + + $service = new DashboardService($repository); + $result = $service->getDashboardData(); + + $this->assertNull($result->electricity); + $this->assertNull($result->electricityCheapest); + $this->assertNull($result->electricityDurable); + $this->assertNull($result->electricityPriciest); + $this->assertNull($result->gas); + $this->assertNull($result->gasCheapest); + $this->assertNull($result->gasPriciest); + } + + private function createElectricityData(Carbon $startDate, float $price, int $sustainabilityScore): ElectricityData + { + return new ElectricityData( + startDate: $startDate, + endDate: $startDate->copy()->addHour(), + period: 'elec_hour', + marketPrice: (int) ($price * 100), + totalPriceTaxIncluded: $price, + priceInclHandlingVat: (int) ($price * 100), + priceTaxWithVat: (int) ($price * 20), + pricingProfile: 'dynamic', + carbonFootprintInGram: 500, + sustainabilityScore: $sustainabilityScore, + startDateDatetime: $startDate, + ); + } + + private function createGasData(Carbon $startDate, float $price): GasData + { + return new GasData( + startDate: $startDate, + endDate: $startDate->copy()->addDay(), + period: 'gas_day', + marketPrice: (int) ($price * 100), + totalPriceTaxIncluded: $price, + priceInclHandlingVat: (int) ($price * 100), + priceTaxWithVat: (int) ($price * 20), + startDateDatetime: $startDate, + ); + } +} diff --git a/tests/Unit/Services/HistoryServiceTest.php b/tests/Unit/Services/HistoryServiceTest.php new file mode 100644 index 0000000..07ef551 --- /dev/null +++ b/tests/Unit/Services/HistoryServiceTest.php @@ -0,0 +1,103 @@ +createElectricityData($date->copy()->setHour(0), 15.0), + $this->createElectricityData($date->copy()->setHour(1), 16.0), + ]); + + $gasData = collect([ + $this->createGasData($date, 5.0), + ]); + + $repository = Mockery::mock(PriceRepository::class); + $repository->shouldReceive('retrieveElectricityForDate') + ->once() + ->with($date) + ->andReturn($electricityData); + $repository->shouldReceive('retrieveGasForDate') + ->once() + ->with($date) + ->andReturn($gasData); + + $service = new HistoryService($repository); + $result = $service->getHistoryData($date); + + $this->assertCount(2, $result->electricity); + $this->assertCount(1, $result->gas); + $this->assertEquals(15.0, $result->electricity->first()->totalPriceTaxIncluded); + $this->assertEquals(5.0, $result->gas->first()->totalPriceTaxIncluded); + } + + public function test_get_history_data_with_empty_collections(): void + { + $date = Carbon::today(); + + $repository = Mockery::mock(PriceRepository::class); + $repository->shouldReceive('retrieveElectricityForDate') + ->once() + ->andReturn(collect()); + $repository->shouldReceive('retrieveGasForDate') + ->once() + ->andReturn(collect()); + + $service = new HistoryService($repository); + $result = $service->getHistoryData($date); + + $this->assertCount(0, $result->electricity); + $this->assertCount(0, $result->gas); + } + + private function createElectricityData(Carbon $startDate, float $price): ElectricityData + { + return new ElectricityData( + startDate: $startDate, + endDate: $startDate->copy()->addHour(), + period: 'elec_hour', + marketPrice: (int) ($price * 100), + totalPriceTaxIncluded: $price, + priceInclHandlingVat: (int) ($price * 100), + priceTaxWithVat: (int) ($price * 20), + pricingProfile: 'dynamic', + carbonFootprintInGram: 500, + sustainabilityScore: 80, + startDateDatetime: $startDate, + ); + } + + private function createGasData(Carbon $startDate, float $price): GasData + { + return new GasData( + startDate: $startDate, + endDate: $startDate->copy()->addDay(), + period: 'gas_day', + marketPrice: (int) ($price * 100), + totalPriceTaxIncluded: $price, + priceInclHandlingVat: (int) ($price * 100), + priceTaxWithVat: (int) ($price * 20), + startDateDatetime: $startDate, + ); + } +}