feat(issues): notificaciones, plantillas de checklist, alertas de vencimiento y reporte desde el mapa
- Notificaciones (DB): asignación de incidencia (IssueAssigned), asignación de tarea (IssueTaskAssigned), comentario (IssueCommented) y cambio de estado (IssueStatusChanged) a reporter+asignado excluyendo al actor. - Plantillas de checklist: tabla issue_checklist_templates + modelo, gestor CRUD (IssueChecklistManager, ruta projects.issues.checklists) y "Aplicar plantilla" en el detalle (alta masiva de tareas). - Alertas de vencimiento: columna overdue_notified_at + scope overdue, comando issues:notify-overdue (programado a diario) que avisa al asignado una sola vez; badge "vencidas" en la tabla y resaltado por tarea en el detalle. - Reporte desde el mapa: botón "Incidencia" en el panel del feature seleccionado → formulario con feature pre-vinculado (IssueForm lee ?feature=). Tests: IssuesEnhancementsTest (7). Suite 57 passing (solo 2 pre-existentes sqlite). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\IssueChecklistManager;
|
||||
use App\Livewire\IssueDetail;
|
||||
use App\Livewire\IssueForm;
|
||||
use App\Models\Feature;
|
||||
use App\Models\Issue;
|
||||
use App\Models\IssueChecklistTemplate;
|
||||
use App\Models\IssueTask;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Notifications\IssueCommentedNotification;
|
||||
use App\Notifications\IssueStatusChangedNotification;
|
||||
use App\Notifications\IssueTaskAssignedNotification;
|
||||
use App\Notifications\IssueTaskOverdueNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IssuesEnhancementsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private User $assignee;
|
||||
private Project $project;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['view issues', 'create issues', 'edit issues', 'delete issues', 'upload media'] as $p) {
|
||||
Permission::findOrCreate($p);
|
||||
}
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->givePermissionTo(['view issues', 'create issues', 'edit issues', 'delete issues', 'upload media']);
|
||||
$this->assignee = User::factory()->create();
|
||||
|
||||
$this->project = Project::create([
|
||||
'reference' => 'ENH-1',
|
||||
'name' => 'Proyecto Enh',
|
||||
'address' => 'Calle 1',
|
||||
'lat' => 40.0,
|
||||
'lng' => -3.0,
|
||||
'start_date' => now()->toDateString(),
|
||||
'end_date_estimated' => now()->addMonths(3)->toDateString(),
|
||||
'status' => 'in_progress',
|
||||
'created_by' => $this->user->id,
|
||||
]);
|
||||
$this->project->users()->attach($this->user->id, ['role_in_project' => 'supervisor']);
|
||||
$this->project->users()->attach($this->assignee->id, ['role_in_project' => 'worker']);
|
||||
}
|
||||
|
||||
private function makeIssue(array $attrs = []): Issue
|
||||
{
|
||||
return Issue::create(array_merge([
|
||||
'project_id' => $this->project->id,
|
||||
'title' => 'Incidencia enh',
|
||||
'status' => 'open',
|
||||
'priority' => 'medium',
|
||||
'reported_by' => $this->user->id,
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
private function makeFeature(): Feature
|
||||
{
|
||||
$phase = Phase::create([
|
||||
'project_id' => $this->project->id, 'name' => 'F', 'order' => 1,
|
||||
'color' => '#000', 'progress_percent' => 0,
|
||||
]);
|
||||
$layer = Layer::create([
|
||||
'project_id' => $this->project->id, 'phase_id' => $phase->id,
|
||||
'name' => 'L', 'color' => '#111', 'uploaded_by' => $this->user->id,
|
||||
]);
|
||||
return Feature::create([
|
||||
'layer_id' => $layer->id, 'name' => 'Muro norte',
|
||||
'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]],
|
||||
'progress' => 0, 'status' => 'planned',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_assigning_a_task_notifies_the_assignee(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$issue = $this->makeIssue();
|
||||
|
||||
Livewire::actingAs($this->user)
|
||||
->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue])
|
||||
->set('newTaskTitle', 'Sanear zona')
|
||||
->set('newTaskAssignee', $this->assignee->id)
|
||||
->call('addTask');
|
||||
|
||||
Notification::assertSentTo($this->assignee, IssueTaskAssignedNotification::class);
|
||||
}
|
||||
|
||||
public function test_commenting_notifies_the_assignee(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$issue = $this->makeIssue(['assigned_to' => $this->assignee->id]);
|
||||
|
||||
Livewire::actingAs($this->user)
|
||||
->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue])
|
||||
->set('newComment', 'Revisado en obra')
|
||||
->call('addComment');
|
||||
|
||||
Notification::assertSentTo($this->assignee, IssueCommentedNotification::class);
|
||||
}
|
||||
|
||||
public function test_resolving_notifies_stakeholders(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$issue = $this->makeIssue(['assigned_to' => $this->assignee->id]);
|
||||
|
||||
Livewire::actingAs($this->user)
|
||||
->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue])
|
||||
->call('verifyResolve');
|
||||
|
||||
Notification::assertSentTo($this->assignee, IssueStatusChangedNotification::class);
|
||||
$this->assertEquals('resolved', $issue->fresh()->status);
|
||||
}
|
||||
|
||||
// ── Checklist templates ────────────────────────────────────────────────────────
|
||||
|
||||
public function test_checklist_manager_creates_a_template(): void
|
||||
{
|
||||
Livewire::actingAs($this->user)
|
||||
->test(IssueChecklistManager::class, ['project' => $this->project])
|
||||
->set('name', 'Reparación grieta')
|
||||
->set('items', ['Picar', 'Sellar', 'Pintar'])
|
||||
->call('save');
|
||||
|
||||
$this->assertDatabaseHas('issue_checklist_templates', [
|
||||
'project_id' => $this->project->id, 'name' => 'Reparación grieta',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_applying_a_template_creates_the_tasks(): void
|
||||
{
|
||||
$issue = $this->makeIssue();
|
||||
$template = IssueChecklistTemplate::create([
|
||||
'project_id' => $this->project->id,
|
||||
'name' => 'Plantilla',
|
||||
'items' => ['Picar', 'Sellar', 'Pintar'],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->user)
|
||||
->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue])
|
||||
->set('applyTemplateId', $template->id)
|
||||
->call('applyTemplate');
|
||||
|
||||
$this->assertEquals(3, $issue->tasks()->count());
|
||||
$this->assertEqualsCanonicalizing(['Picar', 'Sellar', 'Pintar'], $issue->tasks()->pluck('title')->all());
|
||||
}
|
||||
|
||||
// ── Overdue alerts ───────────────────────────────────────────────────────────
|
||||
|
||||
public function test_overdue_command_notifies_assignee_once(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$issue = $this->makeIssue();
|
||||
$task = $issue->tasks()->create([
|
||||
'title' => 'Tarea vencida',
|
||||
'assigned_to' => $this->assignee->id,
|
||||
'due_date' => now()->subDays(2)->toDateString(),
|
||||
'is_done' => false,
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
]);
|
||||
|
||||
$this->artisan('issues:notify-overdue')->assertSuccessful();
|
||||
$this->artisan('issues:notify-overdue')->assertSuccessful(); // idempotent
|
||||
|
||||
Notification::assertSentTo($this->assignee, IssueTaskOverdueNotification::class);
|
||||
Notification::assertCount(1);
|
||||
$this->assertNotNull($task->fresh()->overdue_notified_at);
|
||||
}
|
||||
|
||||
// ── Report issue from a map feature ──────────────────────────────────────────
|
||||
|
||||
public function test_issue_form_prelinks_feature_from_query(): void
|
||||
{
|
||||
$feature = $this->makeFeature();
|
||||
|
||||
// The create form mounts during the GET request, where it reads ?feature=.
|
||||
$this->actingAs($this->user)
|
||||
->get(route('projects.issues.create', ['project' => $this->project, 'feature' => $feature->id]))
|
||||
->assertOk()
|
||||
->assertSee('Muro norte')
|
||||
->assertSee('Vinculada al elemento del mapa');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user