feature: Initial MVP

This commit is contained in:
Keith Solomon
2026-04-05 16:20:39 -05:00
parent 3af0b9cd0f
commit 812e5c2f2a
60 changed files with 5917 additions and 5 deletions
+322
View File
@@ -0,0 +1,322 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* API integration tests.
*/
declare(strict_types=1);
namespace Tests\Api;
use IronKanban\Service\BoardService;
use Tests\Support\IntegrationTestCase;
/**
* Covers JSON endpoint behavior and request validation.
*/
final class BoardApiTest extends IntegrationTestCase
{
/**
* Ensure the board-state endpoint returns project and board data.
*
* @return void
*/
public function testBoardStateEndpointReturnsProjectState(): void
{
$projectId = $this->createProject();
$response = $this->callApi(
'public/api/board-state.php',
'GET',
['project' => $projectId]
);
self::assertSame(200, $response['status']);
self::assertSame('Demo Project', $response['payload']['project']['title']);
self::assertContains('trash', array_column($response['payload']['columns'], 'id'));
}
/**
* Ensure state-changing endpoints reject missing CSRF tokens.
*
* @return void
*/
public function testCreateTaskEndpointRejectsMissingCsrfToken(): void
{
$projectId = $this->createProject();
$response = $this->callApi(
'public/api/create-task.php',
'POST',
[],
[
'projectId' => $projectId,
'title' => 'Write docs',
'column' => 'backlog',
]
);
self::assertSame(403, $response['status']);
self::assertFalse($response['payload']['success']);
self::assertSame('invalid_csrf', $response['payload']['error']);
}
/**
* Ensure create-task returns a successful JSON state payload.
*
* @return void
*/
public function testCreateTaskEndpointCreatesTaskWithValidCsrfToken(): void
{
$projectId = $this->createProject();
$token = csrf_token();
$response = $this->callApi(
'public/api/create-task.php',
'POST',
[],
[
'projectId' => $projectId,
'title' => 'Write docs',
'column' => 'backlog',
'body' => 'Add the release checklist.',
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertSame('Write docs', $response['payload']['state']['task']['title']);
self::assertSame('backlog', $response['payload']['state']['task']['column']);
self::assertCount(1, $response['payload']['state']['tasks']);
}
/**
* Ensure stale revisions return a conflict response.
*
* @return void
*/
public function testCreateTaskEndpointReturnsConflictForStaleRevision(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$current = $service->getBoardState($projectId);
$token = csrf_token();
$service->createTask($projectId, 'Existing task', 'backlog');
$response = $this->callApi(
'public/api/create-task.php',
'POST',
[],
[
'projectId' => $projectId,
'title' => 'Conflicting task',
'column' => 'ready',
'revision' => (string) $current['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(409, $response['status']);
self::assertFalse($response['payload']['success']);
self::assertSame('conflict', $response['payload']['error']);
}
/**
* Ensure move-task updates the task column and preserves a success response.
*
* @return void
*/
public function testMoveTaskEndpointMovesTaskToRequestedColumn(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$created = $service->createTask($projectId, 'Move me', 'backlog');
$token = csrf_token();
$response = $this->callApi(
'public/api/move-task.php',
'POST',
[],
[
'projectId' => $projectId,
'taskId' => $created['task']['id'],
'column' => 'done',
'index' => 0,
'revision' => (string) $created['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertSame('done', $response['payload']['state']['tasks'][0]['column']);
}
/**
* Ensure delete-task trashes a task by default.
*
* @return void
*/
public function testDeleteTaskEndpointMovesTaskToTrash(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$created = $service->createTask($projectId, 'Archive me', 'review');
$token = csrf_token();
$response = $this->callApi(
'public/api/delete-task.php',
'POST',
[],
[
'projectId' => $projectId,
'taskId' => $created['task']['id'],
'revision' => (string) $created['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertSame('trash', $response['payload']['state']['tasks'][0]['column']);
}
/**
* Ensure save-note creates a note and returns it in the response state.
*
* @return void
*/
public function testSaveNoteEndpointCreatesNote(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$state = $service->getBoardState($projectId);
$token = csrf_token();
$response = $this->callApi(
'public/api/save-note.php',
'POST',
[],
[
'projectId' => $projectId,
'title' => 'Sprint Notes',
'body' => 'Capture follow-up items.',
'revision' => (string) $state['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertSame('Sprint Notes', $response['payload']['state']['note']['title']);
self::assertCount(1, $response['payload']['state']['notes']);
}
/**
* Ensure create-column appends a new column to the board.
*
* @return void
*/
public function testCreateColumnEndpointCreatesNewColumn(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$state = $service->getBoardState($projectId);
$token = csrf_token();
$response = $this->callApi(
'public/api/create-column.php',
'POST',
[],
[
'projectId' => $projectId,
'label' => 'Blocked',
'revision' => (string) $state['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertContains('blocked', array_column($response['payload']['state']['columns'], 'id'));
}
/**
* Ensure save-task updates persisted task fields.
*
* @return void
*/
public function testSaveTaskEndpointUpdatesTaskFields(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$created = $service->createTask($projectId, 'Initial title', 'backlog');
$token = csrf_token();
$response = $this->callApi(
'public/api/save-task.php',
'POST',
[],
[
'projectId' => $projectId,
'taskId' => $created['task']['id'],
'title' => 'Updated title',
'body' => 'Updated body',
'priority' => 'urgent',
'completed' => true,
'revision' => (string) $created['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertSame('Updated title', $response['payload']['state']['tasks'][0]['title']);
self::assertSame("Updated body\n", $response['payload']['state']['tasks'][0]['body']);
self::assertSame('urgent', $response['payload']['state']['tasks'][0]['priority']);
self::assertTrue($response['payload']['state']['tasks'][0]['completed']);
}
/**
* Ensure update-board accepts column replacements and keeps trash available.
*
* @return void
*/
public function testUpdateBoardEndpointReplacesColumnDefinitions(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$state = $service->getBoardState($projectId);
$token = csrf_token();
$response = $this->callApi(
'public/api/update-board.php',
'POST',
[],
[
'projectId' => $projectId,
'columns' => [
['id' => 'ideas', 'label' => 'Ideas'],
['id' => 'doing', 'label' => 'Doing'],
],
'revision' => (string) $state['revision'],
'_token' => $token,
],
['HTTP_X_CSRF_TOKEN' => $token]
);
self::assertSame(200, $response['status']);
self::assertTrue($response['payload']['success']);
self::assertSame(['ideas', 'doing', 'trash'], array_column($response['payload']['state']['columns'], 'id'));
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Board domain tests.
*/
declare(strict_types=1);
namespace Tests\Domain;
use IronKanban\Domain\Board;
use IronKanban\Domain\Column;
use PHPUnit\Framework\TestCase;
/**
* Covers board serialization behavior.
*/
final class BoardTest extends TestCase
{
/**
* Ensure a board exposes its columns and metadata as arrays.
*
* @return void
*/
public function testToArrayIncludesSerializedColumnsAndMeta(): void
{
$board = new Board(
'demo-project',
[
new Column('backlog', 'Backlog', 100),
new Column('done', 'Done', 200),
],
['wip_limit' => 3]
);
self::assertSame(
[
'project_id' => 'demo-project',
'columns' => [
['id' => 'backlog', 'label' => 'Backlog', 'order' => 100],
['id' => 'done', 'label' => 'Done', 'order' => 200],
],
'meta' => ['wip_limit' => 3],
],
$board->toArray()
);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Board service integration tests.
*/
declare(strict_types=1);
namespace Tests\Service;
use IronKanban\Service\BoardService;
use Tests\Support\IntegrationTestCase;
/**
* Covers core board and task workflows against markdown storage.
*/
final class BoardServiceTest extends IntegrationTestCase
{
/**
* Ensure a new project gets the default kanban columns.
*
* @return void
*/
public function testGetBoardStateBuildsDefaultColumnsForNewProject(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$state = $service->getBoardState($projectId);
self::assertSame(
['backlog', 'ready', 'in-progress', 'review', 'done', 'trash'],
array_column($state['columns'], 'id')
);
self::assertSame([], $state['tasks']);
self::assertSame([], $state['notes']);
self::assertSame('Demo Project', $state['project']['title']);
}
/**
* Ensure a created task is persisted and returned in board state.
*
* @return void
*/
public function testCreateTaskPersistsTaskInRequestedColumn(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$state = $service->createTask($projectId, 'Ship first milestone', 'ready', 'Write release notes');
self::assertSame('Ship first milestone', $state['task']['title']);
self::assertSame('ready', $state['task']['column']);
self::assertSame('Write release notes', $state['task']['body']);
self::assertCount(1, $state['tasks']);
self::assertSame($state['task']['id'], $state['tasks'][0]['id']);
self::assertNotSame('0', $state['revision']);
}
/**
* Ensure deleting a task without force moves it into trash.
*
* @return void
*/
public function testDeleteTaskMovesTaskToTrashByDefault(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$created = $service->createTask($projectId, 'Clean up copy', 'in-progress');
$state = $service->deleteTask($projectId, $created['task']['id']);
self::assertCount(1, $state['tasks']);
self::assertSame('trash', $state['tasks'][0]['column']);
self::assertSame($created['task']['id'], $state['tasks'][0]['id']);
}
/**
* Ensure removing a column moves its tasks into trash and keeps trash available.
*
* @return void
*/
public function testUpdateBoardMovesDeletedColumnTasksIntoTrash(): void
{
$projectId = $this->createProject();
$service = new BoardService();
$service->createTask($projectId, 'Review architecture', 'review');
$state = $service->updateBoard(
$projectId,
[
['id' => 'backlog', 'label' => 'Backlog'],
['id' => 'ready', 'label' => 'Ready'],
['id' => 'in-progress', 'label' => 'In Progress'],
['id' => 'done', 'label' => 'Done'],
['id' => 'trash', 'label' => 'Trash'],
],
'review'
);
self::assertContains('trash', array_column($state['columns'], 'id'));
self::assertCount(1, $state['tasks']);
self::assertSame('trash', $state['tasks'][0]['column']);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Front matter support tests.
*/
declare(strict_types=1);
namespace Tests\Support;
use IronKanban\Support\FrontMatter;
use PHPUnit\Framework\TestCase;
/**
* Covers front matter parsing and dumping.
*/
final class FrontMatterTest extends TestCase
{
/**
* Ensure metadata survives a dump and parse cycle.
*
* @return void
*/
public function testDumpAndParseRoundTrip(): void
{
$meta = [
'title' => 'Plan launch',
'completed' => false,
'order' => 200,
'tags' => ['alpha', 'beta'],
'assignee' => [
'name' => 'Casey',
'role' => 'owner',
],
];
$body = "Ship the first milestone.\n\nKeep the checklist updated.";
$document = FrontMatter::parse(FrontMatter::dump($meta, $body));
self::assertSame($meta, $document->meta);
self::assertSame($body . "\n", $document->body);
}
/**
* Ensure plain markdown without front matter is preserved.
*
* @return void
*/
public function testParseWithoutFrontMatterReturnsBodyOnly(): void
{
$document = FrontMatter::parse("Just markdown\n\n- item");
self::assertSame([], $document->meta);
self::assertSame("Just markdown\n\n- item", $document->body);
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Shared integration test support.
*/
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
// phpcs:disable PEAR.NamingConventions.ValidVariableName.PrivateNoUnderscore
declare(strict_types=1);
namespace Tests\Support;
use IronKanban\Support\HttpHalt;
use IronKanban\Support\FrontMatter;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* Provides an isolated markdown project workspace for integration tests.
*/
abstract class IntegrationTestCase extends TestCase
{
/**
* Temporary application root for the current test.
*
* @var string
*/
protected string $tempRoot;
/**
* Temporary project storage root for the current test.
*
* @var string
*/
protected string $projectRoot;
/**
* Saved application config to restore after the test.
*
* @var array<string, mixed>
*/
private array $_originalAppConfig;
/**
* Repository root used to resolve actual application scripts.
*
* @var string
*/
private string $_repoRoot;
/**
* Prepare an isolated workspace for each test.
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
global $appConfig;
$this->_originalAppConfig = is_array($appConfig) ? $appConfig : [];
$this->_repoRoot = (string) ($this->_originalAppConfig['base_path'] ?? dirname(__DIR__, 2));
$this->tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ikb-tests-' . bin2hex(random_bytes(6));
$this->projectRoot = $this->tempRoot . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'projects';
mkdir($this->projectRoot, 0777, true);
$appConfig['base_path'] = $this->tempRoot;
$appConfig['project_root'] = $this->projectRoot;
}
/**
* Restore the original workspace after the test.
*
* @return void
*/
protected function tearDown(): void
{
global $appConfig;
$appConfig = $this->_originalAppConfig;
$this->_removeDirectory($this->tempRoot);
parent::tearDown();
}
/**
* Create a markdown-backed project for tests.
*
* @param string $projectId Project identifier.
* @param string $title Project title.
* @param string $body Project body content.
*
* @return string
*/
protected function createProject(string $projectId = 'demo-project', string $title = 'Demo Project', string $body = 'Project body'): string
{
$projectPath = $this->projectRoot . DIRECTORY_SEPARATOR . $projectId;
mkdir($projectPath, 0777, true);
file_put_contents(
$projectPath . DIRECTORY_SEPARATOR . 'index.md',
FrontMatter::dump(
[
'type' => 'project',
'id' => $projectId,
'title' => $title,
],
$body
)
);
return $projectId;
}
/**
* Invoke an API endpoint script with request globals.
*
* @param string $script Relative script path from repo root.
* @param string $method Request method.
* @param array<string, mixed> $get Query parameters.
* @param array<string, mixed> $post Form payload.
* @param array<string, string> $server Additional server variables.
*
* @return array{status:int, payload:array<string, mixed>, raw:string}
*/
protected function callApi(
string $script,
string $method = 'GET',
array $get = [],
array $post = [],
array $server = []
): array {
$_GET = $get;
$_POST = $post;
$_SERVER = array_merge(
[
'REQUEST_METHOD' => strtoupper($method),
],
$server
);
http_response_code(200);
if (function_exists('header_remove')) {
header_remove();
}
ob_start();
try {
include $this->_repoRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $script);
} catch (HttpHalt $halt) {
}
$raw = (string) ob_get_clean();
$decoded = json_decode($raw, true);
self::assertIsArray($decoded, 'Expected a JSON object response from ' . $script . '.');
return [
'status' => http_response_code(),
'payload' => $decoded,
'raw' => $raw,
];
}
/**
* Recursively remove a directory tree.
*
* @param string $path Directory path.
*
* @return void
*/
private function _removeDirectory(string $path): void
{
if ($path === '' || !is_dir($path)) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
continue;
}
unlink($item->getPathname());
}
rmdir($path);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* PHPUnit bootstrap.
*/
declare(strict_types=1);
define('IKB_TEST_MODE', true);
require_once dirname(__DIR__) . '/bootstrap.php';
spl_autoload_register(
static function (string $class): void {
$prefix = 'Tests\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
include_once $file;
}
}
);
+45
View File
@@ -0,0 +1,45 @@
const { test, expect } = require('@playwright/test');
test.describe('IronKanban smoke', () => {
test('loads the demo board and core UI sections', async ({ page }) => {
await page.goto('/?project=demo-project');
await expect(page.getByRole('heading', { name: 'IronKanban' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Demo Project' })).toBeVisible();
await expect(page.locator('#board-columns .column')).toHaveCount(4);
await expect(page.locator('#trash-panel')).toBeVisible();
await expect(page.locator('#notes-panel')).toBeVisible();
});
test('toggles notes and opens existing note and task dialogs', async ({ page }) => {
await page.goto('/?project=demo-project');
const notesToggle = page.getByRole('button', { name: /Show Notes \(\d+\)/ });
await notesToggle.click();
await expect(page.getByRole('button', { name: /Hide Notes \(\d+\)/ })).toBeVisible();
await expect(page.locator('#notes-list .note-card')).toHaveCount(2);
await page.locator('#notes-list .note-card').first().click();
await expect(page.locator('#note-dialog')).toBeVisible();
await expect(page.locator('#note-form input[name="title"]')).not.toHaveValue('');
await page.keyboard.press('Escape');
await expect(page.locator('#note-dialog')).not.toBeVisible();
await page.locator('#board-columns .task-card').first().click();
await expect(page.locator('#task-dialog')).toBeVisible();
await expect(page.locator('#task-form input[name="title"]')).not.toHaveValue('');
await page.keyboard.press('Escape');
await expect(page.locator('#task-dialog')).not.toBeVisible();
});
test('toggles the trash section and shows trashed tasks', async ({ page }) => {
await page.goto('/?project=demo-project');
const trashToggle = page.getByRole('button', { name: /Show Trash \(\d+\)/ });
await trashToggle.click();
await expect(page.getByRole('button', { name: /Hide Trash \(\d+\)/ })).toBeVisible();
await expect(page.locator('#trash-columns .task-card')).toHaveCount(1);
await expect(page.locator('#trash-dropzone-meta')).toContainText('1 task');
});
});