opsdash-app/opsdash/tests/php/Service/ReportRenderServiceTest.php
blade34242 32c5b95894
All checks were successful
Nextcloud Server Tests / version-consistency (push) Successful in 32s
Nextcloud Server Tests / matrix-config (push) Successful in 27s
Nextcloud Server Tests / Nextcloud stable30 / PHP 8.2 (stable30, 8.2) (push) Successful in 15m47s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.2 (stable31, 8.2) (push) Successful in 16m10s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.3 (stable31, 8.3) (push) Successful in 15m58s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.2 (stable32, 8.2) (push) Successful in 15m55s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.3 (stable32, 8.3) (push) Successful in 16m23s
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.2 (stable33, 8.2) (push) Successful in 17m14s
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.3 (stable33, 8.3) (push) Successful in 16m23s
Refine recap delivery scheduling
2026-05-15 14:01:57 +07:00

201 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Tests\Service;
use OCA\Opsdash\Service\ReportRenderService;
use OCP\IURLGenerator;
use OCP\Mail\IMailer;
use PHPUnit\Framework\TestCase;
class ReportRenderServiceTest extends TestCase {
/**
* @param array<string,mixed> $summary
* @param array<string,mixed> $reportingConfig
* @return array{subject:string,plain:string,html:string}
*/
private function render(array $summary, array $reportingConfig): array {
$mailer = new ReportRenderTestMailer();
$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->method('linkToRouteAbsolute')->willReturn('http://localhost/index.php/apps/opsdash/overview');
$service = new ReportRenderService($mailer, $urlGenerator);
return $service->render($summary, $reportingConfig, 'Admin', 'test_case');
}
/**
* @return array<string,mixed>
*/
private function baseSummary(): array {
return [
'range' => 'week',
'from' => '2026-05-11',
'to' => '2026-05-17',
'selected_labels' => ['Opsdash · Deep Work', 'Opsdash · Meetings'],
'selected_count' => 2,
'total_hours' => 41.0,
'future_hours' => 12.5,
'events' => 25,
'active_days' => 4,
'avg_per_day' => 10.25,
'avg_per_event' => 1.64,
'top_calendar' => ['label' => 'Opsdash · Deep Work', 'hours' => 20.5],
'top_category' => ['label' => 'Work', 'hours' => 30.0],
'busiest_day' => ['date' => '2026-05-11', 'hours' => 16.5, 'events' => 11],
'days_off' => 3,
'longest_session' => ['summary' => 'Architecture workshop', 'calendar' => 'Opsdash · Deep Work', 'hours' => 3.0, 'start' => '2026-05-12 08:30:00'],
'balance' => ['index' => 0.5, 'warnings' => ['Balance index low (0.50).']],
'notes' => ['current' => '', 'previous' => ''],
'targets' => [
'total' => [
'actual' => 41.0,
'target' => 34.0,
'remaining' => 0.0,
'percent' => 120.6,
'status' => 'done',
],
'calendars' => [
['label' => 'Opsdash · Deep Work', 'actual' => 20.5, 'target' => 16.0, 'percent' => 128.1, 'status' => 'done'],
['label' => 'Opsdash · Meetings', 'actual' => 8.0, 'target' => 10.0, 'percent' => 80.0, 'status' => 'at_risk'],
],
'categories' => [
['label' => 'Work', 'actual' => 30.0, 'target' => 24.0, 'percent' => 125.0, 'status' => 'done'],
['label' => 'Sport', 'actual' => 3.0, 'target' => 6.0, 'percent' => 50.0, 'status' => 'on_track'],
],
],
];
}
/**
* @return array<string,mixed>
*/
private function reportingConfig(): array {
return [
'enabled' => true,
'modes' => [
'week' => ['enabled' => true, 'delivery' => 'final', 'sendTimeLocal' => '06:00'],
'month' => ['enabled' => false, 'delivery' => 'checkpoint_final', 'sendTimeLocal' => '18:00'],
],
'alertOnRisk' => true,
'riskThreshold' => 0.85,
'notifyEmail' => true,
'notifyNotification' => true,
];
}
public function testSingleGoalRenderContainsSingleGoalSectionsOnly(): void {
$summary = $this->baseSummary();
$summary['report_variant'] = 'single_goal';
$result = $this->render($summary, $this->reportingConfig());
$this->assertStringContainsString('Model: Single Goal', $result['plain']);
$this->assertStringContainsString('Goal progress', $result['plain']);
$this->assertStringNotContainsString('Calendar targets', $result['plain']);
$this->assertStringNotContainsString('Balance index:', $result['plain']);
}
public function testCalendarGoalsRenderContainsCalendarTargets(): void {
$summary = $this->baseSummary();
$summary['report_variant'] = 'calendar_goals';
$result = $this->render($summary, $this->reportingConfig());
$this->assertStringContainsString('Model: Calendar Goals', $result['plain']);
$this->assertStringContainsString('Calendar targets', $result['plain']);
$this->assertStringContainsString('Opsdash · Meetings', $result['plain']);
$this->assertStringNotContainsString('Balance index:', $result['plain']);
}
public function testCategoryAndCalendarGoalsRenderContainsBalanceAndCategoryTargets(): void {
$summary = $this->baseSummary();
$summary['report_variant'] = 'category_and_calendar_goals';
$result = $this->render($summary, $this->reportingConfig());
$this->assertStringContainsString('Model: Calendar + Category Goals', $result['plain']);
$this->assertStringContainsString('Targets & pace', $result['plain']);
$this->assertStringContainsString('Work: 30.00 h / 24.00 h', $result['plain']);
$this->assertStringContainsString('Balance index: 0.50', $result['plain']);
}
public function testHtmlOutputEscapesUserControlledContent(): void {
$summary = $this->baseSummary();
$summary['report_variant'] = 'single_goal';
$summary['selected_labels'] = ['<img src=x onerror=alert(1)>'];
$summary['top_calendar'] = ['label' => '<svg onload=alert(1)>', 'hours' => 20.5];
$summary['notes'] = [
'current' => "Current note <script>alert('xss')</script>",
'previous' => "Previous note <b>safe?</b>",
];
$result = $this->render($summary, $this->reportingConfig());
$this->assertStringNotContainsString('<script>alert(\'xss\')</script>', $result['html']);
$this->assertStringNotContainsString('<img src=x onerror=alert(1)>', $result['html']);
$this->assertStringNotContainsString('<svg onload=alert(1)>', $result['html']);
$this->assertStringContainsString('&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;', $result['html']);
$this->assertStringContainsString('&lt;img src=x onerror=alert(1)&gt;', $result['html']);
$this->assertStringContainsString('&lt;svg onload=alert(1)&gt;', $result['html']);
}
}
final class ReportRenderTestMailer implements IMailer {
public function createEMailTemplate(string $templateId) {
return new ReportRenderTestTemplate();
}
public function createMessage() {
return new \stdClass();
}
public function send($message) {
return [];
}
}
final class ReportRenderTestTemplate {
private string $subject = '';
/** @var list<string> */
private array $plainParts = [];
/** @var list<string> */
private array $htmlParts = [];
public function setSubject(string $subject): void {
$this->subject = $subject;
}
public function addHeader(): void {}
public function addHeading(string $heading): void {
$this->plainParts[] = $heading;
$this->htmlParts[] = $heading;
}
public function addBodyText(string $html, string $plain): void {
$this->htmlParts[] = $html;
$this->plainParts[] = $plain;
}
public function addBodyButton(string $label, string $url): void {
$this->plainParts[] = $label . ' -> ' . $url;
$this->htmlParts[] = $label . ' -> ' . $url;
}
public function addFooter(string $footer): void {
$this->plainParts[] = strip_tags(str_replace('<br>', "\n", $footer));
$this->htmlParts[] = $footer;
}
public function renderSubject(): string {
return $this->subject;
}
public function renderText(): string {
return implode("\n\n", $this->plainParts);
}
public function renderHtml(): string {
return implode("\n", $this->htmlParts);
}
}