✨feature: Initial MVP
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user