ADD work_calendar api sync and test support

This commit is contained in:
E98Developer 2026-04-08 21:47:28 +02:00
parent 6fc5a7374a
commit 0581fee616
14 changed files with 566 additions and 3 deletions

14
.env.testing Normal file
View File

@ -0,0 +1,14 @@
APP_ENV=testing
APP_KEY=base64:Ftjcs9gvv7IgMLvSzaCQIaqIPBFO2fezCFUb+owUSCE=
DB_CONNECTION=mysql
DB_DATABASE=d2dTest
SZUNETNAPOK_API_KEY=test_api_key
SZUNETNAPOK_API_URL=https://szunetnapok.hu/api/
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync

View File

@ -0,0 +1,58 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CheckTestDb extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:check-test-db';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Ellenőrzi a pillanatnyi adatbázis kapcsolatot és az aktív environmentet.';
/**
* Execute the console command.
*/
public function handle()
{
$this->newLine();
$this->info('--- Adatbázis Kapcsolat Ellenőrzése ---');
$this->info('APP_ENV: ' . app()->environment());
$this->info('DB_CONNECTION: ' . config('database.default'));
$this->info('DB_DATABASE (config): ' . config('database.connections.mysql.database'));
$this->info('DB_DATABASE (env): ' . env('DB_DATABASE'));
try {
$dbName = DB::select('SELECT DATABASE() as db')[0]->db;
$this->info('TÉNYLEGES ADATBÁZIS (MySQL select): ' . $dbName);
if (app()->environment() === 'testing' && strtolower($dbName) !== strtolower('d2dTest')) {
$this->error('VIGYÁZAT: Teszt környezetben nem a d2dTest adatbázist használod! (Kapcsolódva: ' . $dbName . ')');
} elseif (app()->environment() === 'testing' && strtolower($dbName) === strtolower('d2dTest')) {
$this->success('Minden rendben: Teszt környezetben a d2dTest adatbázist használod.');
}
} catch (\Exception $e) {
$this->error('Hiba az adatbázishoz való csatlakozáskor: ' . $e->getMessage());
}
$this->newLine();
}
/**
* Success message override
*/
protected function success(string $message)
{
$this->line("<info>{$message}</info>");
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class SyncWorkCalendar extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:sync-work-calendar {year? : A szinkronizálandó év (alapértelmezett: aktuális)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Munkaszüneti napok szinkronizálása külső API-ból';
/**
* Execute the console command.
*/
public function handle(\App\Services\WorkCalendarService $service): int
{
$year = $this->argument('year') ? (int) $this->argument('year') : now()->year;
$this->info("Szinkronizálás indítása {$year} évre...");
if ($service->syncFromApi($year)) {
$this->info('A szinkronizálás sikeresen befejeződött.');
return self::SUCCESS;
}
$this->error('A szinkronizálás során hiba történt:');
foreach ($service->getError() as $error) {
$this->error("- " . implode(', ', (array) $error));
}
return self::FAILURE;
}
}

View File

@ -13,6 +13,7 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
// $schedule->command('inspire')->hourly(); // $schedule->command('inspire')->hourly();
// $schedule->command('app:sync-work-calendar')->quarterly();
} }
/** /**

17
app/Enums/DataSource.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum DataSource: string
{
case Api = 'api';
case Manual = 'manual';
public function label(): string
{
return match($this) {
self::Api => 'API',
self::Manual => 'Kézi rögzítés',
};
}
}

25
app/Enums/WorkDayType.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum WorkDayType: string
{
case Holiday = 'holiday'; // Munkaszüneti nap
case WorkingDay = 'working_day'; // Plusz munkanap (pl. szombat)
public function label(): string
{
return match($this) {
self::Holiday => 'Munkaszüneti nap',
self::WorkingDay => 'Áthelyezett munkanap',
};
}
public function color(): string
{
return match($this) {
self::Holiday => 'danger',
self::WorkingDay => 'success',
};
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Enums\DataSource;
use App\Enums\WorkDayType;
use Illuminate\Database\Eloquent\SoftDeletes;
class WorkCalendar extends BaseAuditable
{
use SoftDeletes;
protected $casts = [
'date' => 'date',
'type' => WorkDayType::class,
'data_source' => DataSource::class,
];
/**
* Meghatározza, hogy az adott nap munkaszüneti nap-e az adatbázis szerint.
*/
public function isHoliday(): bool
{
return $this->type === WorkDayType::Holiday;
}
/**
* Meghatározza, hogy az adott nap plusz munkanap-e az adatbázis szerint.
*/
public function isWorkingDay(): bool
{
return $this->type === WorkDayType::WorkingDay;
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Services;
use App\Enums\DataSource;
use App\Enums\WorkDayType;
use App\Models\WorkCalendar;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WorkCalendarService extends ServiceBase
{
/**
* Szinkronizálja a munkaszüneti napokat a szunetnapok.hu API-ból.
*/
public function syncFromApi(?int $year = null): bool
{
$year ??= now()->year;
$config = config('services.szunetnapok');
$apiKey = $config['key'] ?? null;
$apiUrl = rtrim($config['url'] ?? 'https://szunetnapok.hu/api/', '/');
$verify = $config['verify'] ?? true;
Log::channel('work_calendar')->info('WorkCalendar sync started.', [
'year' => $year,
'api_url' => $apiUrl,
'has_api_key' => !empty($apiKey),
'ssl_verify' => $verify,
]);
if (empty($apiKey)) {
$this->error->add('api', 'Hiányzó szunetnapok.hu API kulcs.');
Log::channel('work_calendar')->error('WorkCalendar sync failed: Missing API key.');
return false;
}
try {
$response = Http::withOptions(['verify' => $verify])
->get("{$apiUrl}/{$apiKey}/{$year}/json");
Log::channel('work_calendar')->info('WorkCalendar API response.', [
'status' => $response->status(),
'body' => $response->body(),
]);
if ($response->failed()) {
$this->error->add('api', 'Nem sikerült elérni a szunetnapok.hu API-t.');
return false;
}
} catch (\Exception $e) {
$this->error->add('api', 'Hálózati hiba az API hívás közben: ' . $e->getMessage());
Log::channel('work_calendar')->error('WorkCalendar sync API error (cURL/SSL):', [
'message' => $e->getMessage(),
'exception' => get_class($e),
]);
return false;
}
$data = $response->json();
if (($data['response'] ?? '') !== 'OK') {
$this->error->add('api', $data['message'] ?? 'Hibás válasz az API-tól.');
return false;
}
if (!isset($data['days']) || !is_array($data['days'])) {
Log::channel('work_calendar')->info('WorkCalendar sync: No days to process.');
return true;
}
$savedRecords = [];
foreach ($data['days'] as $day) {
$type = ($day['type'] == 1) ? WorkDayType::Holiday : WorkDayType::WorkingDay;
$record = WorkCalendar::updateOrCreate(
['date' => $day['date']],
[
'type' => $type,
'description' => $day['name'] ?? '',
'data_source' => DataSource::Api,
]
);
$savedRecords[] = $record->toArray();
}
Log::channel('work_calendar')->info('WorkCalendar sync completed.', [
'count' => count($savedRecords),
'records' => $savedRecords,
]);
return true;
}
/**
* Megállapítja, hogy az adott nap munkanap-e.
*/
public function isWorkDay(Carbon $date): bool
{
$record = WorkCalendar::where('date', $date->toDateString())->first();
if ($record) {
return $record->type === WorkDayType::WorkingDay;
}
// Ha nincs az adatbázisban, a hétvégék alapján döntünk
return !$date->isWeekend();
}
/**
* Kiszámítja a következő érvényes munkanapot.
*/
public function getNextWorkDay(Carbon $date): Carbon
{
$nextDay = $date->copy()->addDay();
while (!$this->isWorkDay($nextDay)) {
$nextDay->addDay();
}
return $nextDay;
}
/**
* Visszaadja a megadott időszak munkaszüneti napjait és plusz munkanapjait.
*/
public function getCalendarEntries(Carbon $start, Carbon $end): Collection
{
return WorkCalendar::whereBetween('date', [$start->toDateString(), $end->toDateString()])
->orderBy('date')
->get();
}
}

View File

@ -81,6 +81,13 @@
// 'formatter'=>\Monolog\Formatter\HtmlFormatter::class, // 'formatter'=>\Monolog\Formatter\HtmlFormatter::class,
], ],
'work_calendar' => [
'driver' => 'daily',
'path' => storage_path('logs/WorkCalendarAPI.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
'BrowserConsole' => [ 'BrowserConsole' => [
'driver' => 'monolog', 'driver' => 'monolog',
'handler' => \Monolog\Handler\BrowserConsoleHandler::class, 'handler' => \Monolog\Handler\BrowserConsoleHandler::class,

View File

@ -35,4 +35,10 @@
], ],
], ],
'szunetnapok' => [
'key' => env('SZUNETNAPOK_API_KEY'),
'url' => env('SZUNETNAPOK_API_URL', 'https://szunetnapok.hu/api/'),
'verify' => env('SZUNETNAPOK_API_VERIFY', true),
],
]; ];

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('work_calendars', function (Blueprint $table) {
$table->id();
$table->date('date')->unique();
$table->enum('type', array_column(\App\Enums\WorkDayType::cases(), 'value'));
$table->enum('data_source', array_column(\App\Enums\DataSource::cases(), 'value'))->default(\App\Enums\DataSource::Manual->value);
$table->text('description')->nullable();
// Audit mezők a BaseAuditable-hoz
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('work_calendars');
}
};

