Add tick delete function (#64)
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/64 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
		
							parent
							
								
									16f8631fd4
								
							
						
					
					
						commit
						195de75b78
					
				| @ -376,6 +376,20 @@ time { | |||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .delete-tick-form { | ||||||
|  |     display: inline; | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .delete-tick-button { | ||||||
|  |     background: none; | ||||||
|  |     border: none; | ||||||
|  |     padding: 0; | ||||||
|  |     margin: 0; | ||||||
|  |     cursor: pointer; | ||||||
|  |     font-size: inherit; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /* Mood selection page */ | /* Mood selection page */ | ||||||
| .mood-option input { | .mood-option input { | ||||||
|     position: absolute; |     position: absolute; | ||||||
|  | |||||||
| @ -27,4 +27,35 @@ class TickController extends Controller{ | |||||||
|             echo '<h1>500 - Internal Server Error</h1>'; |             echo '<h1>500 - Internal Server Error</h1>'; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     public function handleDelete(string $id){ | ||||||
|  |         global $app; | ||||||
|  |          | ||||||
|  |         $id = (int) $id; | ||||||
|  |         Log::debug("Attempting to delete tick with ID: {$id}"); | ||||||
|  |          | ||||||
|  |         try { | ||||||
|  |             $tickModel = new TickModel($app['db'], $app['settings']); | ||||||
|  |              | ||||||
|  |             // TickModel->delete() handles validation and sets flash messages:
 | ||||||
|  |             // - "Tick not found" if tick doesn't exist
 | ||||||
|  |             // - "Tick is too old to delete" if outside deletion window  
 | ||||||
|  |             // - "Deleted: '{content}'" on success
 | ||||||
|  |             $success = $tickModel->delete($id); | ||||||
|  |              | ||||||
|  |             if ($success) { | ||||||
|  |                 Log::info("Successfully deleted tick {$id}"); | ||||||
|  |             } else { | ||||||
|  |                 Log::warning("Failed to delete tick {$id}"); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |         } catch (Exception $e) { | ||||||
|  |             Log::error("Exception while deleting tick {$id}: " . $e->getMessage()); | ||||||
|  |             Session::setFlashMessage('error', 'An error occurred while deleting the tick'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Redirect back to homepage
 | ||||||
|  |         header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, '')); | ||||||
|  |         exit(); | ||||||
|  |     } | ||||||
| } | } | ||||||
| @ -25,6 +25,7 @@ class Router { | |||||||
|         ['tkr-setup', 'AdminController@showSetup'], |         ['tkr-setup', 'AdminController@showSetup'], | ||||||
|         ['tkr-setup', 'AdminController@handleSetup', ['POST']], |         ['tkr-setup', 'AdminController@handleSetup', ['POST']], | ||||||
|         ['tick/{id}', 'TickController'], |         ['tick/{id}', 'TickController'], | ||||||
|  |         ['tick/{id}/delete', 'TickController@handleDelete', ['POST']], | ||||||
|         ['css/custom/{filename}.css', 'CssController@serveCustomCss'], |         ['css/custom/{filename}.css', 'CssController@serveCustomCss'], | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -12,6 +12,8 @@ class SettingsModel { | |||||||
|     public ?int $cssId = null; |     public ?int $cssId = null; | ||||||
|     public bool $strictAccessibility = true; |     public bool $strictAccessibility = true; | ||||||
|     public ?int $logLevel = null; |     public ?int $logLevel = null; | ||||||
|  |     // not currently configurable
 | ||||||
|  |     public int $tickDeleteHours = 1; | ||||||
| 
 | 
 | ||||||
|     public function __construct(private PDO $db) {} |     public function __construct(private PDO $db) {} | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -7,8 +7,17 @@ class TickModel { | |||||||
|     public function getPage(int $limit, int $offset = 0): array { |     public function getPage(int $limit, int $offset = 0): array { | ||||||
|         $stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?"); |         $stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?"); | ||||||
|         $stmt->execute([$limit, $offset]); |         $stmt->execute([$limit, $offset]); | ||||||
| 
 |          | ||||||
|         return $stmt->fetchAll(PDO::FETCH_ASSOC); |         $ticks = $stmt->fetchAll(PDO::FETCH_ASSOC); | ||||||
|  |          | ||||||
|  |         return array_map(function($tick) { | ||||||
|  |             $tickTime = new DateTimeImmutable($tick['timestamp'], new DateTimeZone('UTC')); | ||||||
|  |             $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); | ||||||
|  |             $hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600; | ||||||
|  |              | ||||||
|  |             $tick['can_delete'] = $hoursSinceCreation <= $this->settings->tickDeleteHours; | ||||||
|  |             return $tick; | ||||||
|  |         }, $ticks); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public function insert(string $tick, ?DateTimeImmutable $datetime = null): void { |     public function insert(string $tick, ?DateTimeImmutable $datetime = null): void { | ||||||
| @ -35,4 +44,33 @@ class TickModel { | |||||||
|             'settings' => $this->settings, |             'settings' => $this->settings, | ||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     public function delete(int $id): bool { | ||||||
|  |         // Get tick and validate
 | ||||||
|  |         $stmt = $this->db->prepare("SELECT tick, timestamp FROM tick WHERE id=?"); | ||||||
|  |         $stmt->execute([$id]); | ||||||
|  |         $row = $stmt->fetch(PDO::FETCH_ASSOC); | ||||||
|  |          | ||||||
|  |         if ($row === false || empty($row)) { | ||||||
|  |             Session::setFlashMessage('error', 'Tick not found'); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Check deletion window
 | ||||||
|  |         $tickTime = new DateTimeImmutable($row['timestamp'], new DateTimeZone('UTC')); | ||||||
|  |         $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); | ||||||
|  |         $hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600; | ||||||
|  |          | ||||||
|  |         if ($hoursSinceCreation > $this->settings->tickDeleteHours) { | ||||||
|  |             Session::setFlashMessage('error', 'Tick is too old to delete'); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Delete and set success message
 | ||||||
|  |         $stmt = $this->db->prepare("DELETE FROM tick WHERE id=?"); | ||||||
|  |         $stmt->execute([$id]); | ||||||
|  |          | ||||||
|  |         Session::setFlashMessage('success', "Deleted: '{$row['tick']}'"); | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,7 +23,15 @@ class TicksView { | |||||||
|                     $relativeTime = Util::relative_time($tick['timestamp']); |                     $relativeTime = Util::relative_time($tick['timestamp']); | ||||||
|                 ?>
 |                 ?>
 | ||||||
|                 <li class="tick" tabindex="0"> |                 <li class="tick" tabindex="0"> | ||||||
|                     🗑️ <time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
 |                     <?php if ($tick['can_delete']): ?>
 | ||||||
|  |                     <form method="post" | ||||||
|  |                           action="<?= Util::buildRelativeUrl($settings->basePath, "tick/{$tick['id']}/delete") ?>" | ||||||
|  |                           class="delete-tick-form"> | ||||||
|  |                         <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> | ||||||
|  |                         <button type="submit" class="delete-tick-button">🗑️</button> | ||||||
|  |                     </form> | ||||||
|  |                     <?php endif ?>
 | ||||||
|  |                     <time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
 | ||||||
|                     <span class="tick-text"><?php echo Util::linkify(Util::escape_html($tick['tick'])) ?></span>
 |                     <span class="tick-text"><?php echo Util::linkify(Util::escape_html($tick['tick'])) ?></span>
 | ||||||
|                 </li> |                 </li> | ||||||
|             <?php endforeach; ?>
 |             <?php endforeach; ?>
 | ||||||
|  | |||||||
| @ -152,5 +152,4 @@ class TickControllerTest extends TestCase | |||||||
|         // Should return 500 error
 |         // Should return 500 error
 | ||||||
|         $this->assertStringContainsString('500 - Internal Server Error', $output); |         $this->assertStringContainsString('500 - Internal Server Error', $output); | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
| } | } | ||||||
							
								
								
									
										75
									
								
								tests/Model/TickModel/TickModelTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								tests/Model/TickModel/TickModelTest.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  | 
 | ||||||
|  | use PHPUnit\Framework\TestCase; | ||||||
|  | 
 | ||||||
|  | class TickModelTest extends TestCase | ||||||
|  | { | ||||||
|  |     private $mockPdo; | ||||||
|  |     private $settings; | ||||||
|  | 
 | ||||||
|  |     protected function setUp(): void | ||||||
|  |     { | ||||||
|  |         $this->mockPdo = $this->createMock(PDO::class); | ||||||
|  |         $this->settings = new SettingsModel($this->mockPdo); | ||||||
|  |         $this->settings->tickDeleteHours = 1; // 1 hour deletion window
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public function testDeleteWithRecentTick(): void | ||||||
|  |     { | ||||||
|  |         // Mock successful deletion of recent tick
 | ||||||
|  |         $recentTimestamp = (new DateTimeImmutable())->format('Y-m-d H:i:s'); | ||||||
|  |          | ||||||
|  |         $mockStatement1 = $this->createMock(PDOStatement::class); | ||||||
|  |         $mockStatement1->method('execute')->with([123]); | ||||||
|  |         $mockStatement1->method('fetch')->willReturn([ | ||||||
|  |             'tick' => 'Test content', | ||||||
|  |             'timestamp' => $recentTimestamp | ||||||
|  |         ]); | ||||||
|  | 
 | ||||||
|  |         $mockStatement2 = $this->createMock(PDOStatement::class); | ||||||
|  |         $mockStatement2->method('execute')->with([123]); | ||||||
|  | 
 | ||||||
|  |         $this->mockPdo->method('prepare') | ||||||
|  |             ->willReturnOnConsecutiveCalls($mockStatement1, $mockStatement2); | ||||||
|  | 
 | ||||||
|  |         $tickModel = new TickModel($this->mockPdo, $this->settings); | ||||||
|  |         $result = $tickModel->delete(123); | ||||||
|  | 
 | ||||||
|  |         $this->assertTrue($result); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public function testDeleteWithNonexistentTick(): void | ||||||
|  |     { | ||||||
|  |         $mockStatement = $this->createMock(PDOStatement::class); | ||||||
|  |         $mockStatement->method('execute')->with([999]); | ||||||
|  |         $mockStatement->method('fetch')->willReturn(false); | ||||||
|  | 
 | ||||||
|  |         $this->mockPdo->method('prepare')->willReturn($mockStatement); | ||||||
|  | 
 | ||||||
|  |         $tickModel = new TickModel($this->mockPdo, $this->settings); | ||||||
|  |         $result = $tickModel->delete(999); | ||||||
|  | 
 | ||||||
|  |         $this->assertFalse($result); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public function testDeleteWithOldTick(): void | ||||||
|  |     { | ||||||
|  |         // Tick from 3 hours ago (outside 1-hour window)
 | ||||||
|  |         $oldTimestamp = (new DateTimeImmutable('-3 hours'))->format('Y-m-d H:i:s'); | ||||||
|  |          | ||||||
|  |         $mockStatement = $this->createMock(PDOStatement::class); | ||||||
|  |         $mockStatement->method('execute')->with([456]); | ||||||
|  |         $mockStatement->method('fetch')->willReturn([ | ||||||
|  |             'tick' => 'Old content', | ||||||
|  |             'timestamp' => $oldTimestamp | ||||||
|  |         ]); | ||||||
|  | 
 | ||||||
|  |         $this->mockPdo->method('prepare')->willReturn($mockStatement); | ||||||
|  | 
 | ||||||
|  |         $tickModel = new TickModel($this->mockPdo, $this->settings); | ||||||
|  |         $result = $tickModel->delete(456); | ||||||
|  | 
 | ||||||
|  |         $this->assertFalse($result); | ||||||
|  |     } | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user