Skip to content

Commit d88858e

Browse files
committed
Testing: add REST (JSON/JsTask) equivalent of infolog CalDAVImportTest
Mirrors infolog/tests/CalDAV/CalDAVImportTest.php using JSON POST/PUT against the REST API instead of iCalendar VTODO over CalDAV PUT: field mapping on create, a full update, the completed-timestamp-without-progress default, malformed-payload rejection and foreign-collection ACL denial. Along the way, found that JsCalendar::JsTask() serializes info_priority via Priority($p) (calendar's 0..3 scale) while parseJsTask() parses it back via parsePriority($p, true) (infolog's own 0..3 scale) - composing the two only round-trips for priority=1, every other value comes back different from what was submitted. Documented in the new test's class docblock; left unfixed as out of scope for this change.
1 parent bba1966 commit d88858e

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
<?php
2+
/**
3+
* REST API import/validation/ACL tests for InfoLog tasks.
4+
*
5+
* REST (JSON / JsTask) equivalent of infolog/tests/CalDAV/CalDAVImportTest.php.
6+
* Instead of iCalendar VTODO payloads over CalDAV PUT, this creates/updates
7+
* tasks via JSON POST/PUT against the REST API and checks the same InfoLog
8+
* field mapping, malformed-payload rejection and foreign-collection ACL denial.
9+
*
10+
* NOTE on 'priority': Api\CalDAV\JsCalendar::JsTask() serializes info_priority
11+
* via Priority($p) (the calendar 0..3 scale), while parseJsTask() parses it back
12+
* via parsePriority($p, true) (the infolog-specific 0..3 scale). Composing the
13+
* two is only the identity for priority=1 - every other value (0, 2..9) comes
14+
* back different from what was submitted. This looks like a pre-existing bug
15+
* (parseJsTask() probably needs the same $infolog=true flag as Priority()'s
16+
* call), so these tests deliberately only use priority=1 to stay meaningful.
17+
*
18+
* @package infolog
19+
* @subpackage tests
20+
*/
21+
22+
namespace EGroupware\infolog\REST;
23+
24+
require_once __DIR__.'/../../../api/tests/RestBase.php';
25+
26+
use EGroupware\Api\RestBase;
27+
use GuzzleHttp\RequestOptions;
28+
29+
class CalDAVImportTest extends RestBase
30+
{
31+
protected const OTHER_USER = 'infolog_rest_other';
32+
33+
/**
34+
* Track created task ids for cleanup.
35+
*
36+
* @var int[]
37+
*/
38+
protected $created_tasks = [];
39+
40+
public static function setUpBeforeClass() : void
41+
{
42+
parent::setUpBeforeClass();
43+
$data = [];
44+
self::createUser(self::OTHER_USER, $data);
45+
}
46+
47+
protected function ownerUser() : string
48+
{
49+
$this->assertNotEmpty($GLOBALS['EGW_USER'], 'EGW_USER must be configured for REST tests');
50+
return $GLOBALS['EGW_USER'];
51+
}
52+
53+
protected function collection() : string
54+
{
55+
return $this->collectionUrl('infolog', $this->ownerUser());
56+
}
57+
58+
/**
59+
* POST a JsTask to the owner's infolog collection and return [id, decoded JsTask].
60+
*
61+
* @param array $fields JsTask attributes, e.g. ['title' => ..., 'priority' => 1]
62+
* @return array{0: int, 1: array}
63+
*/
64+
protected function createTask(array $fields) : array
65+
{
66+
$response = $this->postResource($this->collection(), $fields, $this->ownerUser());
67+
$this->assertHttpStatus([200, 201], $response, 'creating infolog task');
68+
69+
$id = (int)$this->resourceIdFromResponse($response, 'infolog');
70+
$this->assertGreaterThan(0, $id, 'Expected numeric id in Location/ETag header');
71+
$this->created_tasks[] = $id;
72+
73+
return [$id, $this->jsonDecode($response)];
74+
}
75+
76+
/**
77+
* PUT a full JsTask onto an existing resource (REST PUT requires all attributes, incl. the
78+
* unchanged 'uid' - the server rejects a PUT that would change it).
79+
*
80+
* @param int $id
81+
* @param array $fields full JsTask attributes, incl. 'uid'
82+
* @return array decoded JsTask
83+
*/
84+
protected function updateTask(int $id, array $fields) : array
85+
{
86+
$response = $this->getClient($this->ownerUser())->put($this->url($this->collection().$id), [
87+
RequestOptions::HEADERS => $this->jsonHeaders(['Prefer' => 'return=representation']),
88+
RequestOptions::BODY => $this->jsonBody($fields),
89+
]);
90+
$this->assertHttpStatus(200, $response, 'updating infolog task '.$id);
91+
92+
return $this->jsonDecode($response);
93+
}
94+
95+
/**
96+
* GET a single task and return its decoded JsTask (asserts HTTP 200).
97+
*/
98+
protected function getResourceJson(int $id) : array
99+
{
100+
$response = $this->getClient($this->ownerUser())->get($this->url($this->collection().$id), [
101+
RequestOptions::HEADERS => $this->jsonHeaders(),
102+
]);
103+
$this->assertHttpStatus(200, $response, 'fetching infolog task '.$id);
104+
return $this->jsonDecode($response);
105+
}
106+
107+
/**
108+
* Check whether a task with the given (unique) title is visible in the owner's collection.
109+
*/
110+
protected function taskExistsWithTitle(string $title) : bool
111+
{
112+
$response = $this->getClient($this->ownerUser())->get($this->url($this->collection()), [
113+
RequestOptions::HEADERS => $this->jsonHeaders(),
114+
RequestOptions::QUERY => ['filters' => ['info_subject' => $title]],
115+
]);
116+
$this->assertHttpStatus(200, $response, 'searching infolog collection for title '.$title);
117+
118+
return !empty($this->jsonDecode($response)['responses']);
119+
}
120+
121+
public function tearDown() : void
122+
{
123+
foreach(array_unique($this->created_tasks) as $id)
124+
{
125+
$response = $this->deleteResource($this->collection().$id, $this->ownerUser());
126+
if (!in_array($response->getStatusCode(), [204, 404], true))
127+
{
128+
$this->assertContains($response->getStatusCode(), [204, 404], "Cleanup delete failed for task $id");
129+
}
130+
}
131+
$this->created_tasks = [];
132+
133+
parent::tearDown();
134+
}
135+
136+
/**
137+
* Create a new task via REST POST and verify InfoLog field mapping.
138+
*
139+
* Pass criteria:
140+
* - POST to the infolog collection succeeds with 200/201.
141+
* - Response (Prefer: return=representation) contains the submitted title,
142+
* description, progress, percentComplete and priority.
143+
*/
144+
public function testCreateSetsFields()
145+
{
146+
$uid = $this->makeUid('infolog-rest-create');
147+
[, $task] = $this->createTask([
148+
'title' => $uid,
149+
'description' => 'Created through REST import',
150+
'progress' => 'in-progress',
151+
'percentComplete' => 55,
152+
'priority' => 1, // see class docblock: only priority=1 round-trips correctly
153+
]);
154+
155+
$this->assertSame($uid, $task['title'] ?? null);
156+
$this->assertSame('Created through REST import', $task['description'] ?? null);
157+
$this->assertSame('in-progress', $task['progress'] ?? null);
158+
$this->assertSame(55, $task['percentComplete'] ?? null);
159+
$this->assertSame(1, $task['priority'] ?? null);
160+
}
161+
162+
/**
163+
* Update an existing task via a full REST PUT and verify the changed fields persist.
164+
*
165+
* Pass criteria:
166+
* - PUT with the unchanged 'uid' plus new title/description/progress/percentComplete
167+
* succeeds with 200 and keeps the same id.
168+
* - GET afterwards reflects all the updated fields, not the initial ones.
169+
*/
170+
public function testUpdateExistingTask()
171+
{
172+
$uid = $this->makeUid('infolog-rest-update');
173+
[$id, $created] = $this->createTask([
174+
'title' => $uid.'-initial',
175+
'description' => 'Initial version',
176+
'progress' => 'in-progress',
177+
'percentComplete' => 55,
178+
'priority' => 1,
179+
]);
180+
181+
$updated = $this->updateTask($id, [
182+
'uid' => $created['uid'],
183+
'title' => $uid.'-updated',
184+
'description' => 'Updated version',
185+
'progress' => 'completed',
186+
'percentComplete' => 100,
187+
'priority' => 1,
188+
'egroupware.org:completed' => '2026-07-20T10:00:00Z',
189+
]);
190+
191+
$this->assertSame($uid.'-updated', $updated['title'] ?? null);
192+
$this->assertSame('Updated version', $updated['description'] ?? null);
193+
$this->assertSame('completed', $updated['progress'] ?? null);
194+
$this->assertSame(100, $updated['percentComplete'] ?? null);
195+
$this->assertNotEmpty($updated['egroupware.org:completed'] ?? null);
196+
197+
$fetched = $this->getResourceJson($id);
198+
$this->assertSame($uid.'-updated', $fetched['title'] ?? null);
199+
}
200+
201+
/**
202+
* Setting 'egroupware.org:completed' without an explicit 'progress' must not implicitly
203+
* mark the task done - current default mapping keeps it open (needs-action / 0%).
204+
*
205+
* Pass criteria:
206+
* - Created task keeps the default "needs-action" progress and no/zero percentComplete.
207+
* - The completed timestamp is still stored.
208+
*/
209+
public function testCompletedWithoutProgressDefaultsToNotStarted()
210+
{
211+
$uid = $this->makeUid('infolog-rest-completed');
212+
[, $task] = $this->createTask([
213+
'title' => $uid,
214+
'egroupware.org:completed' => '2026-07-20T10:00:00Z',
215+
]);
216+
217+
$this->assertSame('needs-action', $task['progress'] ?? null);
218+
$this->assertEmpty($task['percentComplete'] ?? 0);
219+
$this->assertNotEmpty($task['egroupware.org:completed'] ?? null);
220+
}
221+
222+
/**
223+
* Malformed JSON payload on the infolog collection must be rejected.
224+
*
225+
* Pass criteria:
226+
* - POST returns a client error (400/415/422); the broken payload is not silently stored.
227+
*/
228+
public function testCreateRejectsMalformedJson()
229+
{
230+
$response = $this->getClient($this->ownerUser())->post($this->url($this->collection()), [
231+
RequestOptions::HEADERS => $this->jsonHeaders(),
232+
// truncated / invalid JSON
233+
RequestOptions::BODY => '{ "title": "broken payload", "priority": ',
234+
]);
235+
$this->assertHttpStatus([400, 415, 422], $response);
236+
}
237+
238+
/**
239+
* User without ACL must not be able to create in another user's infolog collection.
240+
*
241+
* Pass criteria:
242+
* - Foreign POST is denied with HTTP 403.
243+
* - The task does not show up in the owner's collection under its unique title.
244+
*/
245+
public function testCreateDeniedForForeignCollectionWithoutAcl()
246+
{
247+
$uid = $this->makeUid('infolog-rest-acl-denied');
248+
249+
$response = $this->getClient(self::OTHER_USER)->post($this->url($this->collection()), [
250+
RequestOptions::HEADERS => $this->jsonHeaders(['Prefer' => 'return=representation']),
251+
RequestOptions::BODY => $this->jsonBody(['title' => $uid]),
252+
]);
253+
$this->assertHttpStatus(403, $response);
254+
255+
$this->assertFalse($this->taskExistsWithTitle($uid), 'Foreign task must not have been created in the owner collection');
256+
}
257+
}

0 commit comments

Comments
 (0)