View File

@ -23,8 +23,8 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_DATABASE" value="d2dTest"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>

View File

@ -0,0 +1,173 @@
<?php
use App\Enums\WorkDayType;
use App\Models\WorkCalendar;
use App\Services\WorkCalendarService;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
uses(TestCase::class, RefreshDatabase::class);
test('it correctly identifies working days and holidays', function () {
$service = new WorkCalendarService();
// 2026-04-11 Szombat (Alapesetben nem munkanap)
$saturday = Carbon::parse('2026-04-11');
expect($service->isWorkDay($saturday))->toBeFalse();
// 2026-04-13 Hétfő (Alapesetben munkanap)
$monday = Carbon::parse('2026-04-13');
expect($service->isWorkDay($monday))->toBeTrue();
// Rögzítsünk egy szünnapot a hétfőre
WorkCalendar::create([
'date' => '2026-04-13',
'type' => WorkDayType::Holiday,
'description' => 'Test Holiday'
]);
expect($service->isWorkDay($monday))->toBeFalse();
// Rögzítsünk egy munkanapot a szombatra
WorkCalendar::create([
'date' => '2026-04-11',
'type' => WorkDayType::WorkingDay,
'description' => 'Test Working Saturday'
]);
expect($service->isWorkDay($saturday))->toBeTrue();
});
test('it calculates next working day correctly', function () {
$service = new WorkCalendarService();
// 2026-04-10 Péntek
$friday = Carbon::parse('2026-04-10');
// Alapesetben a hétvége miatt hétfő lesz a következő munkanap
expect($service->getNextWorkDay($friday)->toDateString())->toBe('2026-04-13');
// Ha a szombat munkanap lesz
WorkCalendar::create([
'date' => '2026-04-11',
'type' => WorkDayType::WorkingDay,
'description' => 'Working Saturday'
]);
expect($service->getNextWorkDay($friday)->toDateString())->toBe('2026-04-11');
// Ha a hétfő szünnap lesz
WorkCalendar::create([
'date' => '2026-04-13',
'type' => WorkDayType::Holiday,
'description' => 'Monday Holiday'
]);
// Péntek után szombat munkanap, vasárnap nem, hétfő szünnap -> kedd (04-14)
expect($service->getNextWorkDay($friday)->toDateString())->toBe('2026-04-11');
$saturday = Carbon::parse('2026-04-11');
expect($service->getNextWorkDay($saturday)->toDateString())->toBe('2026-04-14');
});
test('it can sync holidays from API', function () {
$service = new WorkCalendarService();
Http::fake([
'szunetnapok.hu/api/*' => Http::response([
'response' => 'OK',
'year' => 2026,
'days' => [
[
'date' => '2026-01-01',
'name' => 'Új Év napja',
'type' => 1,
'weekday' => 5
],
[
'date' => '2026-05-10',
'name' => 'Áthelyezett munkanap',
'type' => 2,
'weekday' => 6
]
]
], 200)
]);
$result = $service->syncFromApi(2026);
expect($result)->toBeTrue();
expect(WorkCalendar::count())->toBe(2);
$holiday = WorkCalendar::where('date', '2026-01-01')->first();
expect($holiday->type)->toBe(WorkDayType::Holiday);
expect($holiday->data_source)->toBe(\App\Enums\DataSource::Api);
$workingDay = WorkCalendar::where('date', '2026-05-10')->first();
expect($workingDay->type)->toBe(WorkDayType::WorkingDay);
expect($workingDay->data_source)->toBe(\App\Enums\DataSource::Api);
});
test('it handles API errors during sync', function () {
$service = new WorkCalendarService();
Http::fake([
'szunetnapok.hu/api/*' => Http::response([
'response' => 'Error',
'message' => 'Nincs API kulcs megadva'
], 200)
]);
$result = $service->syncFromApi(2026);
expect($result)->toBeFalse();
expect($service->getError()['api'][0])->toBe('Nincs API kulcs megadva');
});
test('it logs API calls and database entries during sync', function () {
$service = new WorkCalendarService();
Http::fake([
'szunetnapok.hu/api/*' => Http::response([
'response' => 'OK',
'days' => [
[
'date' => '2026-01-01',
'name' => 'Új Év',
'type' => 1
]
]
], 200)
]);
$mock = Log::partialMock();
$mock->shouldReceive('channel')->with('work_calendar')->andReturnSelf();
$mock->shouldReceive('info')->atLeast()->once();
$service->syncFromApi(2026);
});
test('it logs cURL/SSL exceptions during sync', function () {
$service = new WorkCalendarService();
Http::fake([
'szunetnapok.hu/api/*' => function () {
throw new \Exception('cURL error 60: SSL certificate problem');
}
]);
$mock = Log::partialMock();
$mock->shouldReceive('channel')->with('work_calendar')->andReturnSelf();
$mock->shouldReceive('info')->once(); // Start log
$mock->shouldReceive('error')->once()->withArgs(function ($message, $context) {
return str_contains($message, 'cURL/SSL') &&
str_contains($context['message'], 'SSL certificate problem');
});
$result = $service->syncFromApi(2026);
expect($result)->toBeFalse();
expect($service->getError()['api'][0])->toContain('Hálózati hiba');
});

View File

@ -6,5 +6,14 @@
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
// protected function setUp(): void
{
parent::setUp();
$currentDb = config('database.connections.mysql.database');
if (strtolower($currentDb) !== strtolower('d2dTest') && env('APP_ENV') === 'testing') {
throw new \Exception('KRITIKUS HIBA: A tesztek NEM a d2dTest adatbázison futnak! Aktuális adatbázis: ' . $currentDb);
}
}
} }