OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
175
tests/Acceptance/API/ApiCest.php
Normal file
175
tests/Acceptance/API/ApiCest.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance\API;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Install;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
class ApiCest
|
||||
{
|
||||
private string $apiKey;
|
||||
|
||||
private Login $loginPage;
|
||||
|
||||
private Install $installPage;
|
||||
|
||||
public function _before(AcceptanceTester $I, Login $loginPage, Install $installPage)
|
||||
{
|
||||
$this->loginPage = $loginPage;
|
||||
$this->installPage = $installPage;
|
||||
|
||||
// Ensure database is installed before running API tests
|
||||
$this->installPage->install(
|
||||
'test@leantime.io',
|
||||
'Test123456!',
|
||||
'John',
|
||||
'Smith',
|
||||
'Smith & Co'
|
||||
);
|
||||
}
|
||||
|
||||
#[Group('api')]
|
||||
#[Depends('Acceptance\LoginCest:loginSuccessfully')]
|
||||
public function createAPIKey(AcceptanceTester $I)
|
||||
{
|
||||
|
||||
$this->loginPage->login('test@leantime.io', 'test');
|
||||
|
||||
// Generate API key if not exists
|
||||
$I->amOnPage('setting/editCompanySettings#/api/newApiKey');
|
||||
$I->waitForElementVisible('#firstname', 120);
|
||||
|
||||
$I->fillField(['id' => 'firstname'], 'APIUser');
|
||||
$I->selectOption(['id' => 'role'], 'Administrator');
|
||||
$I->waitForElementClickable('#project_1');
|
||||
$I->wait(2);
|
||||
$I->checkOption('#project_1');
|
||||
$I->clickWithRetry('#save');
|
||||
|
||||
$I->waitForElement('#apiKey');
|
||||
|
||||
$this->apiKey = $I->grabValueFrom('#apiKey');
|
||||
|
||||
$I->resetCookie('leantime_session', []);
|
||||
$I->deleteSessionSnapshot('leantime_session');
|
||||
}
|
||||
|
||||
#[Group('api')]
|
||||
#[Depends('createAPIKey')]
|
||||
public function testJsonRpcEndpoint(AcceptanceTester $I)
|
||||
{
|
||||
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->haveHttpHeader('x-api-key', $this->apiKey);
|
||||
|
||||
$I->sendPost('/api/jsonrpc', [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
]);
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeResponseIsJson();
|
||||
$I->seeResponseMatchesJsonType([
|
||||
'jsonrpc' => 'string',
|
||||
'result' => 'array',
|
||||
'id' => 'integer',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Group('api')]
|
||||
#[Depends('createAPIKey')]
|
||||
public function testJsonRpcEndpointStringId(AcceptanceTester $I)
|
||||
{
|
||||
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->haveHttpHeader('x-api-key', $this->apiKey);
|
||||
|
||||
$I->sendPost('/api/jsonrpc', [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 'one',
|
||||
]);
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeResponseIsJson();
|
||||
$I->seeResponseMatchesJsonType([
|
||||
'jsonrpc' => 'string',
|
||||
'result' => 'array',
|
||||
'id' => 'string',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Group('api')]
|
||||
#[Depends('createAPIKey')]
|
||||
public function testInvalidJsonRpcRequest(AcceptanceTester $I)
|
||||
{
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->haveHttpHeader('x-api-key', $this->apiKey);
|
||||
|
||||
$I->sendPost('/api/jsonrpc', [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'invalid.method',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
]);
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeResponseIsJson();
|
||||
$I->seeResponseMatchesJsonType([
|
||||
'jsonrpc' => 'string',
|
||||
'error' => [
|
||||
'code' => 'integer',
|
||||
'message' => 'string',
|
||||
'data' => 'string',
|
||||
],
|
||||
'id' => 'integer',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
#[Group('api')]
|
||||
#[Depends('createAPIKey')]
|
||||
public function testValidReturnId(AcceptanceTester $I)
|
||||
{
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->haveHttpHeader('x-api-key', $this->apiKey);
|
||||
|
||||
$I->sendPost('/api/jsonrpc', [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 123,
|
||||
]);
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeResponseIsJson([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 'integer',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
#[Group('api')]
|
||||
#[Depends('createAPIKey')]
|
||||
public function testMissingApiKey(AcceptanceTester $I)
|
||||
{
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
|
||||
$I->sendPost('/api/jsonrpc', [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
]);
|
||||
|
||||
$I->seeResponseCodeIs(401);
|
||||
}
|
||||
}
|
||||
252
tests/Acceptance/API/BearerApiCest.php
Normal file
252
tests/Acceptance/API/BearerApiCest.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance\API;
|
||||
|
||||
use Codeception\Attribute\Group;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Install;
|
||||
|
||||
/**
|
||||
* Bearer-token JSON-RPC contract test.
|
||||
*
|
||||
* Sibling to ApiCest (x-api-key auth). Exists because the JSON-RPC endpoint accepts three auth
|
||||
* modes (session cookie, x-api-key, Bearer token) and only the first two had coverage. The Bearer
|
||||
* path is what the mobile app + any AdvancedAuth integrator hits, and a permission-engine deploy in
|
||||
* 2026-06 silently broke it (every gated read 401'd) without CI noticing.
|
||||
*
|
||||
* Each test is self-contained: the minted token lives in zp_access_tokens and the Db module deletes
|
||||
* haveInDatabase() rows after each test, so a method keeps everything it needs alive within itself
|
||||
* (a multi-method #[Depends] chain would lose the token between the mint and the assertions). Each
|
||||
* authed call asserts 200, JSON, and NOT a -32001 permission denial — that last assertion is the
|
||||
* regression gate.
|
||||
*
|
||||
* Two scenarios:
|
||||
* - bearerAuthHonorsGatedReads — the OWNER (role 50). Owner short-circuits project-role resolution.
|
||||
* - nonManagerBearerHonorsProjectScopedReads — an EDITOR (role 20). Sub-manager roles run a wholly
|
||||
* different authorization path (getProjectRole + isUserAssignedToProject + a resolved projectId),
|
||||
* which owner-only testing never exercises. This is the path that would silently break for real
|
||||
* non-admin mobile users.
|
||||
*/
|
||||
class BearerApiCest
|
||||
{
|
||||
private string $bearerToken;
|
||||
|
||||
public function _before(AcceptanceTester $I, Install $installPage)
|
||||
{
|
||||
// Fresh install — same fixture as ApiCest so this Cest can run standalone or alongside it.
|
||||
$installPage->install('test@leantime.io', 'Test123456!', 'John', 'Smith', 'Smith & Co');
|
||||
}
|
||||
|
||||
#[Group('bearer-api')]
|
||||
public function bearerAuthHonorsGatedReads(AcceptanceTester $I)
|
||||
{
|
||||
// 1) No bearer → 401. Done first, before any Authorization header is set (Codeception
|
||||
// headers are sticky across requests within a test).
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->sendPost('/api/jsonrpc', json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'leantime.rpc.Tickets.Tickets.getAllOpenUserTickets',
|
||||
'params' => new \stdClass,
|
||||
'id' => 1,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$I->seeResponseCodeIs(401);
|
||||
|
||||
// 2) Mint a Bearer token directly in the DB — no UI flow, no AdvancedAuth plugin. A token
|
||||
// is just a random string whose sha256 is stored in zp_access_tokens, exactly as
|
||||
// AccessTokenRepository::createToken persists it. (Done via the Db module because the
|
||||
// Laravel container is not reliably bootstrapped against the test DB in the acceptance
|
||||
// process, so app()->getUserByEmail() resolves the wrong connection.)
|
||||
$userId = $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']);
|
||||
Assert::assertNotEmpty($userId, 'Test user not found after install');
|
||||
|
||||
$this->bearerToken = bin2hex(random_bytes(20)); // 40-char opaque token
|
||||
$I->haveInDatabase('zp_access_tokens', [
|
||||
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
|
||||
'tokenable_id' => (int) $userId,
|
||||
'name' => 'bearer-api-cest',
|
||||
'token' => hash('sha256', $this->bearerToken),
|
||||
'abilities' => json_encode(['*']),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
// 3) Gated reads over Bearer must all resolve (200, no -32001).
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Users.Users.getUser', new \stdClass);
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Projects.Projects.getProjectsUserHasAccessTo', new \stdClass);
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Tickets.Tickets.getAllOpenUserTickets', new \stdClass);
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Notifications.Notifications.getUnreadCount', new \stdClass);
|
||||
|
||||
// 4) The exact pair that broke mobile: create a ticket, then fetch it by id.
|
||||
// quickAddTicket($params) takes a single array argument literally named "params".
|
||||
$created = $this->rpc($I, 'leantime.rpc.Tickets.Tickets.quickAddTicket', [
|
||||
'params' => ['headline' => 'Bearer-auth contract test', 'projectId' => 1],
|
||||
]);
|
||||
$newId = $created['result'] ?? null;
|
||||
Assert::assertIsInt($newId, 'quickAddTicket should return an int id, got: '.json_encode($created));
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Tickets.Tickets.getTicket', ['id' => $newId]);
|
||||
|
||||
// 5) Entity-scoped comments resolve the project from (module, moduleId).
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Comments.Comments.getComments', ['module' => 'ticket', 'moduleId' => 1]);
|
||||
|
||||
// 6) Session-scoped mobile endpoints: no userId param, the caller is resolved from the
|
||||
// token. These previously 404'd on mobile because the userId-taking originals are
|
||||
// deliberately NOT @api (IDOR guard); the session-scoped siblings are the fix.
|
||||
// getInbox is the inbox list (getAllNotifications stays non-@api — it takes a $userId).
|
||||
$inbox = $this->rpc($I, 'leantime.rpc.Notifications.Notifications.getInbox', new \stdClass);
|
||||
Assert::assertArrayNotHasKey('error', $inbox, 'getInbox must be exposed + session-scoped: '.json_encode($inbox));
|
||||
Assert::assertIsArray($inbox['result'] ?? null, 'getInbox should return an array: '.json_encode($inbox));
|
||||
|
||||
// getExternalCalendarEvents (subscribed iCal feeds) is already session-scoped; it only
|
||||
// lacked the @api tag. No external calendars on a fresh install => an empty array.
|
||||
$extEvents = $this->rpc($I, 'leantime.rpc.Calendar.Calendar.getExternalCalendarEvents', new \stdClass);
|
||||
Assert::assertArrayNotHasKey('error', $extEvents, 'getExternalCalendarEvents must be exposed: '.json_encode($extEvents));
|
||||
Assert::assertIsArray($extEvents['result'] ?? null, 'getExternalCalendarEvents should return an array: '.json_encode($extEvents));
|
||||
|
||||
// getICalUrl is now exposed too. It legitimately errors with -32602 when the user has no
|
||||
// iCal feed configured yet, so a valid response is EITHER a URL string OR -32602 — but
|
||||
// NEVER -32601 (method-not-found regression) or -32001 (permission regression).
|
||||
$icalUrl = $this->rpc($I, 'leantime.rpc.Calendar.Calendar.getICalUrl', new \stdClass);
|
||||
$icalErr = $icalUrl['error']['code'] ?? null;
|
||||
Assert::assertNotSame(-32601, $icalErr, 'getICalUrl must be exposed via @api (not method-not-found): '.json_encode($icalUrl));
|
||||
Assert::assertNotSame(-32001, $icalErr, 'getICalUrl must not be permission-denied for the owner: '.json_encode($icalUrl));
|
||||
Assert::assertTrue(
|
||||
array_key_exists('result', $icalUrl) || $icalErr === -32602,
|
||||
'getICalUrl should return a URL or -32602 (no feed configured): '.json_encode($icalUrl)
|
||||
);
|
||||
}
|
||||
|
||||
#[Group('bearer-api')]
|
||||
public function nonManagerBearerHonorsProjectScopedReads(AcceptanceTester $I)
|
||||
{
|
||||
// A non-manager (editor, role 20) assigned to a project. Unlike the owner, this role does
|
||||
// NOT short-circuit effectiveRoleForProject() — it exercises getProjectRole() +
|
||||
// isUserAssignedToProject() + a resolved project role over Bearer. That path only works
|
||||
// when the session role context is correctly established (the regression), so this is the
|
||||
// guard for "works for the owner but -32001s for real non-admin users."
|
||||
$ownerProjectId = (int) $I->grabFromDatabase('zp_projects', 'id', ['name' => 'My Project']);
|
||||
Assert::assertNotEmpty($ownerProjectId, 'Seed project not found after install');
|
||||
|
||||
$editorId = (int) $I->haveInDatabase('zp_user', [
|
||||
'firstname' => 'Ed',
|
||||
'lastname' => 'Itor',
|
||||
'username' => 'editor@leantime.io',
|
||||
'password' => 'x',
|
||||
'role' => '20',
|
||||
'status' => 'A',
|
||||
'createdOn' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$I->haveInDatabase('zp_relationuserproject', [
|
||||
'userId' => $editorId,
|
||||
'projectId' => $ownerProjectId,
|
||||
'projectRole' => '',
|
||||
]);
|
||||
|
||||
$this->bearerToken = bin2hex(random_bytes(20));
|
||||
$I->haveInDatabase('zp_access_tokens', [
|
||||
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
|
||||
'tokenable_id' => $editorId,
|
||||
'name' => 'bearer-api-cest-editor',
|
||||
'token' => hash('sha256', $this->bearerToken),
|
||||
'abilities' => json_encode(['*']),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
// Cross-project "my work" read (no projectId) + a project-scoped read by id, both as the
|
||||
// editor over Bearer. Must resolve (200, no -32001).
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Tickets.Tickets.getAllOpenUserTickets', new \stdClass);
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Projects.Projects.getProject', ['id' => $ownerProjectId]);
|
||||
$this->assertRpcSucceeds($I, 'leantime.rpc.Projects.Projects.getProjectProgress', ['projectId' => $ownerProjectId]);
|
||||
|
||||
// Cross-project "my work" with the projectId=0 sentinel mobile sends — must actually
|
||||
// SUCCEED, not just avoid -32001 (it previously -32001'd on 0 / -32602 when omitted, for
|
||||
// every role incl. owner). Assert no error at all + an array result, so a regression to any
|
||||
// error code (not only -32001) fails the test.
|
||||
$body = $this->rpc($I, 'leantime.rpc.Tickets.Tickets.getOpenUserTicketsThisWeekAndLater', ['userId' => $editorId, 'projectId' => 0]);
|
||||
Assert::assertArrayNotHasKey('error', $body, 'projectId=0 cross-project read must not error: '.json_encode($body));
|
||||
Assert::assertIsArray($body['result'] ?? null, 'expected an array result, got: '.json_encode($body));
|
||||
|
||||
// markTicketDone (mobile swipe-complete) must be EXPOSED (was -32601) AND succeed for the
|
||||
// assigned editor. Assert no error + result true, so a regression to -32601/-32602/false is
|
||||
// caught — assertRpcSucceeds (absence of -32001 only) would not catch those.
|
||||
$assignedTicketId = (int) $I->grabFromDatabase('zp_tickets', 'id', ['projectId' => $ownerProjectId]);
|
||||
Assert::assertNotEmpty($assignedTicketId, 'Seed ticket not found in project');
|
||||
$done = $this->rpc($I, 'leantime.rpc.Tickets.Tickets.markTicketDone', ['id' => $assignedTicketId]);
|
||||
Assert::assertArrayNotHasKey('error', $done, 'markTicketDone must be exposed + authorized: '.json_encode($done));
|
||||
Assert::assertTrue($done['result'] ?? false, 'markTicketDone should return true: '.json_encode($done));
|
||||
|
||||
// getMyCalendar is the session-scoped calendar feed (getCalendar itself trusts a userId and
|
||||
// stays non-@api). Its calendar.view gate is project-scoped, but on an API call there is no
|
||||
// session project, so it resolves capability-only against the effective role — which a
|
||||
// non-manager editor holds (calendar.view is readonly+). This is the exact path that would
|
||||
// -32001 if a project-scoped gate fell closed on a null project, so prove it resolves for a
|
||||
// non-manager, not just the owner.
|
||||
$cal = $this->rpc($I, 'leantime.rpc.Calendar.Calendar.getMyCalendar', new \stdClass);
|
||||
Assert::assertArrayNotHasKey('error', $cal, 'getMyCalendar must resolve for a non-manager editor: '.json_encode($cal));
|
||||
Assert::assertIsArray($cal['result'] ?? null, 'getMyCalendar should return an array: '.json_encode($cal));
|
||||
|
||||
// And the inbox list resolves for the editor too (session-scoped, ungated).
|
||||
$editorInbox = $this->rpc($I, 'leantime.rpc.Notifications.Notifications.getInbox', new \stdClass);
|
||||
Assert::assertArrayNotHasKey('error', $editorInbox, 'getInbox must resolve for a non-manager editor: '.json_encode($editorInbox));
|
||||
Assert::assertIsArray($editorInbox['result'] ?? null, 'getInbox should return an array: '.json_encode($editorInbox));
|
||||
|
||||
// IDOR guard: markNotificationUnread is session-scoped (matches on (id, session user)).
|
||||
// Seed a notification owned by the OWNER, read=1, then — as the editor — try to flip it
|
||||
// unread by its id. With the previous unscoped where('id') update this would succeed; now
|
||||
// it must NOT: the result is false and the row stays read.
|
||||
$ownerId = (int) $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']);
|
||||
$ownerNotifId = (int) $I->haveInDatabase('zp_notifications', [
|
||||
'userId' => $ownerId,
|
||||
'read' => 1,
|
||||
'type' => 'mention',
|
||||
'module' => 'ticket',
|
||||
'moduleId' => 1,
|
||||
'message' => 'owner-only notification',
|
||||
'datetime' => date('Y-m-d H:i:s'),
|
||||
'url' => '',
|
||||
'authorId' => $ownerId,
|
||||
]);
|
||||
// result === false proves the IDOR is closed: the session-scoped repo matched (id, editor)
|
||||
// => 0 rows => update affected nothing. (A DB read-back is avoided here because `read` is a
|
||||
// MySQL reserved word and Codeception's grabFromDatabase doesn't quote the column.)
|
||||
$unread = $this->rpc($I, 'leantime.rpc.Notifications.Notifications.markNotificationUnread', ['id' => $ownerNotifId]);
|
||||
Assert::assertArrayNotHasKey('error', $unread, 'markNotificationUnread should respond cleanly: '.json_encode($unread));
|
||||
Assert::assertFalse($unread['result'] ?? true, 'editor must NOT mark the owner\'s notification unread (IDOR): '.json_encode($unread));
|
||||
}
|
||||
|
||||
/** POST /api/jsonrpc with the test bearer + method, return the decoded body. */
|
||||
private function rpc(AcceptanceTester $I, string $method, array|\stdClass $params): array
|
||||
{
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->haveHttpHeader('Authorization', 'Bearer '.$this->bearerToken);
|
||||
|
||||
// JSON string body (not a PHP array): an array body with a stdClass param makes
|
||||
// Codeception's REST module form-encode it, so it never arrives as JSON-RPC.
|
||||
$I->sendPost('/api/jsonrpc', json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => $method,
|
||||
'params' => $params,
|
||||
'id' => 1,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeResponseIsJson();
|
||||
|
||||
return json_decode($I->grabResponse(), true) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a Bearer call is NOT denied by the permission engine (-32001). Other engine codes
|
||||
* (e.g. -32602 invalid params) are out of scope — this gates auth → user-context → project-role
|
||||
* on the Bearer path, not method correctness. A -32001 here is the regression.
|
||||
*/
|
||||
private function assertRpcSucceeds(AcceptanceTester $I, string $method, array|\stdClass $params): void
|
||||
{
|
||||
$body = $this->rpc($I, $method, $params);
|
||||
|
||||
Assert::assertNotSame(
|
||||
-32001,
|
||||
$body['error']['code'] ?? null,
|
||||
sprintf('Bearer-auth call to %s was denied by the permission engine (-32001). Response: %s', $method, json_encode($body))
|
||||
);
|
||||
}
|
||||
}
|
||||
250
tests/Acceptance/API/McpCest.php
Normal file
250
tests/Acceptance/API/McpCest.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance\API;
|
||||
|
||||
use Codeception\Attribute\Group;
|
||||
use Codeception\Scenario;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Install;
|
||||
|
||||
/**
|
||||
* MCP server contract test (laravel/mcp over the /mcp HTTP transport).
|
||||
*
|
||||
* Sibling to BearerApiCest: same Bearer-token mint (a row in zp_access_tokens), but against the
|
||||
* MCP endpoint the McpServer plugin registers instead of the JSON-RPC API. Exists because the MCP
|
||||
* tool layer is exercised by AI clients (Claude Code, MCP inspector), not by the web UI, so
|
||||
* nothing else in CI notices when it breaks — e.g. tools calling since-removed service methods, or
|
||||
* the protocol-version handshake rejecting current clients (both happened in 2026-07 and were only
|
||||
* caught by live testing).
|
||||
*
|
||||
* Group `mcp` is NOT part of the default CI groups: the McpServer plugin lives in the private
|
||||
* app/Plugins submodule, which is absent in OSS CI. Every test therefore skips itself when the
|
||||
* endpoint 404s. Run locally (plugins checked out) with:
|
||||
*
|
||||
* docker compose ... exec leantime-dev php vendor/bin/codecept run -g mcp --steps
|
||||
*
|
||||
* Each test is self-contained (token + plugin row minted per test) because the Db module rolls
|
||||
* back haveInDatabase() rows after each test.
|
||||
*/
|
||||
class McpCest
|
||||
{
|
||||
private const MCP_PATH = '/mcp';
|
||||
|
||||
public function _before(AcceptanceTester $I, Install $installPage)
|
||||
{
|
||||
// Fresh install — same fixture as ApiCest/BearerApiCest.
|
||||
$installPage->install('test@leantime.io', 'Test123456!', 'John', 'Smith', 'Smith & Co');
|
||||
}
|
||||
|
||||
#[Group('mcp')]
|
||||
public function mcpEndpointRequiresAuth(AcceptanceTester $I, Scenario $scenario)
|
||||
{
|
||||
$this->enableMcpPluginOrSkip($I, $scenario);
|
||||
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->sendPost(self::MCP_PATH, json_encode($this->initializeRequest('2025-06-18'), JSON_THROW_ON_ERROR));
|
||||
$I->seeResponseCodeIs(401);
|
||||
}
|
||||
|
||||
#[Group('mcp')]
|
||||
public function mcpHandshakeNegotiatesProtocolVersions(AcceptanceTester $I, Scenario $scenario)
|
||||
{
|
||||
$this->enableMcpPluginOrSkip($I, $scenario);
|
||||
$this->mintBearerToken($I);
|
||||
|
||||
// Baseline: a revision from laravel/mcp v0.1.1's built-in list.
|
||||
$response = $this->mcp($I, $this->initializeRequest('2025-06-18'));
|
||||
Assert::assertSame('2025-06-18', $response['result']['protocolVersion'] ?? null, 'initialize failed: '.json_encode($response));
|
||||
Assert::assertNotEmpty($response['result']['serverInfo']['name'] ?? null);
|
||||
|
||||
// Regression gate for the Claude Code handshake: current clients request 2025-11-25.
|
||||
// v0.1.1 rejects unknown revisions instead of downgrading (spec says downgrade), so the
|
||||
// server class must keep newer revisions in $supportedProtocolVersion (plugins#60).
|
||||
$response = $this->mcp($I, $this->initializeRequest('2025-11-25'));
|
||||
Assert::assertArrayNotHasKey(
|
||||
'error',
|
||||
$response,
|
||||
'Server rejected protocol 2025-11-25 — Claude Code cannot connect. '
|
||||
.'LeantimeMcpServer::$supportedProtocolVersion must include it: '.json_encode($response)
|
||||
);
|
||||
}
|
||||
|
||||
#[Group('mcp')]
|
||||
public function mcpListsAllRegisteredTools(AcceptanceTester $I, Scenario $scenario)
|
||||
{
|
||||
$this->enableMcpPluginOrSkip($I, $scenario);
|
||||
$this->mintBearerToken($I);
|
||||
|
||||
// tools/list paginates (15 per page in v0.1.1) — walk every cursor.
|
||||
$toolNames = [];
|
||||
$cursor = null;
|
||||
$guard = 0;
|
||||
do {
|
||||
$params = $cursor === null ? new \stdClass : ['cursor' => $cursor];
|
||||
$response = $this->mcp($I, [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 2,
|
||||
'method' => 'tools/list',
|
||||
'params' => $params,
|
||||
]);
|
||||
Assert::assertArrayNotHasKey('error', $response, 'tools/list failed: '.json_encode($response));
|
||||
foreach ($response['result']['tools'] ?? [] as $tool) {
|
||||
$toolNames[] = $tool['name'];
|
||||
}
|
||||
$cursor = $response['result']['nextCursor'] ?? null;
|
||||
} while ($cursor !== null && ++$guard < 20);
|
||||
|
||||
Assert::assertGreaterThanOrEqual(56, count($toolNames), 'Expected the full tool catalog, got: '.implode(', ', $toolNames));
|
||||
|
||||
// One representative per domain — catches a whole domain falling out of the registry.
|
||||
foreach (['findTasks', 'getAllProjects', 'getAllGoals', 'getCalendar', 'getComments', 'logTime'] as $expected) {
|
||||
Assert::assertContains($expected, $toolNames, "Tool {$expected} missing from tools/list");
|
||||
}
|
||||
}
|
||||
|
||||
#[Group('mcp')]
|
||||
public function mcpToolCallLifecycle(AcceptanceTester $I, Scenario $scenario)
|
||||
{
|
||||
$this->enableMcpPluginOrSkip($I, $scenario);
|
||||
$this->mintBearerToken($I);
|
||||
|
||||
// Create a task, read it back, patch it — the minimal write→read→write contract that
|
||||
// exercises DI-constructed tools, session auth context, and the Tickets service layer.
|
||||
$response = $this->callTool($I, 'addTask', [
|
||||
'headline' => 'MCP contract test task',
|
||||
'projectId' => 1,
|
||||
]);
|
||||
$text = $this->toolText($response);
|
||||
Assert::assertFalse($response['result']['isError'] ?? true, 'addTask errored: '.$text);
|
||||
Assert::assertSame(1, preg_match('/ID:?\s*(\d+)/', $text, $matches), 'addTask did not return an id: '.$text);
|
||||
$taskId = (int) $matches[1];
|
||||
|
||||
$response = $this->callTool($I, 'getTicket', ['id' => $taskId]);
|
||||
Assert::assertFalse($response['result']['isError'] ?? true, 'getTicket errored');
|
||||
Assert::assertStringContainsString('MCP contract test task', $this->toolText($response));
|
||||
|
||||
$response = $this->callTool($I, 'editTask', [
|
||||
'id' => $taskId,
|
||||
'params' => ['headline' => 'MCP contract test task (edited)'],
|
||||
]);
|
||||
Assert::assertFalse($response['result']['isError'] ?? true, 'editTask errored: '.$this->toolText($response));
|
||||
|
||||
$response = $this->callTool($I, 'getTicket', ['id' => $taskId]);
|
||||
Assert::assertStringContainsString('(edited)', $this->toolText($response), 'edit did not persist');
|
||||
}
|
||||
|
||||
#[Group('mcp')]
|
||||
public function mcpRejectsUnknownTool(AcceptanceTester $I, Scenario $scenario)
|
||||
{
|
||||
$this->enableMcpPluginOrSkip($I, $scenario);
|
||||
$this->mintBearerToken($I);
|
||||
|
||||
// laravel/mcp reports unknown tools as an isError tool result ("Tool not found"),
|
||||
// not a JSON-RPC error object.
|
||||
$response = $this->callTool($I, 'definitelyNotATool', []);
|
||||
Assert::assertTrue($response['result']['isError'] ?? false, 'Unknown tool should produce an error result: '.json_encode($response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the McpServer plugin for this test (row is rolled back by the Db module afterwards),
|
||||
* or skip when the plugin code is not present (public OSS checkout — app/Plugins is a private
|
||||
* submodule). The test runner shares the app container, so the folder check is authoritative.
|
||||
*/
|
||||
private function enableMcpPluginOrSkip(AcceptanceTester $I, Scenario $scenario): void
|
||||
{
|
||||
if (! is_dir(dirname(__DIR__, 3).'/app/Plugins/McpServer')) {
|
||||
$scenario->skip('McpServer plugin not present (app/Plugins submodule not checked out)');
|
||||
}
|
||||
|
||||
$I->haveInDatabase('zp_plugins', [
|
||||
'name' => 'leantime/mcpServer',
|
||||
'enabled' => 1,
|
||||
'description' => 'MCP Server (acceptance fixture)',
|
||||
'version' => '1.0.0',
|
||||
'installdate' => date('Y-m-d H:i:s'),
|
||||
'foldername' => 'McpServer',
|
||||
'homepage' => 'https://leantime.io',
|
||||
'authors' => '[]',
|
||||
'license' => '',
|
||||
'format' => 'folder',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a Bearer token directly in the DB — sha256 of an opaque string, exactly as
|
||||
* AccessTokenRepository::createToken persists it (same approach as BearerApiCest).
|
||||
*/
|
||||
private function mintBearerToken(AcceptanceTester $I): void
|
||||
{
|
||||
$userId = $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']);
|
||||
Assert::assertNotEmpty($userId, 'Test user not found after install');
|
||||
|
||||
$token = bin2hex(random_bytes(20));
|
||||
$I->haveInDatabase('zp_access_tokens', [
|
||||
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
|
||||
'tokenable_id' => (int) $userId,
|
||||
'name' => 'mcp-cest',
|
||||
'token' => hash('sha256', $token),
|
||||
'abilities' => json_encode(['*']),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$I->haveHttpHeader('Authorization', 'Bearer '.$token);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a JSON-RPC payload to /mcp and return the decoded response.
|
||||
*/
|
||||
private function mcp(AcceptanceTester $I, array $payload): array
|
||||
{
|
||||
$I->haveHttpHeader('Content-Type', 'application/json');
|
||||
$I->haveHttpHeader('Accept', 'application/json');
|
||||
$I->sendPost(self::MCP_PATH, json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeResponseIsJson();
|
||||
|
||||
return json_decode($I->grabResponse(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke an MCP tool via tools/call.
|
||||
*/
|
||||
private function callTool(AcceptanceTester $I, string $name, array $arguments): array
|
||||
{
|
||||
return $this->mcp($I, [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 3,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $name,
|
||||
'arguments' => $arguments === [] ? new \stdClass : $arguments,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text content of a tools/call response ('' when the shape is unexpected).
|
||||
*/
|
||||
private function toolText(array $response): string
|
||||
{
|
||||
return $response['result']['content'][0]['text'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A spec-shaped initialize request for the given protocol revision.
|
||||
*/
|
||||
private function initializeRequest(string $protocolVersion): array
|
||||
{
|
||||
return [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'initialize',
|
||||
'params' => [
|
||||
'protocolVersion' => $protocolVersion,
|
||||
'capabilities' => new \stdClass,
|
||||
'clientInfo' => ['name' => 'mcp-cest', 'version' => '1.0'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
79
tests/Acceptance/BlueprintsCest.php
Normal file
79
tests/Acceptance/BlueprintsCest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
/**
|
||||
* Acceptance tests for the consolidated Blueprints domain.
|
||||
*
|
||||
* Exercises the new /blueprints/{slug}/... routing (the dispatch bridge),
|
||||
* the YAML-driven board rendering, default-board creation, and the legacy
|
||||
* /{slug}canvas/... -> /blueprints/{slug}/... redirects.
|
||||
*/
|
||||
class BlueprintsCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I, Login $loginPage): void
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
#[Group('blueprints')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function swotBoardRendersAndCreatesDefaultBoard(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Open a SWOT blueprint board and have a default board created');
|
||||
|
||||
$I->amOnPage('/blueprints/swot/showCanvas');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
$I->dontSee('Whoops');
|
||||
$I->dontSee('Fatal error');
|
||||
|
||||
// Visiting the board auto-creates a default board for the current project.
|
||||
$I->seeInDatabase('zp_canvas', ['type' => 'swotcanvas']);
|
||||
}
|
||||
|
||||
#[Group('blueprints')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function leanBoardRenders(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Open a Lean Canvas blueprint board');
|
||||
|
||||
$I->amOnPage('/blueprints/lean/showCanvas');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
$I->dontSee('Whoops');
|
||||
|
||||
$I->seeInDatabase('zp_canvas', ['type' => 'leancanvas']);
|
||||
}
|
||||
|
||||
#[Group('blueprints')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function legacyCanvasUrlRedirectsToBlueprints(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Be redirected from a legacy /swotcanvas URL to the blueprints route');
|
||||
|
||||
$I->amOnPage('/swotcanvas/showCanvas');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
$I->seeInCurrentUrl('/blueprints/swot/');
|
||||
}
|
||||
|
||||
#[Group('blueprints')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function rendersEditableSwotBoxesWiredToBlueprintRoutes(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('See the SWOT boxes render with add-item links pointing at the blueprints route');
|
||||
|
||||
$I->amOnPage('/blueprints/swot/showCanvas');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
|
||||
// Each box (e.g. "strengths") renders an add-item affordance wired to the
|
||||
// consolidated /blueprints/{slug}/editCanvasItem route, confirming the
|
||||
// YAML-driven box rendering produced the right links.
|
||||
$I->seeElementInDOM('#swot_strengths');
|
||||
$I->seeElementInDOM('a[href*="/blueprints/swot/editCanvasItem?type=swot_strengths"]');
|
||||
$I->seeElementInDOM('a[href*="/blueprints/swot/editCanvasItem?type=swot_threats"]');
|
||||
}
|
||||
}
|
||||
58
tests/Acceptance/CreateUserCest.php
Normal file
58
tests/Acceptance/CreateUserCest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
class CreateUserCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I, Login $loginPage): void
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
#[Group('user')]
|
||||
#[Depends('Acceptance\LoginCest:loginSuccessfully')]
|
||||
public function createAUser(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Create a user');
|
||||
$I->amOnPage('/users/showAll');
|
||||
$I->clickWithRetry('.userEditModal');
|
||||
$I->waitForElement('#firstname', 120);
|
||||
$I->fillField('#firstname', 'John');
|
||||
$I->fillField('#lastname', 'Doe');
|
||||
$I->selectOption('#role', 'Read Only');
|
||||
$I->selectOption('#client', 'Not assigned to a client');
|
||||
$I->fillField('#user', 'john@doe.com');
|
||||
$I->fillField('#phone', '1234567890');
|
||||
$I->fillField('#jobTitle', 'Testing');
|
||||
$I->fillField('#jobLevel', 'Testing');
|
||||
$I->fillField('#department', 'Testing');
|
||||
$I->clickWithRetry('#save');
|
||||
$I->waitForElement('.growl', 120);
|
||||
|
||||
$I->seeInDatabase('zp_user', [
|
||||
'username' => 'john@doe.com',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Group('user')]
|
||||
#[Depends('Acceptance\LoginCest:loginSuccessfully')]
|
||||
public function editAUser(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Edit a user');
|
||||
|
||||
// Set CSRF token before making the request
|
||||
$I->setCSRFToken();
|
||||
$I->amOnPage('/users/editUser/1/');
|
||||
$I->waitForElement('.pagetitle', 120);
|
||||
$I->see('Edit User');
|
||||
$I->fillField(['name' => 'jobTitle'], 'Testing');
|
||||
$I->clickWithRetry('#save');
|
||||
$I->waitForElement('.growl', 120);
|
||||
$I->seeInSource('User edited successfully');
|
||||
}
|
||||
}
|
||||
46
tests/Acceptance/GoalsCest.php
Normal file
46
tests/Acceptance/GoalsCest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
/**
|
||||
* Acceptance tests for the Goals (Goalcanvas) domain after it was decoupled
|
||||
* from the deprecated Canvas domain (its repository now extends Blueprints and
|
||||
* its controllers no longer extend the Canvas controllers). These smoke tests
|
||||
* confirm the goal pages still render end-to-end through the decoupled stack.
|
||||
*/
|
||||
class GoalsCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I, Login $loginPage): void
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
#[Group('goals')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function goalDashboardLoads(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Open the goals dashboard');
|
||||
|
||||
$I->amOnPage('/goalcanvas/dashboard');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
$I->dontSee('Whoops');
|
||||
$I->dontSee('Fatal error');
|
||||
}
|
||||
|
||||
#[Group('goals')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function goalCanvasLoads(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Open the goals canvas board');
|
||||
|
||||
$I->amOnPage('/goalcanvas/showCanvas');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
$I->dontSee('Whoops');
|
||||
$I->dontSee('Fatal error');
|
||||
}
|
||||
}
|
||||
35
tests/Acceptance/InstallCest.php
Normal file
35
tests/Acceptance/InstallCest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Install;
|
||||
|
||||
class InstallCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I) {}
|
||||
|
||||
#[Group('install, api')]
|
||||
public function installPageWorks(AcceptanceTester $I): void
|
||||
{
|
||||
$I->amOnPage('/install');
|
||||
$I->waitForElementVisible('.registrationForm', 10);
|
||||
|
||||
$I->see('Install');
|
||||
}
|
||||
|
||||
#[Group('install, api')]
|
||||
#[Depends('installPageWorks')]
|
||||
public function createDBSuccessfully(AcceptanceTester $I, Install $installPage): void
|
||||
{
|
||||
$installPage->install(
|
||||
'test@leantime.io',
|
||||
'Test123456!',
|
||||
'John',
|
||||
'Smith',
|
||||
'Smith & Co'
|
||||
);
|
||||
}
|
||||
}
|
||||
52
tests/Acceptance/LoginCest.php
Normal file
52
tests/Acceptance/LoginCest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
class LoginCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I) {}
|
||||
|
||||
#[Group('login')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function loginPageWorks(AcceptanceTester $I): void
|
||||
{
|
||||
$I->amOnPage('/auth/login');
|
||||
$I->see('Login');
|
||||
}
|
||||
|
||||
#[Group('login')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function loginDeniedForWrongCredentials(AcceptanceTester $I): void
|
||||
{
|
||||
$I->amOnPage('/auth/login');
|
||||
$I->waitForElementVisible('#login', 10);
|
||||
$I->fillField(['name' => 'username'], 'test@leantime.io');
|
||||
$I->fillField(['name' => 'password'], 'WrongPassword');
|
||||
$I->click('Login');
|
||||
$I->waitForElementVisible('.login-alert');
|
||||
|
||||
$I->see('Username or password incorrect!');
|
||||
}
|
||||
|
||||
#[Group('login')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function loginSuccessfully(AcceptanceTester $I, Login $loginPage): void
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
#[Group('login')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function loginFormIsHidden(AcceptanceTester $I): void
|
||||
{
|
||||
$_ENV['LEAN_DISABLE_LOGIN_FORM'] = true;
|
||||
|
||||
$I->amOnPage('/auth/login');
|
||||
$I->dontSeeElementInDOM('div#login');
|
||||
}
|
||||
}
|
||||
75
tests/Acceptance/TicketsCest.php
Normal file
75
tests/Acceptance/TicketsCest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
class TicketsCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I, Login $loginPage)
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
#[Group('timesheet', 'ticket')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function createTicket(AcceptanceTester $I)
|
||||
{
|
||||
$I->wantTo('Create a ticket');
|
||||
|
||||
$I->amOnPage('/tickets/showKanban#/tickets/newTicket');
|
||||
$I->waitForElementVisible('.main-title-input', 120);
|
||||
$I->fillField(['class' => 'main-title-input'], 'Test Ticket');
|
||||
|
||||
$I->waitForElementClickable('.tagsinput', 15);
|
||||
$I->clickWithRetry('.tagsinput', 90);
|
||||
$I->wait(2);
|
||||
$I->type('test-tag,');
|
||||
$I->waitForElementClickable('[data-tiptap-editor] .ProseMirror', 120);
|
||||
$I->wait(2);
|
||||
$I->clickWithRetry('[data-tiptap-editor] .ProseMirror');
|
||||
$I->type('Test Description');
|
||||
$I->waitForElementClickable('.saveTicketBtn', 120);
|
||||
$I->clickWithRetry('.saveTicketBtn');
|
||||
$I->waitForElement('.growl', 120);
|
||||
// Match by headline, not a hardcoded auto-increment id: the id depends on how many
|
||||
// tickets earlier Cests created, so any change in suite composition (e.g. BearerApiCest
|
||||
// creating a ticket) would shift it. Headline keeps this order-independent.
|
||||
$I->seeInDatabase('zp_tickets', [
|
||||
'headline' => 'Test Ticket',
|
||||
'description like' => '%<p>Test Description</p>%',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Group('ticket')]
|
||||
#[Depends('createTicket')]
|
||||
public function editTicket(AcceptanceTester $I)
|
||||
{
|
||||
$I->wantTo('Edit a ticket');
|
||||
|
||||
// Resolve the ticket created by createTicket() by headline rather than a hardcoded id,
|
||||
// so this is independent of how many tickets other Cests created.
|
||||
$ticketId = $I->grabFromDatabase('zp_tickets', 'id', ['headline' => 'Test Ticket']);
|
||||
|
||||
$I->amOnPage('/tickets/showKanban#/tickets/showTicket/'.$ticketId);
|
||||
// Currently (and only in tests) the editor is not loaded when clicked on less the page is reloaded first.
|
||||
$I->reloadPage();
|
||||
$I->waitForElementVisible('.main-title-input', 120);
|
||||
$I->waitForElementClickable('[data-tiptap-editor] .ProseMirror', 120);
|
||||
$I->wait(2);
|
||||
$I->clickWithRetry('[data-tiptap-editor] .ProseMirror');
|
||||
$I->type('Test Description Edited');
|
||||
$I->waitForElementClickable('.saveTicketBtn', 120);
|
||||
$I->clickWithRetry('.saveTicketBtn');
|
||||
$I->waitForElement('.growl', 120);
|
||||
$I->wait(2);
|
||||
$I->seeInDatabase('zp_tickets', [
|
||||
'id' => $ticketId,
|
||||
'headline' => 'Test Ticket',
|
||||
'description like' => '%Test Description Edited%',
|
||||
]);
|
||||
}
|
||||
}
|
||||
308
tests/Acceptance/TimesheetCest.php
Normal file
308
tests/Acceptance/TimesheetCest.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
class TimesheetCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I, Login $loginPage): void
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timesheet on my page.
|
||||
*/
|
||||
#[Group('timesheet')]
|
||||
#[Depends('Acceptance\TicketsCest:createTicket')]
|
||||
public function createMyTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Add hours to tickets on my timesheet');
|
||||
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
// Select project.
|
||||
$I->waitForElementNotVisible('.project-select', 120);
|
||||
$I->clickWithRetry('#projectSelect .chosen-single');
|
||||
$I->waitForElementVisible('.chosen-drop', 120);
|
||||
$I->clickWithRetry('#projectSelect .chosen-results .active-result');
|
||||
|
||||
// Select ticket.
|
||||
$I->waitForElementNotVisible('.ticket-select', 120);
|
||||
$I->clickWithRetry('#ticketSelect .chosen-single');
|
||||
$I->waitForElementVisible('.chosen-drop', 120);
|
||||
$I->clickWithRetry('#ticketSelect .chosen-results .active-result');
|
||||
|
||||
// Select type.
|
||||
$I->waitForElementVisible('.kind-select', 120);
|
||||
$I->selectOption('.kind-select', 'General, billable');
|
||||
|
||||
// Set hours in active
|
||||
$I->fillField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', 1);
|
||||
$I->fillField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', 2);
|
||||
$I->clickWithRetry('.saveTimesheetBtn');
|
||||
$I->waitForElement('.growl', 60);
|
||||
|
||||
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
|
||||
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
|
||||
|
||||
$I->wait(3);
|
||||
|
||||
$I->seeInSource('<td id="finalSum">3</td>');
|
||||
$I->seeInDatabase('zp_timesheets', [
|
||||
'id' => 1,
|
||||
'hours' => 1,
|
||||
'kind' => 'GENERAL_BILLABLE',
|
||||
]);
|
||||
$I->seeInDatabase('zp_timesheets', [
|
||||
'id' => 2,
|
||||
'hours' => 2,
|
||||
'kind' => 'GENERAL_BILLABLE',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function checkForEmptyTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('I do not what to see empty ("zero") time registrations');
|
||||
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
|
||||
// Do not what to see empty time regs.
|
||||
$I->dontSeeInDatabase('zp_timesheets', [
|
||||
'hours' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function notShiftingTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Try registering time on the same ticket on another day. Should not remove existing registrations.');
|
||||
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
|
||||
// Since we assume "Create Timesheet was created we just add values to day 3 and 4.
|
||||
|
||||
// Set hours.
|
||||
$I->fillField('//*[contains(@class, "rowday3")]//input[@class="hourCell"]', 1);
|
||||
$I->fillField('//*[contains(@class, "rowday4")]//input[@class="hourCell"]', 2);
|
||||
$I->clickWithRetry('.saveTimesheetBtn');
|
||||
$I->waitForElement('.growl', 60);
|
||||
|
||||
$I->wait(10);
|
||||
|
||||
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
|
||||
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
|
||||
$I->seeInField('//*[contains(@class, "rowday3")]//input[@class="hourCell"]', '1');
|
||||
$I->seeInField('//*[contains(@class, "rowday4")]//input[@class="hourCell"]', '2');
|
||||
|
||||
$I->seeInSource('<td id="finalSum">6</td>');
|
||||
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function sameTicketTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Test timesheet updated with data one same ticket');
|
||||
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
|
||||
// Set hours in active
|
||||
$I->fillField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', 1);
|
||||
$I->fillField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', 2);
|
||||
$I->clickWithRetry('.saveTimesheetBtn');
|
||||
$I->waitForElement('.growl', 60);
|
||||
|
||||
$I->seeInSource('<td id="finalSum">6</td>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the timesheet once more to ensure number do not change.
|
||||
*
|
||||
* If the cell IDs are not correct, this will break the registrations.
|
||||
*/
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function saveOnceMoreTimezone(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Save the timesheet once more to ensure number do not change');
|
||||
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
$I->clickWithRetry('.saveTimesheetBtn');
|
||||
$I->waitForElement('.growl', 120);
|
||||
$I->seeInSource('Timesheet saved successfully');
|
||||
|
||||
// An page reload will trigger an "resend submission popup".
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
|
||||
$I->waitForElementVisible('//*[contains(@class, "rowday1")]//input[@class="hourCell"]');
|
||||
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
|
||||
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
|
||||
|
||||
$I->seeInSource('<td id="finalSum">6</td>');
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function changeTimezone(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Change timezone and see the correct timesheet');
|
||||
|
||||
// Change timezone and see the correct timesheet.
|
||||
$this->changeUsersTimeZone($I, 'Europe/Copenhagen');
|
||||
|
||||
// Check timesheet
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
$I->waitForElementVisible('//*[contains(@class, "rowday1")]//input[@class="hourCell"]');
|
||||
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
|
||||
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
|
||||
|
||||
$I->seeInSource('<td id="finalSum">6</td>');
|
||||
|
||||
// Switch back.
|
||||
$this->changeUsersTimeZone($I);
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function editTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Edit timesheet');
|
||||
|
||||
$I->amOnPage('/timesheets/showMyList');
|
||||
$I->waitForElementVisible('#allTimesheetsTable');
|
||||
$I->see('#1 - Edit');
|
||||
|
||||
$I->clickWithRetry('#editTimesheet-1');
|
||||
$I->waitForElementVisible('#hours');
|
||||
$I->fillField('#hours', 2);
|
||||
$I->clickWithRetry('.stdformbutton input[type=submit]');
|
||||
$I->waitForElement('.growl', 120);
|
||||
|
||||
$I->seeInDatabase('zp_timesheets', [
|
||||
'id' => '1',
|
||||
'hours' => 2,
|
||||
]);
|
||||
|
||||
// Close modal.
|
||||
$I->waitForElementVisible('.nyroModalClose');
|
||||
$I->clickWithRetry('.nyroModalClose');
|
||||
|
||||
// Check that data have been updated.
|
||||
$I->wait(5);
|
||||
$I->waitForElementVisible('#allTimesheetsTable');
|
||||
$I->see('2', '//*//tr[@class="odd"]//td', '2');
|
||||
$I->see('2', '//*//tr[@class="odd"]//td', '-2');
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet', 'editTimesheet')]
|
||||
public function logTimeOnTicketTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Open ticket and add time');
|
||||
|
||||
$I->amOnPage('/#/tickets/showTicket/10');
|
||||
$I->waitForElementVisible('#ui-id-8');
|
||||
$I->clickWithRetry('#ui-id-8');
|
||||
$I->waitForElementVisible('#hours');
|
||||
$I->fillField('#hours', 4);
|
||||
|
||||
$I->clickWithRetry('.formModal input[type=submit][name=saveTimes]');
|
||||
$I->wait(1);
|
||||
|
||||
// Go and see if the total is correct.
|
||||
$I->amOnPage('/timesheets/showMy');
|
||||
$I->waitForElementVisible('#finalSum');
|
||||
$I->seeInSource('<td id="finalSum">11</td>');
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet', 'editTimesheet', 'logTimeOnTicketTimesheet')]
|
||||
public function showAllTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Show all timesheet list');
|
||||
$I->amOnPage('/timesheets/showAll');
|
||||
|
||||
$I->waitForElementVisible('#allTimesheetsTable');
|
||||
$I->see('2', '//*//tr[@class="odd"]//td', '2');
|
||||
$I->see('2', '//*//tr[@class="odd"]//td', '-2');
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function showAllEditsTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Show all timesheet list');
|
||||
$I->amOnPage('/timesheets/showAll');
|
||||
$I->waitForElementVisible('#allTimesheetsTable');
|
||||
|
||||
// Maker paid
|
||||
$I->checkOption('//*//input[@id="checkAllPaid"]');
|
||||
$I->clickWithRetry('#allTimesheetsTable_wrapper input[name=saveInvoice]');
|
||||
$I->waitForElementVisible('#allTimesheetsTable_wrapper');
|
||||
$I->wait(2);
|
||||
$I->cantSeeElement('//*//input[@class="paid"]');
|
||||
|
||||
// Make Invoiced
|
||||
$I->checkOption('//*/input[@id="checkAllEmpl"]');
|
||||
$I->clickWithRetry('#allTimesheetsTable_wrapper input[name=saveInvoice]');
|
||||
$I->waitForElementVisible('#allTimesheetsTable_wrapper');
|
||||
$I->wait(2);
|
||||
$I->cantSeeElement('//*//input[@class="invoicedEmpl"]');
|
||||
|
||||
// Make MGR Approval
|
||||
$I->checkOption('//*//input[@id="checkAllComp"]');
|
||||
$I->clickWithRetry('#allTimesheetsTable_wrapper input[name=saveInvoice]');
|
||||
$I->waitForElementVisible('#allTimesheetsTable_wrapper');
|
||||
$I->wait(2);
|
||||
$I->cantSeeElement('//*//input[@class="invoicedComp"]');
|
||||
}
|
||||
|
||||
#[Group('timesheet')]
|
||||
#[Depends('createMyTimesheet')]
|
||||
public function deleteTimesheet(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Delete timesheet');
|
||||
|
||||
$I->amOnPage('/timesheets/showMyList');
|
||||
$I->waitForElementVisible('#allTimesheetsTable');
|
||||
$I->see('#1 - Edit');
|
||||
|
||||
$I->clickWithRetry('#editTimesheet-1');
|
||||
$I->waitForElementVisible('.delete');
|
||||
$I->clickWithRetry('.stdformbutton .delete');
|
||||
|
||||
$I->wait(5);
|
||||
$I->see('Should the timesheet really be deleted?');
|
||||
|
||||
$I->clickWithRetry('.nyroModalLink input[type=submit]');
|
||||
|
||||
$I->waitForElementVisible('#allTimesheetsTable');
|
||||
$I->wait(5);
|
||||
$I->cantSee('#1 - Edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the timezone for the logged-in user.
|
||||
*
|
||||
* @param AcceptanceTester $I The AcceptanceTester object representing the test runner.
|
||||
* @param string $timezone The timezone to be set. Defaults to 'America/Los_Angeles'.
|
||||
*/
|
||||
private function changeUsersTimeZone(AcceptanceTester $I, string $timezone = 'America/Los_Angeles'): void
|
||||
{
|
||||
$I->amOnPage('/users/editOwn#settings');
|
||||
$I->waitForElementVisible('#timezone');
|
||||
$I->selectOption('#timezone', $timezone);
|
||||
$I->waitForElementClickable('#saveSettings');
|
||||
$I->clickWithRetry('#saveSettings', 90);
|
||||
$I->waitForText($timezone, 20);
|
||||
$I->wait(5);
|
||||
|
||||
}
|
||||
}
|
||||
34
tests/Acceptance/WikiCest.php
Normal file
34
tests/Acceptance/WikiCest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Acceptance;
|
||||
|
||||
use Codeception\Attribute\Depends;
|
||||
use Codeception\Attribute\Group;
|
||||
use Tests\Support\AcceptanceTester;
|
||||
use Tests\Support\Page\Acceptance\Login;
|
||||
|
||||
/**
|
||||
* Acceptance tests for the Wiki domain after it was decoupled from the
|
||||
* deprecated Canvas domain (its repository now extends Blueprints). These
|
||||
* smoke tests confirm the wiki still renders and persists through the
|
||||
* decoupled repository.
|
||||
*/
|
||||
class WikiCest
|
||||
{
|
||||
public function _before(AcceptanceTester $I, Login $loginPage): void
|
||||
{
|
||||
$loginPage->login('test@leantime.io', 'Test123456!');
|
||||
}
|
||||
|
||||
#[Group('wiki')]
|
||||
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
|
||||
public function wikiLoads(AcceptanceTester $I): void
|
||||
{
|
||||
$I->wantTo('Open the wiki');
|
||||
|
||||
$I->amOnPage('/wiki/show');
|
||||
$I->waitForElementVisible('.pageheader', 30);
|
||||
$I->dontSee('Whoops');
|
||||
$I->dontSee('Fatal error');
|
||||
}
|
||||
}
|
||||
218
tests/Acceptance/bootstrap.php
Normal file
218
tests/Acceptance/bootstrap.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
// Don't run the script unless using the 'run' command
|
||||
if (! isset($_SERVER['argv'][1]) || $_SERVER['argv'][1] !== 'run') {
|
||||
return;
|
||||
}
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
// Load test environment
|
||||
$testEnv = __DIR__.'/../../.dev/test.env';
|
||||
if (file_exists($testEnv)) {
|
||||
\Dotenv\Dotenv::createImmutable(dirname($testEnv), basename($testEnv))->load();
|
||||
}
|
||||
|
||||
define('PROJECT_ROOT', realpath(__DIR__.'/../../').'/');
|
||||
define('DEV_ROOT', PROJECT_ROOT.'.dev/');
|
||||
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(\Leantime\Core\Console\ConsoleKernel::class)->bootstrap();
|
||||
|
||||
$bootstrapper = get_class(new class
|
||||
{
|
||||
/**
|
||||
* @var self
|
||||
*/
|
||||
protected static $instance;
|
||||
|
||||
/**
|
||||
* Get the singleton instance of this class
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (! isset(self::$instance)) {
|
||||
self::$instance = new self;
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the testing environment
|
||||
*/
|
||||
public function start(): void
|
||||
{
|
||||
$this->setFolderPermissions();
|
||||
$this->createDatabase();
|
||||
$this->createStep('Starting Codeception Testing Framework');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the testing environment
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->createStep('Stopping Codeception Testing Framework');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the test database
|
||||
*/
|
||||
protected function createDatabase(): void
|
||||
{
|
||||
$host = getenv('LEAN_DB_HOST');
|
||||
$user = getenv('LEAN_DB_USER');
|
||||
$pass = getenv('LEAN_DB_PASSWORD');
|
||||
$db = getenv('LEAN_DB_DATABASE');
|
||||
|
||||
$this->createStep('Dropping Test Database');
|
||||
$result = $this->executeCommand([
|
||||
'mysql',
|
||||
"--host=$host",
|
||||
"--user=$user",
|
||||
"--password=$pass",
|
||||
'--skip-ssl',
|
||||
'-e',
|
||||
"DROP DATABASE IF EXISTS $db;",
|
||||
], ['cwd' => DEV_ROOT]);
|
||||
|
||||
$this->createStep('Creating Test Database');
|
||||
$this->executeCommand([
|
||||
'mysql',
|
||||
"--host=$host",
|
||||
"--user=$user",
|
||||
"--password=$pass",
|
||||
'--skip-ssl',
|
||||
'-e',
|
||||
'CREATE DATABASE IF NOT EXISTS leantime_test;',
|
||||
], ['cwd' => DEV_ROOT]);
|
||||
|
||||
$this->createStep('Clearing Application Cache');
|
||||
$this->executeCommand(
|
||||
'find '.PROJECT_ROOT.'storage/framework/cache/ -type f ! -name ".gitignore" -delete && '.
|
||||
'find '.PROJECT_ROOT.'storage/framework/sessions/ -type f ! -name ".gitignore" -delete && '.
|
||||
'find '.PROJECT_ROOT.'storage/framework/views/ -type f ! -name ".gitignore" -delete',
|
||||
['cwd' => PROJECT_ROOT],
|
||||
false,
|
||||
);
|
||||
|
||||
// Restart Apache to clear OPcache and any in-memory state
|
||||
$this->executeCommand('apache2ctl graceful', ['cwd' => PROJECT_ROOT], false);
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
protected function setFolderPermissions(): void
|
||||
{
|
||||
$this->createStep('Setting folder permissions on cache folder');
|
||||
|
||||
// Set file permissions
|
||||
$this->executeCommand(
|
||||
array_filter(
|
||||
[
|
||||
'chown',
|
||||
'-R',
|
||||
'www-data:www-data',
|
||||
'/var/www/html/storage/',
|
||||
]
|
||||
),
|
||||
[
|
||||
'cwd' => DEV_ROOT,
|
||||
]
|
||||
);
|
||||
|
||||
$this->executeCommand(
|
||||
array_filter(
|
||||
[
|
||||
'chown',
|
||||
'-R',
|
||||
'www-data:www-data',
|
||||
'/var/www/html/storage/logs',
|
||||
]
|
||||
),
|
||||
[
|
||||
'cwd' => DEV_ROOT,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a step in the output
|
||||
*/
|
||||
protected function createStep(string $message): void
|
||||
{
|
||||
$chars = strlen($message);
|
||||
$line = str_repeat('=', $chars);
|
||||
|
||||
echo "\n$line\n$message\n$line\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command
|
||||
*/
|
||||
protected function executeCommand(
|
||||
string|array $command,
|
||||
array $args = [],
|
||||
bool $required = true,
|
||||
): Process|string {
|
||||
$process = is_array($command)
|
||||
? new Process($command)
|
||||
: Process::fromShellCommandline($command);
|
||||
|
||||
if (isset($args['cwd'])) {
|
||||
$process->setWorkingDirectory($args['cwd']);
|
||||
}
|
||||
|
||||
if (isset($args['timeout'])) {
|
||||
$process->setTimeout($args['timeout']);
|
||||
}
|
||||
|
||||
if (isset($args['options'])) {
|
||||
$process->setOptions($args['options']);
|
||||
}
|
||||
|
||||
if (isset($args['background']) && $args['background']) {
|
||||
$process->start();
|
||||
} else {
|
||||
$process->run(fn ($type, $buffer) => $this->commandOutputHandler($type, $buffer));
|
||||
}
|
||||
|
||||
if (
|
||||
$required
|
||||
&& (! isset($args['background']) || ! $args['background'])
|
||||
&& ! $process->isSuccessful()
|
||||
) {
|
||||
throw new ProcessFailedException($process);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($args['getOutput'])
|
||||
&& $args['getOutput']
|
||||
) {
|
||||
if (isset($args['background']) && $args['background']) {
|
||||
throw new RuntimeException('Cannot get output from background process');
|
||||
}
|
||||
|
||||
return $process->getOutput();
|
||||
}
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle command output
|
||||
*/
|
||||
private function commandOutputHandler(string $type, string $buffer): void
|
||||
{
|
||||
echo $type === Process::ERR ? "\nSTDERR: $buffer" : "\nSTDOUT: $buffer";
|
||||
}
|
||||
});
|
||||
|
||||
register_shutdown_function(fn () => $bootstrapper::getInstance()->destroy());
|
||||
|
||||
$bootstrapper::getInstance()->start();
|
||||
Reference in New Issue
Block a user