From 5b12eb3e8c4ab0658859867f241d93364aa0fd27 Mon Sep 17 00:00:00 2001 From: yaambe Date: Sat, 25 Apr 2026 22:03:34 +0500 Subject: [PATCH] Add Laravel Boost integration and MCP server tools --- composer.json | 2 + resources/boost/guidelines/core.blade.php | 41 +++++ .../boost/skills/stats-development/SKILL.md | 159 ++++++++++++++++ .../stats-development/references/advanced.md | 71 +++++++ .../references/querying-and-formatting.md | 96 ++++++++++ .../references/routes-and-export.md | 71 +++++++ src/Mcp/StatsMcpServer.php | 49 +++++ src/Mcp/Tools/ListFormatters.php | 35 ++++ src/Mcp/Tools/ListMetrics.php | 42 +++++ src/Mcp/Tools/QueryStat.php | 138 ++++++++++++++ src/StatsServiceProvider.php | 27 +++ tests/Feature/Mcp/StatsMcpServerTest.php | 44 +++++ .../Feature/Mcp/Tools/ListFormattersTest.php | 73 ++++++++ tests/Feature/Mcp/Tools/ListMetricsTest.php | 91 +++++++++ tests/Feature/Mcp/Tools/QueryStatTest.php | 174 ++++++++++++++++++ 15 files changed, 1113 insertions(+) create mode 100644 resources/boost/guidelines/core.blade.php create mode 100644 resources/boost/skills/stats-development/SKILL.md create mode 100644 resources/boost/skills/stats-development/references/advanced.md create mode 100644 resources/boost/skills/stats-development/references/querying-and-formatting.md create mode 100644 resources/boost/skills/stats-development/references/routes-and-export.md create mode 100644 src/Mcp/StatsMcpServer.php create mode 100644 src/Mcp/Tools/ListFormatters.php create mode 100644 src/Mcp/Tools/ListMetrics.php create mode 100644 src/Mcp/Tools/QueryStat.php create mode 100644 tests/Feature/Mcp/StatsMcpServerTest.php create mode 100644 tests/Feature/Mcp/Tools/ListFormattersTest.php create mode 100644 tests/Feature/Mcp/Tools/ListMetricsTest.php create mode 100644 tests/Feature/Mcp/Tools/QueryStatTest.php diff --git a/composer.json b/composer.json index 422a094..b763416 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,8 @@ "plannr/laravel-fast-refresh-database": "^1.2" }, "suggest": { + "laravel/boost": "Required for AI-assisted development guidelines and skills", + "laravel/mcp": "Required for MCP server tools integration", "spatie/laravel-activitylog": "Required for included activity log based stats" }, "autoload": { diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..6b44aa8 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,41 @@ +## Javaabu Stats + +This package provides time-series statistics for Laravel applications, supporting aggregate count and sum queries with filters, formatters, date ranges, and CSV export. + +### Architecture + +Stats are managed through a central static registry `TimeSeriesStats` and follow a repository pattern: + +- `CountStatsRepository` — counts rows grouped by time period. Implement `query(): Builder`, `getTable(): string`, `getAggregateFieldName(): string`. +- `SumStatsRepository` — sums a numeric field grouped by time period. Same methods as count, plus `getFieldToSum(): string`. + +Stat classes live in `App\Stats\TimeSeries\` and are registered via `TimeSeriesStats::register()` in a service provider's `boot()` method with snake_case metric names. + +### Artisan Generator + +Generate and auto-register a stat class: `php artisan stats:time-series {Name} {Model} --type={count|sum}`. + +### Filters + +Define allowed filters by returning `Filter` objects from `allowedFilters()` using the `StatsFilter` factory: + +- `StatsFilter::exact('name', 'column')` — matches a column value exactly +- `StatsFilter::scope('name', 'scopeMethod')` — calls an Eloquent query scope +- `StatsFilter::closure('name', fn)` — applies arbitrary query logic + +### Formatters + +Five built-in formatters: `default`, `chartjs`, `sparkline`, `flot`, `combined`. Register custom formatters via `TimeSeriesStats::registerFormatters()`. + +### Routes + +- `TimeSeriesStats::registerApiRoute()` — registers a GET endpoint returning JSON stats data +- `TimeSeriesStats::registerRoutes()` — registers GET (web view) and POST (CSV export) endpoints + +### Authorization + +Override `canView(?Authorizable $user)` on a stat repository to control access. The default checks for `view_stats` permission. The `stats.view-time-series` middleware alias is auto-registered. + +### Configuration + +Publish with `php artisan vendor:publish --tag=stats-config`. Other publish tags: `stats-views`, `stats-stubs`. Key options in `config/stats.php`: `week_starts_on_sunday`, `date_locale`, `date_formats`, `default_time_series_mode`, `default_date_range`, `framework`, `default_layout`. diff --git a/resources/boost/skills/stats-development/SKILL.md b/resources/boost/skills/stats-development/SKILL.md new file mode 100644 index 0000000..735e848 --- /dev/null +++ b/resources/boost/skills/stats-development/SKILL.md @@ -0,0 +1,159 @@ +--- +name: stats-development +description: Use when creating stat classes, registering metrics, adding filters, setting up stats routes, or refactoring existing stats code with javaabu/stats. +--- + +# Stats Development + +## When to use this skill + +Use when creating stat repositories, registering metrics, adding filters, or setting up stats routes with `javaabu/stats`. + +## Core Principle: Use What Exists + +The package provides built-in controllers, routes, middleware, formatters, and export. Your job is to generate stat repository classes for each model — not to reimplement infrastructure. Specifically: + +- **Use the Artisan generator** to scaffold stat classes — it auto-registers them too +- **Use `registerApiRoute()` / `registerRoutes()`** — never write custom route handlers for stats +- **Use built-in formatters** (`default`, `chartjs`, `sparkline`, `flot`, `combined`) before creating custom ones +- **Use the `stats.view-time-series` middleware** — it's auto-registered, don't recreate auth logic +- **Use `ExportsTimeSeriesStats` trait** in existing controllers for CSV export — don't build export from scratch + +When refactoring existing stats code, check for: direct filter class instantiation (should use `StatsFilter` factory), manual route definitions (should use `registerApiRoute`/`registerRoutes`), and reimplemented formatting or export logic. + +## Creating a Stat + +Place stat classes in `app/Stats/TimeSeries/`. Extend `CountStatsRepository` for row counts or `SumStatsRepository` for numeric sums. One class per model/metric — each stat targets a single table. + +**Quick path — Artisan generator (auto-registers in AppServiceProvider):** + +```bash +php artisan stats:time-series OrdersCount Order --type=count +php artisan stats:time-series PaymentAmounts Payment --type=sum +``` + +**Manual — Count stat with filters** (namespace `App\Stats\TimeSeries`, import `StatsFilter`, `CountStatsRepository`, `Builder`): + +```php +class OrdersCount extends CountStatsRepository +{ + public function query(): Builder { return Order::query(); } + public function getTable(): string { return 'orders'; } + public function getAggregateFieldName(): string { return 'count'; } + + public function allowedFilters(): array + { + return [ + StatsFilter::exact('customer', 'customer_id'), + StatsFilter::exact('status', 'status'), + ]; + } +} +``` + +**For a sum stat**, extend `SumStatsRepository` instead and add one extra method: + +```php +public function getFieldToSum(): string +{ + return 'amount'; +} +``` + +## Registering Stats + +Register in `AppServiceProvider::boot()`. Metric names must be snake_case. + +```php +use Javaabu\Stats\TimeSeriesStats; + +public function boot(): void +{ + TimeSeriesStats::register([ + 'orders_count' => OrdersCount::class, + 'payment_amounts' => PaymentAmounts::class, + ]); +} +``` + +To suppress built-in user_signups/user_logins stats: `TimeSeriesStats::excludeDefaultStats();` + +## Filters + +Always use the `StatsFilter` factory. Never instantiate filter classes directly. + +```php +use Javaabu\Stats\Filters\StatsFilter; + +public function allowedFilters(): array +{ + return [ + // Exact column match + StatsFilter::exact('customer', 'customer_id'), + + // Eloquent query scope — calls $query->whereActive() + StatsFilter::scope('active', 'whereActive'), + + // Custom closure — receives ($query, $value, $stat) + StatsFilter::closure('min_amount', function (Builder $query, $value, $stat) { + return $query->where('amount', '>=', $value); + }), + ]; +} +``` + +Pass filters when creating a stat instance: + +```php +$stats = TimeSeriesStats::createFromMetric('orders_count', PresetDateRanges::THIS_YEAR, [ + 'customer' => 5, + 'status' => 'completed', +]); +``` + +## Routes + +**API route (JSON) — register in `routes/api.php`:** + +```php +use Javaabu\Stats\TimeSeriesStats; + +// IMPORTANT: Do NOT include /api in the URL — routes/api.php adds it automatically. +TimeSeriesStats::registerApiRoute('/stats/time-series', 'stats.time-series.index'); +``` + +**Admin routes (web view + CSV export):** + +```php +TimeSeriesStats::registerRoutes('/stats/time-series', 'stats.index', 'stats.export', ['auth', 'stats.view-time-series']); +``` + +The `stats.view-time-series` middleware alias is auto-registered by the package. + +## Quick Reference + +| What | How | +|------|-----| +| Base classes | `CountStatsRepository`, `SumStatsRepository` | +| Register metrics | `TimeSeriesStats::register(['name' => Class::class])` | +| Create instance | `TimeSeriesStats::createFromMetric('name', $dateRange, $filters)` | +| Time modes | `TimeSeriesModes::HOUR\|DAY\|WEEK\|MONTH\|YEAR` | +| Date ranges | `PresetDateRanges::THIS_YEAR\|LAST_30_DAYS\|LAST_7_DAYS\|...` | +| Custom range | `new ExactDateRange('2024-01-01', '2024-12-31')` | +| Format output | `$stat->format('chartjs', TimeSeriesModes::DAY)` | +| Built-in formats | `default`, `chartjs`, `sparkline`, `flot`, `combined` | +| Get total | `$stat->total()` | +| Custom date col | Override `getDateFieldName()` (default: `created_at`) | +| Authorization | Override `canView(?Authorizable $user)` (default: `view_stats` permission) | + +See `references/` for formatters, export, authorization, and advanced features. + +## Verify Against Live State + +If the app has `laravel/mcp` installed, use the MCP tools to cross-check before writing code: + +- **ListMetrics** — confirm which metrics are registered, their filters, and aggregate fields +- **ListFormatters** — confirm available formatters (including custom ones) +- **QueryStat** — test a metric with real data before building on top of it + +This avoids guessing metric names or filter keys — the MCP tools reflect the actual running application. diff --git a/resources/boost/skills/stats-development/references/advanced.md b/resources/boost/skills/stats-development/references/advanced.md new file mode 100644 index 0000000..260b56d --- /dev/null +++ b/resources/boost/skills/stats-development/references/advanced.md @@ -0,0 +1,71 @@ +# Advanced Features + +## Authorization + +Override `canView()` to customize per-stat access control. Default requires `view_stats` permission. + +```php +use Illuminate\Contracts\Auth\Access\Authorizable; + +public function canView(?Authorizable $user = null): bool +{ + return $user && $user->can('view_order_stats'); +} +``` + +Check if any stat is viewable: `TimeSeriesStats::canViewAny($user)` + +## Custom Date Field + +Override `getDateFieldName()` to group by a column other than `created_at`: + +```php +public function getDateFieldName(): string +{ + return 'completed_at'; +} +``` + +## Login & Signup Repositories + +Built-in abstract repos for tracking user auth activity. Extend and implement `userModelClass()`: + +```php +use Javaabu\Stats\Repositories\TimeSeries\SignupsRepository; + +class CustomerSignups extends SignupsRepository +{ + public function userModelClass(): string + { + return \App\Models\Customer::class; + } +} +``` + +For logins (requires `spatie/laravel-activitylog`), extend `LoginsRepository` the same way. + +The package auto-registers `user_signups` and `user_logins` for the default User model. Suppress with `TimeSeriesStats::excludeDefaultStats()`. + +## Configuration + +Publish: `php artisan vendor:publish --tag=stats-config` + +Key options in `config/stats.php`: + +| Option | Default | Description | +|--------|---------|-------------| +| `week_starts_on_sunday` | `true` | Week grouping start day | +| `date_locale` | `en_GB` | Date format locale | +| `default_time_series_mode` | `DAY` | Default grouping granularity | +| `default_date_range` | `LAST_7_DAYS` | Default date range | +| `framework` | `material-admin-26` | CSS framework for views | + +Other publish tags: `stats-views`, `stats-stubs`. + +## MCP Tools + +Three read-only MCP tools for AI agent integration (requires `laravel/mcp`): + +- **ListMetrics** — lists all registered metrics with classes, aggregate fields, and allowed filters +- **QueryStat** — queries a metric with parameters (mode, date range, filters, format) +- **ListFormatters** — lists available output formats diff --git a/resources/boost/skills/stats-development/references/querying-and-formatting.md b/resources/boost/skills/stats-development/references/querying-and-formatting.md new file mode 100644 index 0000000..8795357 --- /dev/null +++ b/resources/boost/skills/stats-development/references/querying-and-formatting.md @@ -0,0 +1,96 @@ +# Querying Stats & Formatting Output + +## Querying Stats Programmatically + +```php +use Javaabu\Stats\TimeSeriesStats; +use Javaabu\Stats\Enums\PresetDateRanges; +use Javaabu\Stats\Enums\TimeSeriesModes; + +// Create a stat instance +$stat = TimeSeriesStats::createFromMetric('orders_count', PresetDateRanges::LAST_30_DAYS, [ + 'status' => 'completed', +]); + +// Get results grouped by time mode +$results = $stat->results(TimeSeriesModes::DAY); // Collection of [count, day] + +// Get formatted output for a charting library +$chartData = $stat->format('chartjs', TimeSeriesModes::DAY); + +// Get aggregate total +$total = $stat->total(); +``` + +## Preset Date Ranges + +`PresetDateRanges` enum values: `TODAY`, `YESTERDAY`, `THIS_WEEK`, `LAST_WEEK`, `THIS_MONTH`, `LAST_MONTH`, `THIS_YEAR`, `LAST_YEAR`, `LAST_7_DAYS`, `LAST_14_DAYS`, `LAST_30_DAYS`, `LAST_5_YEARS`, `LAST_10_YEARS`, `LIFETIME`. + +For custom ranges: + +```php +use Javaabu\Stats\Support\ExactDateRange; + +$range = new ExactDateRange('2024-01-01', '2024-12-31'); +$stat = TimeSeriesStats::createFromMetric('orders_count', $range); +``` + +## Comparison Periods + +```php +$current = PresetDateRanges::THIS_MONTH; +$previous = $current->getPreviousDateRange(); + +$currentStats = TimeSeriesStats::createFromMetric('orders_count', $current); +$previousStats = TimeSeriesStats::createFromMetric('orders_count', $previous); +``` + +## Time Series Modes + +`TimeSeriesModes` enum: `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR`. Controls SQL grouping granularity. + +```php +$stat->results(TimeSeriesModes::MONTH); // Monthly aggregation +$stat->format('chartjs', TimeSeriesModes::WEEK); // Weekly chart data +``` + +## Built-in Formatters + +`default`, `chartjs`, `sparkline`, `flot`, `combined`. Request via API with `?format=chartjs`. + +## Creating a Custom Formatter + +Extend `AbstractTimeSeriesStatsFormatter` and implement `format()`: + +```php +getAggregateFieldName(); + $results = $stats->results($mode); + + return [ + 'labels' => $results->pluck($mode->value)->toArray(), + 'values' => $results->pluck($field)->toArray(), + 'total' => $stats->total(), + ]; + } +} +``` + +Register in a service provider: + +```php +TimeSeriesStats::registerFormatters([ + 'react' => \App\Formatters\ReactFormatter::class, +]); +``` diff --git a/resources/boost/skills/stats-development/references/routes-and-export.md b/resources/boost/skills/stats-development/references/routes-and-export.md new file mode 100644 index 0000000..260ac50 --- /dev/null +++ b/resources/boost/skills/stats-development/references/routes-and-export.md @@ -0,0 +1,71 @@ +# Routes, Export & Blade Component + +## API Route (JSON) + +Register in `routes/api.php`. The `/api` prefix is added automatically — do not include it in the URL. + +```php +use Javaabu\Stats\TimeSeriesStats; + +// Signature: registerApiRoute(string $url, string $name, array $middleware) +TimeSeriesStats::registerApiRoute( + '/stats/time-series', // URL (no /api prefix) + 'stats.time-series.index', // Route name + ['auth:sanctum', 'stats.view-time-series'] // Middleware +); +``` + +**Query parameters accepted:** `metric`, `mode`, `date_range`, `date_from`, `date_to`, `format`, `filters`. + +## Admin Routes (Web + Export) + +```php +// Signature: registerRoutes(string $url, string $index_name, string $export_name, array $middleware) +TimeSeriesStats::registerRoutes( + '/stats/time-series', // URL + 'stats.time-series.index', // GET route name (view) + 'stats.time-series.export', // POST route name (CSV export) + ['auth', 'stats.view-time-series'] +); +``` + +## CSV Export + +Use `ExportsTimeSeriesStats` trait in a controller: + +```php +use Javaabu\Stats\Concerns\ExportsTimeSeriesStats; +use Javaabu\Stats\Http\Requests\TimeSeriesStatsRequest; + +class StatsController extends Controller +{ + use ExportsTimeSeriesStats; + + public function export(TimeSeriesStatsRequest $request) + { + return $this->exportStats($request); + } +} +``` + +With pre-applied filters (e.g., model-scoped): + +```php +public function export(TimeSeriesStatsRequest $request) +{ + return $this->exportStats($request, ['customer' => $request->route('customer')], 'Customer Stats'); +} +``` + +## Blade Component + +```blade + +``` diff --git a/src/Mcp/StatsMcpServer.php b/src/Mcp/StatsMcpServer.php new file mode 100644 index 0000000..a4f84d1 --- /dev/null +++ b/src/Mcp/StatsMcpServer.php @@ -0,0 +1,49 @@ +> + */ + protected array $tools = [ + ListMetrics::class, + QueryStat::class, + ListFormatters::class, + ]; + + /** + * Get the tool classes registered with this server. + * + * @return array> + */ + public static function toolClasses(): array + { + return (new \ReflectionClass(static::class)) + ->getProperty('tools') + ->getDefaultValue(); + } +} diff --git a/src/Mcp/Tools/ListFormatters.php b/src/Mcp/Tools/ListFormatters.php new file mode 100644 index 0000000..cd07229 --- /dev/null +++ b/src/Mcp/Tools/ListFormatters.php @@ -0,0 +1,35 @@ + $class) { + $formatters[] = [ + 'name' => $name, + 'class' => $class, + ]; + } + + return Response::json($formatters); + } +} diff --git a/src/Mcp/Tools/ListMetrics.php b/src/Mcp/Tools/ListMetrics.php new file mode 100644 index 0000000..05d2747 --- /dev/null +++ b/src/Mcp/Tools/ListMetrics.php @@ -0,0 +1,42 @@ + $class) { + $stat = TimeSeriesStats::createFromMetric($slug); + + $filters = array_map(fn ($filter) => $filter->getName(), $stat->allowedFilters()); + + $metrics[] = [ + 'metric' => $slug, + 'name' => $stat->getName(), + 'class' => $class, + 'aggregate_field' => $stat->getAggregateFieldName(), + 'filters' => $filters, + ]; + } + + return Response::json($metrics); + } +} diff --git a/src/Mcp/Tools/QueryStat.php b/src/Mcp/Tools/QueryStat.php new file mode 100644 index 0000000..9d6b98b --- /dev/null +++ b/src/Mcp/Tools/QueryStat.php @@ -0,0 +1,138 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'metric' => $schema->string() + ->description('The metric slug to query (e.g. "user_signups"). Use list-metrics to see available metrics.') + ->required(), + + 'mode' => $schema->string() + ->enum(array_column(TimeSeriesModes::cases(), 'value')) + ->description('Time grouping mode.') + ->default('day'), + + 'date_range' => $schema->string() + ->description('Preset date range (e.g. "this_year", "last_30_days", "today"). If set, date_from and date_to are ignored.'), + + 'date_from' => $schema->string() + ->description('Custom start date (YYYY-MM-DD). Required if date_range is not set.'), + + 'date_to' => $schema->string() + ->description('Custom end date (YYYY-MM-DD). Required if date_range is not set.'), + + 'format' => $schema->string() + ->description('Output format. Available: ' . implode(', ', TimeSeriesStats::allowedFormats()) . '.') + ->default('default'), + + 'filters' => $schema->object() + ->description('Key-value pairs of filters to apply (e.g. {"customer": 5}).'), + ]; + } + + /** + * Handle the tool request. + */ + public function handle(Request $request): Response + { + $metric = (string) $request->get('metric'); + + if (! $metric || ! array_key_exists($metric, TimeSeriesStats::statsMap())) { + $available = implode(', ', array_keys(TimeSeriesStats::statsMap())); + + return Response::error("Unknown metric \"{$metric}\". Available metrics: {$available}"); + } + + $mode = TimeSeriesModes::tryFrom((string) ($request->get('mode') ?? 'day')) ?? TimeSeriesModes::DAY; + $format = (string) ($request->get('format') ?? 'default'); + $filters = $request->get('filters') ?? []; + + if (is_string($filters)) { + $filters = json_decode($filters, true) ?? []; + } + + try { + $range = $this->resolveDateRange($request); + } catch (Throwable $e) { + return Response::error($e->getMessage()); + } + + try { + $stats = TimeSeriesStats::createFromMetric($metric, $range, $filters); + + $result = $stats->format($format, $mode); + $total = $stats->total(); + + return Response::json([ + 'metric' => $metric, + 'name' => $stats->getName(), + 'mode' => $mode->value, + 'date_from' => $stats->getDateFrom()->toDateTimeString(), + 'date_to' => $stats->getDateTo()->toDateTimeString(), + 'total' => $total, + 'format' => $format, + 'result' => $result, + ]); + } catch (Throwable $e) { + return Response::error('Query failed: ' . $e->getMessage()); + } + } + + /** + * Resolve the date range from the request. + */ + protected function resolveDateRange(Request $request): PresetDateRanges|ExactDateRange + { + $dateRange = $request->get('date_range'); + + if ($dateRange) { + $preset = PresetDateRanges::tryFrom((string) $dateRange); + + if (! $preset) { + $available = implode(', ', array_column(PresetDateRanges::cases(), 'value')); + throw new \InvalidArgumentException("Unknown date range \"{$dateRange}\". Available: {$available}"); + } + + return $preset; + } + + $dateFrom = $request->get('date_from'); + $dateTo = $request->get('date_to'); + + if ($dateFrom && $dateTo) { + return new ExactDateRange($dateFrom, $dateTo); + } + + if ($dateFrom || $dateTo) { + throw new \InvalidArgumentException('Both date_from and date_to are required for a custom date range.'); + } + + return PresetDateRanges::THIS_YEAR; + } +} diff --git a/src/StatsServiceProvider.php b/src/StatsServiceProvider.php index 9199aea..22a3e45 100644 --- a/src/StatsServiceProvider.php +++ b/src/StatsServiceProvider.php @@ -56,6 +56,8 @@ public function boot() if (! TimeSeriesStats::shouldExcludeDefaultStats()) { TimeSeriesStats::registerDefaultStats(); } + + $this->registerMcpTools(); } /** @@ -80,6 +82,31 @@ protected function registerFormatters() ]); } + protected function registerMcpTools() + { + if (! class_exists(\Laravel\Mcp\Server\Tool::class)) { + return; + } + + if (class_exists(\Laravel\Mcp\Facades\Mcp::class)) { + \Laravel\Mcp\Facades\Mcp::local('stats', \Javaabu\Stats\Mcp\StatsMcpServer::class); + } + + $this->mergeBoostToolsConfig(); + } + + protected function mergeBoostToolsConfig() + { + if (! $this->app->configurationIsCached()) { + $existing = $this->app['config']->get('boost.mcp.tools.include', []); + + $this->app['config']->set( + 'boost.mcp.tools.include', + array_merge($existing, \Javaabu\Stats\Mcp\StatsMcpServer::toolClasses()) + ); + } + } + protected function registerMiddlewareAliases() { app('router')->aliasMiddleware('stats.view-time-series', AbortIfCannotViewAnyTimeSeriesStats::class); diff --git a/tests/Feature/Mcp/StatsMcpServerTest.php b/tests/Feature/Mcp/StatsMcpServerTest.php new file mode 100644 index 0000000..77fbce5 --- /dev/null +++ b/tests/Feature/Mcp/StatsMcpServerTest.php @@ -0,0 +1,44 @@ +markTestSkipped('laravel/mcp is not installed.'); + } + } + + public function test_server_class_declares_all_tools(): void + { + $reflection = new \ReflectionClass(StatsMcpServer::class); + $property = $reflection->getProperty('tools'); + $property->setAccessible(true); + + $tools = $property->getDefaultValue(); + + $this->assertContains(ListMetrics::class, $tools); + $this->assertContains(QueryStat::class, $tools); + $this->assertContains(ListFormatters::class, $tools); + $this->assertCount(3, $tools); + } + + public function test_it_merges_tools_into_boost_config(): void + { + $tools = $this->app['config']->get('boost.mcp.tools.include', []); + + $this->assertContains(ListMetrics::class, $tools); + $this->assertContains(QueryStat::class, $tools); + $this->assertContains(ListFormatters::class, $tools); + } +} diff --git a/tests/Feature/Mcp/Tools/ListFormattersTest.php b/tests/Feature/Mcp/Tools/ListFormattersTest.php new file mode 100644 index 0000000..cac94c3 --- /dev/null +++ b/tests/Feature/Mcp/Tools/ListFormattersTest.php @@ -0,0 +1,73 @@ +markTestSkipped('laravel/mcp is not installed.'); + } + } + + public function test_it_lists_registered_formatters(): void + { + TimeSeriesStats::registerFormatters([ + 'default' => DefaultStatsFormatter::class, + 'chartjs' => ChartjsStatsFormatter::class, + ], false); + + $tool = new ListFormatters; + $response = $tool->handle(new Request); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertCount(2, $data); + + $names = array_column($data, 'name'); + $this->assertContains('default', $names); + $this->assertContains('chartjs', $names); + } + + public function test_it_includes_formatter_class(): void + { + TimeSeriesStats::registerFormatters([ + 'default' => DefaultStatsFormatter::class, + ], false); + + $tool = new ListFormatters; + $response = $tool->handle(new Request); + + $data = json_decode((string) $response->content(), true); + + $this->assertEquals('default', $data[0]['name']); + $this->assertEquals(DefaultStatsFormatter::class, $data[0]['class']); + } + + public function test_it_returns_empty_array_when_no_formatters_registered(): void + { + TimeSeriesStats::registerFormatters([], false); + + $tool = new ListFormatters; + $response = $tool->handle(new Request); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertIsArray($data); + $this->assertEmpty($data); + } +} diff --git a/tests/Feature/Mcp/Tools/ListMetricsTest.php b/tests/Feature/Mcp/Tools/ListMetricsTest.php new file mode 100644 index 0000000..360d708 --- /dev/null +++ b/tests/Feature/Mcp/Tools/ListMetricsTest.php @@ -0,0 +1,91 @@ +markTestSkipped('laravel/mcp is not installed.'); + } + } + + public function test_it_lists_registered_metrics(): void + { + TimeSeriesStats::register([ + 'payments_count' => PaymentsCount::class, + 'user_logouts' => UserLogoutsRepository::class, + ], false); + + $tool = new ListMetrics; + $response = $tool->handle(new Request); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertCount(2, $data); + + $metrics = array_column($data, 'metric'); + $this->assertContains('payments_count', $metrics); + $this->assertContains('user_logouts', $metrics); + } + + public function test_it_includes_metric_details(): void + { + TimeSeriesStats::register([ + 'payments_count' => PaymentsCount::class, + ], false); + + $tool = new ListMetrics; + $response = $tool->handle(new Request); + + $data = json_decode((string) $response->content(), true); + $metric = $data[0]; + + $this->assertEquals('payments_count', $metric['metric']); + $this->assertEquals(PaymentsCount::class, $metric['class']); + $this->assertEquals('count', $metric['aggregate_field']); + $this->assertArrayHasKey('filters', $metric); + $this->assertArrayHasKey('name', $metric); + } + + public function test_it_includes_filter_names(): void + { + TimeSeriesStats::register([ + 'payments_count' => PaymentsCount::class, + ], false); + + $tool = new ListMetrics; + $response = $tool->handle(new Request); + + $data = json_decode((string) $response->content(), true); + + $this->assertContains('user', $data[0]['filters']); + } + + public function test_it_returns_empty_array_when_no_metrics_registered(): void + { + TimeSeriesStats::register([], false); + + $tool = new ListMetrics; + $response = $tool->handle(new Request); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertIsArray($data); + $this->assertEmpty($data); + } +} diff --git a/tests/Feature/Mcp/Tools/QueryStatTest.php b/tests/Feature/Mcp/Tools/QueryStatTest.php new file mode 100644 index 0000000..5693587 --- /dev/null +++ b/tests/Feature/Mcp/Tools/QueryStatTest.php @@ -0,0 +1,174 @@ +markTestSkipped('laravel/mcp is not installed.'); + } + + TimeSeriesStats::register([ + 'payments_count' => PaymentsCount::class, + ], false); + + TimeSeriesStats::registerFormatters([ + 'default' => DefaultStatsFormatter::class, + ], false); + } + + public function test_it_returns_error_for_unknown_metric(): void + { + $tool = new QueryStat; + $response = $tool->handle(new Request(['metric' => 'nonexistent'])); + + $this->assertTrue($response->isError()); + $this->assertStringContainsString('Unknown metric', (string) $response->content()); + $this->assertStringContainsString('payments_count', (string) $response->content()); + } + + public function test_it_returns_error_for_empty_metric(): void + { + $tool = new QueryStat; + $response = $tool->handle(new Request([])); + + $this->assertTrue($response->isError()); + $this->assertStringContainsString('Unknown metric', (string) $response->content()); + } + + public function test_it_returns_error_for_invalid_date_range(): void + { + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + 'date_range' => 'invalid_range', + ])); + + $this->assertTrue($response->isError()); + $this->assertStringContainsString('Unknown date range', (string) $response->content()); + } + + public function test_it_returns_error_for_partial_date_range(): void + { + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + 'date_from' => '2024-01-01', + ])); + + $this->assertTrue($response->isError()); + $this->assertStringContainsString('Both date_from and date_to are required', (string) $response->content()); + } + + public function test_it_queries_stat_with_preset_date_range(): void + { + if (! extension_loaded('pdo')) { + $this->markTestSkipped('PDO extension required.'); + } + + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + 'date_range' => 'this_year', + 'mode' => 'month', + ])); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertEquals('payments_count', $data['metric']); + $this->assertEquals('month', $data['mode']); + $this->assertEquals('default', $data['format']); + $this->assertArrayHasKey('total', $data); + $this->assertArrayHasKey('result', $data); + $this->assertArrayHasKey('date_from', $data); + $this->assertArrayHasKey('date_to', $data); + } + + public function test_it_queries_stat_with_custom_date_range(): void + { + if (! extension_loaded('pdo')) { + $this->markTestSkipped('PDO extension required.'); + } + + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + 'date_from' => '2024-01-01', + 'date_to' => '2024-12-31', + ])); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertEquals('payments_count', $data['metric']); + $this->assertStringContainsString('2024-01-01', $data['date_from']); + $this->assertStringContainsString('2024-12-31', $data['date_to']); + } + + public function test_it_defaults_to_this_year_when_no_date_provided(): void + { + if (! extension_loaded('pdo')) { + $this->markTestSkipped('PDO extension required.'); + } + + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + ])); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertStringContainsString(date('Y') . '-01-01', $data['date_from']); + } + + public function test_it_defaults_to_day_mode(): void + { + if (! extension_loaded('pdo')) { + $this->markTestSkipped('PDO extension required.'); + } + + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + 'date_range' => 'today', + ])); + + $this->assertFalse($response->isError()); + + $data = json_decode((string) $response->content(), true); + + $this->assertEquals('day', $data['mode']); + } + + public function test_it_returns_error_on_query_failure(): void + { + if (extension_loaded('pdo')) { + $this->markTestSkipped('This test verifies graceful error handling when DB is unavailable.'); + } + + $tool = new QueryStat; + $response = $tool->handle(new Request([ + 'metric' => 'payments_count', + 'date_range' => 'this_year', + ])); + + $this->assertTrue($response->isError()); + $this->assertStringContainsString('Query failed', (string) $response->content()); + } +}