mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Answer for calendars over JMAP
Calendar/get, CalendarEvent/get, CalendarEvent/query and CalendarEvent/set, under urn:plmail:params:jmap:calendars. The draft URN is deliberately not claimed; the PUSH capability set that precedent. A JMAP event id is a calendar_event row — the series, not a dated instance. This is the decision the feature turns on and it is not the obvious one, because /query reads occurrences: the tsrange overlap against the GiST index hands back occurrence ids, and both are autoincrement ints from different tables, so an untranslated one is not a decode failure but a valid-looking id for an unrelated event. That is exactly the defect EmailMapperTest documents, so the translation happens in one place and the id space is the same in get, query, set and notFound. It is also what a JSCalendar Event is, and occurrence rows are rewritten wholesale by the materialiser on every write, so a stored occurrence id would go stale the moment someone corrected a title. CalendarEvent/query requires a date window. Occurrences exist only inside the materialiser's horizon, so an unbounded query would answer from a partial index and look complete — a truncation the client cannot detect. Exactly one account serves calendars, the one the session already names as primary; every other answers accountNotSupportedByMethod. A plMail calendar is user-scoped with no per-account binding, so publishing the list under every connected account would put one calendar under three accountIds with the same id, and a client keying by (accountId, id) would draw it three times. State is the fixed string "fixed", with canCalculateChanges false and no /changes. Mail's token works because its change log is complete; an event changes from four places and only one of them is in src/Jmap. A log recording a quarter of the writes would sit still while a pull replaced the whole day — worse than none, because a client comparing states would conclude nothing had changed. Non-numeric on purpose: if calendars later join the log, a client still holding this value fails ctype_digit and is told to resync. A synced event written through /set is marked for push, which the writer itself deliberately does not do — the writer is also how the sync engine applies what it has just read, so marking belongs to whatever made the change, and a JMAP client is a person making one exactly as the web editor is. Without it an edit made on a phone never reaches Google and is silently reverted by the next pull. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc2d091c5b
commit
f689571fd4
20 files changed
+2930
-6
No files matched your search
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Account;
|
||||
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\User\User;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
|
||||
/**
|
||||
* Which JMAP account a user's calendars are served from.
|
||||
*
|
||||
* A JMAP accountId is a connected mail Account (see AccountResolver), but a
|
||||
* plMail Calendar is the *user's* — user-scoped like Label and MailRule, with
|
||||
* the mail account only ever an optional owner for the one calendar extraction
|
||||
* files into. So the same list of calendars is reachable from every account a
|
||||
* user has connected, and there is no per-account identity for one the way
|
||||
* LabelBinding gives a label one.
|
||||
*
|
||||
* Serving that list from every account would therefore publish one calendar
|
||||
* under three accountIds. A client keys every object by (accountId, id), so it
|
||||
* would draw the calendar three times, and an event created on it would appear
|
||||
* to exist three times over — with no way for the client to tell that the three
|
||||
* are one. **Exactly one account serves calendars**, then: the one the Session
|
||||
* already names in primaryAccounts, which is the user's first. Any other
|
||||
* account is refused with "accountNotSupportedByMethod", RFC 8620's error for
|
||||
* precisely this — an accountId that exists but does not hold this object type.
|
||||
*
|
||||
* A user with no mail account has nowhere to serve calendars from and the
|
||||
* Session advertises none. That is a real state (a user can delete their last
|
||||
* account and keep a calendar) and it degrades to "this install has no calendar
|
||||
* account", not to an error at some other account's expense.
|
||||
*
|
||||
* Reads User::$accounts and Account::$id and nothing else, the same two
|
||||
* SessionBuilder and AccountResolver are coupled to; the "first account" rule
|
||||
* is spelled here so those two and every method cannot answer it differently.
|
||||
*/
|
||||
final class CalendarAccountResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountResolver $accountResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The account calendars belong to, or null when the user has none at all.
|
||||
*/
|
||||
public function accountFor(User $user): ?Account
|
||||
{
|
||||
$account = $user->accounts->first();
|
||||
|
||||
if (false === $account instanceof Account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a calendar method was called with, having proved it is the
|
||||
* one that serves calendars.
|
||||
*
|
||||
* Goes through AccountResolver first so an unknown or foreign accountId is
|
||||
* still "accountNotFound" — telling a stranger that an id they do not own
|
||||
* is merely unsupported here would confirm the id exists.
|
||||
*/
|
||||
public function resolve(User $user, mixed $accountId): Account
|
||||
{
|
||||
$account = $this->accountResolver->resolve($user, $accountId);
|
||||
$calendarAccount = $this->accountFor($user);
|
||||
|
||||
if (null === $calendarAccount || $calendarAccount->id !== $account->id) {
|
||||
throw new MethodException(
|
||||
'accountNotSupportedByMethod',
|
||||
sprintf(
|
||||
'Calendars are served from account "%s" only; they are the user\'s rather than one account\'s.',
|
||||
null === $calendarAccount ? '' : (string) $calendarAccount->id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Calendar;
|
||||
|
||||
/**
|
||||
* The state string every calendar method returns, and why it is a constant.
|
||||
*
|
||||
* Mail's state is a real token: `StateManager` writes one `jmap_change_log` row
|
||||
* per mutation and the autoincrement PK *is* the token, so `Email/get` can hand
|
||||
* a client a number that `Email/changes` will honour. That works because every
|
||||
* path that changes a JMAP-visible mail property calls `StateManager::record*`
|
||||
* — through `MailChangeRecorder`, which exists exactly so five callers cannot
|
||||
* each forget the same two things.
|
||||
*
|
||||
* Calendars have no such recorder, and could not be given one from inside this
|
||||
* directory. An event changes from four places: the sync engine pulling a
|
||||
* remote calendar, extraction reading a message, the web editor, and
|
||||
* `CalendarEvent/set` here. Only the last is in `src/Jmap/`. **A log that
|
||||
* recorded a quarter of the writes would be worse than none**: the token would
|
||||
* sit still while a pull replaced the whole day, and a client comparing states
|
||||
* would conclude nothing had changed and never refetch. Mail's log is
|
||||
* trustworthy because it is complete; a partial one is not a weaker version of
|
||||
* it, it is a lie with a number on it.
|
||||
*
|
||||
* So the state is fixed and the methods say so in the only other way the
|
||||
* protocol offers: `canCalculateChanges` is false, there is no
|
||||
* `Calendar/changes` or `CalendarEvent/changes`, and a client re-runs its query
|
||||
* — which is what `Email/query` already asks for and is spec-legal.
|
||||
*
|
||||
* The value is deliberately not a number. Should calendars later join the
|
||||
* change log — a `CalendarChangeRecorder` beside `MailChangeRecorder`, called by
|
||||
* all four writers, and `JmapObjectType` cases to match — tokens become
|
||||
* sequences, and a client still holding this one fails `ctype_digit` in
|
||||
* `StateManager::changesSince()` and is told to resync. That is the correct
|
||||
* degradation, and it is free.
|
||||
*/
|
||||
final class CalendarState
|
||||
{
|
||||
public const string FIXED = 'fixed';
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Calendar;
|
||||
|
||||
use App\Domain\Enum\Calendar\EventStatus;
|
||||
use App\Entity\Calendar\Calendar;
|
||||
use App\Entity\Calendar\CalendarEvent;
|
||||
use App\Entity\User\User;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Repository\Calendar\CalendarRepository;
|
||||
use App\Service\Calendar\CalendarEventWriter;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Turns a JMAP CalendarEvent/set object into a written event.
|
||||
*
|
||||
* What an event IS — the canonical JSCalendar object, the columns projected
|
||||
* from it, the occurrence rows a view reads — belongs to CalendarEventWriter,
|
||||
* shared with the web editor and the sync engine. Nothing here touches a
|
||||
* column: that is the whole reason the writer exists, and a JMAP client that
|
||||
* set `$title` without `jscalendar['title']` would produce an event that looked
|
||||
* right in the app and exported blank.
|
||||
*
|
||||
* What is left here is the protocol: reading a JSCalendar object off the wire,
|
||||
* refusing what cannot be stored faithfully, and mapping a JMAP patch onto the
|
||||
* writer's parameter list.
|
||||
*
|
||||
* **Unknown properties are refused, not dropped.** The vocabulary below is what
|
||||
* the writer can honour; anything else is `invalidProperties` naming it. A
|
||||
* client whose `participants` were silently discarded would believe it had
|
||||
* invited somebody, and there is no way for it to discover otherwise — the same
|
||||
* argument EmailFilterCompiler makes for refusing an unknown filter condition.
|
||||
*
|
||||
* The properties left out, and why:
|
||||
*
|
||||
* participants An RSVP is answered through the invite flow (InviteResponder),
|
||||
* which sends an iTIP reply. Accepting an attendee list here
|
||||
* would record answers nobody was told about.
|
||||
* privacy There is no writer parameter for it, and writing the column
|
||||
* directly is exactly what this class must not do.
|
||||
* alerts, links Nowhere to project them from, and the writer rebuilds the
|
||||
* canonical object from the columns on every write — so they
|
||||
* would survive one save and vanish on the next.
|
||||
*/
|
||||
final class JmapEventWriter
|
||||
{
|
||||
/**
|
||||
* JSCalendar's LocalDateTime (RFC 8984 §4.1.2): no offset, no trailing Z.
|
||||
*
|
||||
* Parsed strictly. "2026-06-02T09:00:00Z" looks like a courtesy to accept
|
||||
* and is not: it says UTC, the zone beside it says Europe/Berlin, and
|
||||
* guessing which one the client meant moves a meeting by hours in silence.
|
||||
*/
|
||||
private const string LOCAL_DATE_TIME = 'Y-m-d\TH:i:s';
|
||||
|
||||
/** What an event lasts when the client did not say. */
|
||||
private const string DEFAULT_DURATION = 'PT1H';
|
||||
|
||||
/** The same, for one marked showWithoutTime. */
|
||||
private const string DEFAULT_ALL_DAY_DURATION = 'P1D';
|
||||
|
||||
/**
|
||||
* Everything a create or an update may name. "@type" is accepted and
|
||||
* ignored: it is JSCalendar's discriminator and always "Event" here.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array WRITABLE = [
|
||||
'@type',
|
||||
'calendarId',
|
||||
'uid',
|
||||
'title',
|
||||
'description',
|
||||
'start',
|
||||
'duration',
|
||||
'timeZone',
|
||||
'showWithoutTime',
|
||||
'locations',
|
||||
'status',
|
||||
'recurrenceRules',
|
||||
'recurrenceOverrides',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CalendarRepository $calendars,
|
||||
private readonly CalendarEventWriter $writer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $create the JMAP CalendarEvent being created
|
||||
*/
|
||||
public function create(User $user, array $create): CalendarEvent
|
||||
{
|
||||
$this->assertVocabulary($create);
|
||||
|
||||
$calendar = $this->calendar($user, $create['calendarId'] ?? null);
|
||||
$event = new CalendarEvent();
|
||||
|
||||
$uid = $create['uid'] ?? null;
|
||||
|
||||
if (null !== $uid) {
|
||||
// Kept verbatim when the client supplies one: RFC 5546 already
|
||||
// decided what an event's identity is, and a server that reissued
|
||||
// it would break every reply and update sent about the meeting.
|
||||
$event->uid = $this->string($uid, 'uid');
|
||||
}
|
||||
|
||||
$this->apply($event, $calendar, $user, $create, $create);
|
||||
|
||||
// Before the id can be read back. The set method flushes again at the
|
||||
// end of the call; this one is what mints the id it reports.
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites an existing event from a JMAP patch.
|
||||
*
|
||||
* Only the properties the patch names; everything else is re-supplied from
|
||||
* what is stored. The writer derives the canonical object from its
|
||||
* arguments, so an absent `description` passed as null would delete a
|
||||
* description the client never mentioned.
|
||||
*
|
||||
* @param array<string,mixed> $patch
|
||||
*/
|
||||
public function update(User $user, CalendarEvent $event, array $patch): void
|
||||
{
|
||||
$this->assertVocabulary($patch);
|
||||
|
||||
if (true === array_key_exists('uid', $patch)) {
|
||||
throw new MethodException('invalidProperties', 'The uid is the event\'s identity and cannot be changed.', ['properties' => ['uid']]);
|
||||
}
|
||||
|
||||
$calendar = true === array_key_exists('calendarId', $patch)
|
||||
? $this->calendar($user, $patch['calendarId'])
|
||||
: $this->assertWritable($event->calendar);
|
||||
|
||||
$this->apply($event, $calendar, $user, $patch, $this->stored($event));
|
||||
}
|
||||
|
||||
/**
|
||||
* A patch key naming a path — "locations/1/name" — rather than a property.
|
||||
*
|
||||
* RFC 8620 §5.3 allows a PatchObject to address inside an object, and this
|
||||
* server does not implement it: the writer takes whole values, so a partial
|
||||
* one would have to be merged here against a stored object the writer is
|
||||
* about to rebuild, and the two merges would disagree the first time a
|
||||
* projection changed. Refused by name so a client can send the whole
|
||||
* property instead, which it always can.
|
||||
*
|
||||
* @param array<string,mixed> $properties
|
||||
*/
|
||||
private function assertVocabulary(array $properties): void
|
||||
{
|
||||
$unknown = [];
|
||||
|
||||
foreach (array_keys($properties) as $property) {
|
||||
$property = (string) $property;
|
||||
|
||||
if (true === str_contains($property, '/')) {
|
||||
throw new MethodException('invalidPatch', sprintf('Patch paths are not supported; send the whole "%s" property.', explode('/', $property)[0]));
|
||||
}
|
||||
|
||||
if (false === in_array($property, self::WRITABLE, true)) {
|
||||
$unknown[] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] !== $unknown) {
|
||||
throw new MethodException(
|
||||
'invalidProperties',
|
||||
sprintf('Not settable on a CalendarEvent: %s.', implode(', ', $unknown)),
|
||||
['properties' => $unknown],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One write, from a patch layered over a set of defaults.
|
||||
*
|
||||
* $defaults is the create object itself on a create — where an absent
|
||||
* property means its default — and the stored event's own values on an
|
||||
* update, where an absent property means "leave it alone". Expressing both
|
||||
* through one call is what keeps a create and an update from disagreeing
|
||||
* about what, say, an absent duration means.
|
||||
*
|
||||
* @param array<string,mixed> $patch
|
||||
* @param array<string,mixed> $defaults
|
||||
*/
|
||||
private function apply(CalendarEvent $event, Calendar $calendar, User $user, array $patch, array $defaults): void
|
||||
{
|
||||
$values = array_merge($defaults, $patch);
|
||||
|
||||
$isAllDay = $this->bool($values['showWithoutTime'] ?? false, 'showWithoutTime');
|
||||
$zoneName = $this->zoneName($values, $calendar);
|
||||
// A floating event has no zone to be local to, so its start is read as
|
||||
// UTC — which is what the entity means by "local midnight with a null
|
||||
// timeZone" and what the writer stores for an all-day event.
|
||||
$zone = $this->zone($zoneName ?? 'UTC');
|
||||
|
||||
$startsAt = $this->start($values['start'] ?? null, $zone);
|
||||
$endsAt = $startsAt->add($this->duration(
|
||||
$values['duration'] ?? (true === $isAllDay ? self::DEFAULT_ALL_DAY_DURATION : self::DEFAULT_DURATION),
|
||||
));
|
||||
|
||||
$this->writer->write(
|
||||
event: $event,
|
||||
calendar: $calendar,
|
||||
user: $user,
|
||||
title: $this->string($values['title'] ?? '', 'title'),
|
||||
startsAt: $startsAt,
|
||||
endsAt: $endsAt,
|
||||
timeZone: $zoneName,
|
||||
isAllDay: $isAllDay,
|
||||
location: $this->location($values['locations'] ?? null),
|
||||
description: $this->stringOrNull($values['description'] ?? null, 'description'),
|
||||
status: $this->status($values['status'] ?? null),
|
||||
recurrenceRule: $this->recurrenceRule($values['recurrenceRules'] ?? null),
|
||||
);
|
||||
|
||||
// After write(), which rebuilds the canonical object and carries the
|
||||
// stored overrides across. A JMAP property is replaced whole, so the
|
||||
// patch is the entire truth about which instances differ — which is
|
||||
// exactly what $replaceExisting means to the writer, and the only way
|
||||
// an instance moved back stops being drawn where it used to be.
|
||||
if (true === array_key_exists('recurrenceOverrides', $patch)) {
|
||||
$this->writer->overrideInstances($event, $this->overrides($patch['recurrenceOverrides']), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The values a patch is layered over on an update: the event as it stands,
|
||||
* read back in the shape the wire uses.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function stored(CalendarEvent $event): array
|
||||
{
|
||||
$zoneName = $event->timeZone;
|
||||
$startsAt = $event->startsAt;
|
||||
$endsAt = $event->endsAt;
|
||||
|
||||
if (null === $startsAt || null === $endsAt) {
|
||||
throw new MethodException('serverFail', 'This event has no start; it cannot be patched.');
|
||||
}
|
||||
|
||||
$local = $startsAt->setTimezone($this->zone($zoneName ?? 'UTC'));
|
||||
|
||||
return [
|
||||
'title' => (string) $event->title,
|
||||
'start' => $local->format(self::LOCAL_DATE_TIME),
|
||||
'duration' => $this->writer->isoDuration($endsAt->getTimestamp() - $startsAt->getTimestamp()),
|
||||
'timeZone' => $zoneName,
|
||||
'showWithoutTime' => $event->isAllDay,
|
||||
'description' => $event->jscalendar['description'] ?? null,
|
||||
'locations' => $event->jscalendar['locations'] ?? null,
|
||||
'status' => $event->status->value,
|
||||
'recurrenceRules' => $event->jscalendar['recurrenceRules'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar a write lands on, owned by this user and accepting writes.
|
||||
*
|
||||
* A calendar belonging to somebody else is reported as notFound rather than
|
||||
* forbidden: the two are distinguishable, and only one of them tells a
|
||||
* stranger that the id exists.
|
||||
*/
|
||||
private function calendar(User $user, mixed $calendarId): Calendar
|
||||
{
|
||||
if (false === is_string($calendarId) && false === is_int($calendarId)) {
|
||||
throw new MethodException('invalidProperties', 'A "calendarId" is required.', ['properties' => ['calendarId']]);
|
||||
}
|
||||
|
||||
$id = (string) $calendarId;
|
||||
|
||||
if (false === ctype_digit($id)) {
|
||||
throw new MethodException('invalidProperties', 'A "calendarId" is required.', ['properties' => ['calendarId']]);
|
||||
}
|
||||
|
||||
$calendar = $this->calendars->findOneForUser($user, (int) $id);
|
||||
|
||||
if (null === $calendar) {
|
||||
throw new MethodException('notFound', sprintf('No calendar "%s".', $id));
|
||||
}
|
||||
|
||||
return $this->assertWritable($calendar);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mirror of somewhere that does not accept writes back refuses every
|
||||
* write, here rather than at the remote.
|
||||
*
|
||||
* The alternative is to accept the edit, mark it pending, and watch the
|
||||
* pusher discard it — which CalendarPusher does — leaving a client that was
|
||||
* told "updated" showing an event the next pull silently reverts. Refusing
|
||||
* is the only answer the client can act on.
|
||||
*/
|
||||
private function assertWritable(?Calendar $calendar): Calendar
|
||||
{
|
||||
if (null === $calendar) {
|
||||
throw new MethodException('notFound', 'This event has no calendar.');
|
||||
}
|
||||
|
||||
if (true === $calendar->isReadOnly) {
|
||||
throw new MethodException('forbidden', sprintf('Calendar "%s" is read-only; it mirrors somewhere that does not accept writes.', (string) $calendar->id));
|
||||
}
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone the start is local to, or null for a floating event.
|
||||
*
|
||||
* An absent timeZone and an explicit null are different answers and are
|
||||
* told apart here: absent means "you decide", and the calendar's own zone
|
||||
* is the only sensible decision; null is JSCalendar's floating time
|
||||
* (RFC 8984 §4.1.2), which an all-day event is. Reading `?? null` would
|
||||
* collapse the two and quietly zone every floating event a client sent.
|
||||
*
|
||||
* @param array<string,mixed> $values
|
||||
*/
|
||||
private function zoneName(array $values, Calendar $calendar): ?string
|
||||
{
|
||||
if (false === array_key_exists('timeZone', $values)) {
|
||||
return $calendar->timeZone;
|
||||
}
|
||||
|
||||
if (null === $values['timeZone']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->string($values['timeZone'], 'timeZone');
|
||||
}
|
||||
|
||||
private function zone(string $name): DateTimeZone
|
||||
{
|
||||
try {
|
||||
return new DateTimeZone($name);
|
||||
} catch (\Exception) {
|
||||
throw new MethodException('invalidProperties', sprintf('"%s" is not an IANA time zone.', $name), ['properties' => ['timeZone']]);
|
||||
}
|
||||
}
|
||||
|
||||
private function start(mixed $start, DateTimeZone $zone): DateTimeImmutable
|
||||
{
|
||||
$value = $this->string($start ?? '', 'start');
|
||||
$parsed = DateTimeImmutable::createFromFormat(self::LOCAL_DATE_TIME, $value, $zone);
|
||||
|
||||
// createFromFormat is forgiving about trailing text and about a date
|
||||
// that does not exist; comparing the reformatted result back is what
|
||||
// makes "2026-02-30T09:00:00" a refusal rather than the 2nd of March.
|
||||
if (false === $parsed instanceof DateTimeImmutable || $parsed->format(self::LOCAL_DATE_TIME) !== $value) {
|
||||
throw new MethodException(
|
||||
'invalidProperties',
|
||||
sprintf('"start" must be a JSCalendar LocalDateTime (%s), with no offset and no trailing Z.', self::LOCAL_DATE_TIME),
|
||||
['properties' => ['start']],
|
||||
);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function duration(mixed $duration): DateInterval
|
||||
{
|
||||
$value = $this->string($duration, 'duration');
|
||||
|
||||
try {
|
||||
return new DateInterval($value);
|
||||
} catch (\Exception) {
|
||||
throw new MethodException('invalidProperties', sprintf('"%s" is not an ISO 8601 duration.', $value), ['properties' => ['duration']]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one location name the columns can hold.
|
||||
*
|
||||
* CalendarEvent projects a single `location` and the writer rebuilds
|
||||
* `locations` from it, so a second entry would survive this write and
|
||||
* vanish on the next one. Refused rather than truncated, for the reason
|
||||
* every refusal here exists: a client cannot detect what it lost.
|
||||
*/
|
||||
private function location(mixed $locations): ?string
|
||||
{
|
||||
if (null === $locations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (false === is_array($locations)) {
|
||||
throw new MethodException('invalidProperties', '"locations" must be a map of Location objects.', ['properties' => ['locations']]);
|
||||
}
|
||||
|
||||
if (0 === count($locations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (1 < count($locations)) {
|
||||
throw new MethodException('invalidProperties', 'Only one location is stored; send the one that matters.', ['properties' => ['locations']]);
|
||||
}
|
||||
|
||||
$location = reset($locations);
|
||||
|
||||
if (false === is_array($location)) {
|
||||
throw new MethodException('invalidProperties', '"locations" must be a map of Location objects.', ['properties' => ['locations']]);
|
||||
}
|
||||
|
||||
foreach (array_keys($location) as $property) {
|
||||
if (false === in_array((string) $property, ['@type', 'name'], true)) {
|
||||
throw new MethodException('invalidProperties', sprintf('Only a location\'s name is stored; "%s" would not survive the next save.', (string) $property), ['properties' => ['locations']]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->stringOrNull($location['name'] ?? null, 'locations');
|
||||
}
|
||||
|
||||
private function status(mixed $status): EventStatus
|
||||
{
|
||||
if (null === $status) {
|
||||
return EventStatus::Confirmed;
|
||||
}
|
||||
|
||||
$resolved = EventStatus::tryFrom($this->string($status, 'status'));
|
||||
|
||||
if (null === $resolved) {
|
||||
throw new MethodException(
|
||||
'invalidProperties',
|
||||
sprintf('"status" must be one of: %s.', implode(', ', array_column(EventStatus::cases(), 'value'))),
|
||||
['properties' => ['status']],
|
||||
);
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one recurrence rule the writer stores.
|
||||
*
|
||||
* A rule this server cannot convert to an RRULE is kept verbatim and
|
||||
* expands to a single occurrence — RecurrenceMaterialiser's contract, and
|
||||
* deliberately not re-decided here. Validating it a second time would mean
|
||||
* two grammars that agree until the day one of them is fixed.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function recurrenceRule(mixed $rules): ?array
|
||||
{
|
||||
if (null === $rules) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (false === is_array($rules)) {
|
||||
throw new MethodException('invalidProperties', '"recurrenceRules" must be an array.', ['properties' => ['recurrenceRules']]);
|
||||
}
|
||||
|
||||
if (0 === count($rules)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (1 < count($rules)) {
|
||||
throw new MethodException('invalidProperties', 'Only one recurrence rule is stored.', ['properties' => ['recurrenceRules']]);
|
||||
}
|
||||
|
||||
$rule = reset($rules);
|
||||
|
||||
if (false === is_array($rule)) {
|
||||
throw new MethodException('invalidProperties', '"recurrenceRules" must hold RecurrenceRule objects.', ['properties' => ['recurrenceRules']]);
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function overrides(mixed $overrides): array
|
||||
{
|
||||
if (false === is_array($overrides)) {
|
||||
throw new MethodException('invalidProperties', '"recurrenceOverrides" must be a map keyed by LocalDateTime.', ['properties' => ['recurrenceOverrides']]);
|
||||
}
|
||||
|
||||
$patches = [];
|
||||
|
||||
foreach ($overrides as $key => $patch) {
|
||||
$key = (string) $key;
|
||||
|
||||
if (false === is_array($patch)) {
|
||||
throw new MethodException('invalidProperties', sprintf('The override at "%s" must be an object.', $key), ['properties' => ['recurrenceOverrides']]);
|
||||
}
|
||||
|
||||
$patches[$key] = $patch;
|
||||
}
|
||||
|
||||
return $patches;
|
||||
}
|
||||
|
||||
private function bool(mixed $value, string $property): bool
|
||||
{
|
||||
if (false === is_bool($value)) {
|
||||
throw new MethodException('invalidProperties', sprintf('"%s" must be a boolean.', $property), ['properties' => [$property]]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function string(mixed $value, string $property): string
|
||||
{
|
||||
if (false === is_string($value) || '' === $value) {
|
||||
throw new MethodException('invalidProperties', sprintf('"%s" must be a non-empty string.', $property), ['properties' => [$property]]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value, string $property): ?string
|
||||
{
|
||||
if (null === $value || '' === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (false === is_string($value)) {
|
||||
throw new MethodException('invalidProperties', sprintf('"%s" must be a string or null.', $property), ['properties' => [$property]]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Mapper;
|
||||
|
||||
use App\Entity\Calendar\CalendarEvent;
|
||||
|
||||
/**
|
||||
* Maps a plMail CalendarEvent onto the JMAP CalendarEvent object.
|
||||
*
|
||||
* **A JMAP CalendarEvent id is a `calendar_event` row id — the series, not one
|
||||
* of its dated instances.** That is the id-space decision this whole feature
|
||||
* turns on, and it is not the obvious one: the query that finds events runs over
|
||||
* `calendar_event_occurrence`, so the ids the database hands back are occurrence
|
||||
* ids and something has to translate them. Both are autoincrement ints from
|
||||
* different tables, so an untranslated occurrence id does not fail — it is a
|
||||
* valid-looking id for an unrelated event, which a client fetches and renders.
|
||||
* That is exactly how Email.mailboxIds shipped label ids where binding ids were
|
||||
* meant (see EmailMapperTest), and the answer is the same: one id space
|
||||
* everywhere — `list[].id`, `/query`'s ids, `/get`'s ids, `/set`'s ids — and a
|
||||
* test that puts every emitted id back into a filter and checks it selects what
|
||||
* it came from.
|
||||
*
|
||||
* The series is the right unit because it is what a JSCalendar Event *is*
|
||||
* (RFC 8984): one object carrying `recurrenceRules` and `recurrenceOverrides`,
|
||||
* from which a client expands instances itself. An id per occurrence would name
|
||||
* rows that this application creates and destroys on every write — the
|
||||
* materialiser rewrites them wholesale — so a client's stored id would go stale
|
||||
* the moment somebody corrected a title.
|
||||
*
|
||||
* The object published IS the stored canonical JSCalendar object, plus the
|
||||
* envelope JMAP adds (`id`, `calendarId`, `created`, `updated`). Nothing is
|
||||
* re-derived from the projected columns here: CalendarEventWriter is the one
|
||||
* place that turns columns into JSCalendar, and a second derivation in the
|
||||
* mapper would be a second answer to what the event is — the exact drift the
|
||||
* writer exists to prevent. An event whose `jscalendar` is empty is therefore
|
||||
* published nearly empty, which is honest: no writer made that row.
|
||||
*/
|
||||
final class CalendarEventMapper
|
||||
{
|
||||
/**
|
||||
* @param list<string>|null $properties
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toJmap(CalendarEvent $event, ?array $properties = null): array
|
||||
{
|
||||
$full = $this->full($event);
|
||||
|
||||
if (null === $properties) {
|
||||
return $full;
|
||||
}
|
||||
|
||||
// "id" is always returned regardless of the requested property set.
|
||||
$filtered = ['id' => $full['id']];
|
||||
|
||||
foreach ($properties as $property) {
|
||||
if (true === array_key_exists($property, $full)) {
|
||||
$filtered[$property] = $full[$property];
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function full(CalendarEvent $event): array
|
||||
{
|
||||
$jscalendar = $event->jscalendar;
|
||||
|
||||
// The envelope overwrites, and must: `uid` is in the canonical object
|
||||
// too, and the column is what uniqueness is enforced on, so the two
|
||||
// disagreeing is a row the writer has to be told about rather than a
|
||||
// difference to publish.
|
||||
$jscalendar['@type'] = 'Event';
|
||||
$jscalendar['uid'] = $event->uid;
|
||||
$jscalendar['id'] = (string) $event->id;
|
||||
$jscalendar['calendarId'] = null === $event->calendar ? null : (string) $event->calendar->id;
|
||||
$jscalendar['sequence'] = $event->sequence;
|
||||
$jscalendar['created'] = $this->utcOrNull($event->createdAt);
|
||||
$jscalendar['updated'] = $this->utcOrNull($event->updatedAt);
|
||||
// Derived, and published because a client cannot see it from the rule
|
||||
// alone: a rule this server could not convert is stored verbatim and
|
||||
// expands to a single occurrence, so `recurrenceRules` being present is
|
||||
// not the same claim as "this recurs here".
|
||||
$jscalendar['isRecurring'] = $event->isRecurring;
|
||||
// plMail extension: what this event was extracted from, or null when a
|
||||
// person made it. It is what "Happening Soon" filters on, and a client
|
||||
// that could not tell a booking confirmation from a typed appointment
|
||||
// would have to guess from the title.
|
||||
$jscalendar['kind'] = $event->kind?->value;
|
||||
$jscalendar['source'] = $event->source->value;
|
||||
|
||||
return $jscalendar;
|
||||
}
|
||||
|
||||
private function utcOrNull(?\DateTimeImmutable $date): ?string
|
||||
{
|
||||
if (null === $date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Mapper;
|
||||
|
||||
use App\Entity\Calendar\Calendar;
|
||||
|
||||
/**
|
||||
* Maps a plMail Calendar onto the JMAP Calendar object this server publishes.
|
||||
*
|
||||
* The id is the `calendar` row id, and that is the whole of it — unlike a
|
||||
* Mailbox, whose id had to be a per-account binding because labels are
|
||||
* user-scoped and a JMAP account is one mail account. A Calendar is user-scoped
|
||||
* too, but it is served from exactly one account (see CalendarAccountResolver),
|
||||
* so there is one id space and nothing to translate between. **The same id is
|
||||
* what `CalendarEvent/query`'s `inCalendar` consumes**, which is the property
|
||||
* that matters: an id a client reads off a Calendar must select that calendar's
|
||||
* events when it hands it back, and here it does so without passing through a
|
||||
* second table.
|
||||
*
|
||||
* Writability is published as `myRights` rather than as an `isReadOnly` flag,
|
||||
* following RFC 8621's Mailbox. Two spellings of "may I write here?" is how one
|
||||
* of them ends up not being consulted, and the rights map is the one every JMAP
|
||||
* client already knows to look at.
|
||||
*
|
||||
* `isVisible` is published rather than acted on. It is the sidebar tick, a
|
||||
* display preference of the web UI, and a JMAP client filtering events by it
|
||||
* would hide from a phone what its user had chosen to hide in a browser — two
|
||||
* surfaces disagreeing about which events exist. So it is stated and the client
|
||||
* decides.
|
||||
*/
|
||||
final class CalendarMapper
|
||||
{
|
||||
/**
|
||||
* @param list<string>|null $properties
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toJmap(Calendar $calendar, ?array $properties = null): array
|
||||
{
|
||||
$full = $this->full($calendar);
|
||||
|
||||
if (null === $properties) {
|
||||
return $full;
|
||||
}
|
||||
|
||||
// "id" is always returned regardless of the requested property set.
|
||||
$filtered = ['id' => $full['id']];
|
||||
|
||||
foreach ($properties as $property) {
|
||||
if (true === array_key_exists($property, $full)) {
|
||||
$filtered[$property] = $full[$property];
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function full(Calendar $calendar): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $calendar->id,
|
||||
'name' => $calendar->name,
|
||||
// #rrggbb, not the Tailwind token Mailbox.color carries. The two
|
||||
// are different vocabularies in the database — Label::$color is a
|
||||
// token, Calendar::$color is hex — and translating one into the
|
||||
// other here would invent a colour the user never picked.
|
||||
'color' => $calendar->color,
|
||||
'sortOrder' => $calendar->sortOrder,
|
||||
'isVisible' => $calendar->isVisible,
|
||||
'isDefault' => $calendar->isDefault,
|
||||
'timeZone' => $calendar->timeZone,
|
||||
// plMail extension: what the calendar is FOR, which is what decides
|
||||
// where an extracted event lands. A client cannot derive it and a
|
||||
// user cannot change it, but "the calendar this account's mail files
|
||||
// into" is worth being able to label.
|
||||
'role' => $calendar->role->value,
|
||||
// plMail extension: whether there is a remote behind this at all.
|
||||
// Distinct from the rights below — a calendar can mirror a remote
|
||||
// and still accept writes — and it is what tells a client that an
|
||||
// event it creates here will leave the machine.
|
||||
'isSynced' => $calendar->isSynced(),
|
||||
'myRights' => $this->rights($calendar),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private function rights(Calendar $calendar): array
|
||||
{
|
||||
$writable = false === $calendar->isReadOnly;
|
||||
|
||||
return [
|
||||
'mayReadItems' => true,
|
||||
// Every write goes through the same gate: a mirror of somewhere
|
||||
// that does not accept writes back refuses all three, so a client
|
||||
// can grey the button out rather than discover it by SetError.
|
||||
'mayAddItems' => $writable,
|
||||
'mayUpdateAll' => $writable,
|
||||
'mayRemoveItems' => $writable,
|
||||
// The calendar itself, not its events. The two provisioned roles
|
||||
// are re-created rather than mourned, so deleting one is refused
|
||||
// (CalendarRole::isDeletable) — and Calendar/set is not implemented
|
||||
// at all, so this is advisory for now.
|
||||
'mayDelete' => $writable && true === $calendar->role->isDeletable(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method\Calendar;
|
||||
|
||||
use App\Jmap\Account\CalendarAccountResolver;
|
||||
use App\Jmap\Calendar\CalendarState;
|
||||
use App\Jmap\Mapper\CalendarEventMapper;
|
||||
use App\Jmap\Method\JmapMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
use App\Repository\Calendar\CalendarEventRepository;
|
||||
|
||||
/**
|
||||
* "CalendarEvent/get".
|
||||
*
|
||||
* ids is REQUIRED, like Email/get and for the same reason: a user's events are
|
||||
* unbounded, so they are reached through CalendarEvent/query and a null ids is
|
||||
* requestTooLarge rather than "return everything".
|
||||
*
|
||||
* An id names a series (see CalendarEventMapper), so what comes back is one
|
||||
* JSCalendar Event object per id — rule and overrides included — rather than one
|
||||
* object per dated instance.
|
||||
*
|
||||
* Each id is resolved through CalendarEventRepository::findOneForUser(), which
|
||||
* scopes on the owner. That makes somebody else's event indistinguishable from
|
||||
* one that does not exist, which is the answer that leaks nothing; it also means
|
||||
* one query per id, and the cap below is lower than mail's 500 because of it.
|
||||
* The batch method this wants — findByIdsForUser() — does not exist in
|
||||
* CalendarEventRepository, and that repository is not this directory's to
|
||||
* extend.
|
||||
*/
|
||||
final class CalendarEventGetMethod implements JmapMethod
|
||||
{
|
||||
/**
|
||||
* Ceiling on ids per call, matching maxEventsInGet in the Session's
|
||||
* calendars capability.
|
||||
*/
|
||||
private const int MAX_OBJECTS = 100;
|
||||
|
||||
public function __construct(
|
||||
private readonly CalendarAccountResolver $accountResolver,
|
||||
private readonly CalendarEventRepository $events,
|
||||
private readonly CalendarEventMapper $mapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'CalendarEvent/get';
|
||||
}
|
||||
|
||||
public function handle(array $arguments, JmapContext $context): array
|
||||
{
|
||||
$account = $this->accountResolver->resolve($context->user, $arguments['accountId'] ?? null);
|
||||
|
||||
$requestedIds = $arguments['ids'] ?? null;
|
||||
|
||||
if (null === $requestedIds) {
|
||||
throw new MethodException('requestTooLarge', '"ids" is required for CalendarEvent/get; use CalendarEvent/query to select them.');
|
||||
}
|
||||
|
||||
if (false === is_array($requestedIds)) {
|
||||
throw new MethodException('invalidArguments', '"ids" must be an array.');
|
||||
}
|
||||
|
||||
if (count($requestedIds) > self::MAX_OBJECTS) {
|
||||
throw new MethodException('requestTooLarge', sprintf('At most %d ids per CalendarEvent/get.', self::MAX_OBJECTS));
|
||||
}
|
||||
|
||||
$properties = $arguments['properties'] ?? null;
|
||||
|
||||
if (null !== $properties && false === is_array($properties)) {
|
||||
throw new MethodException('invalidArguments', '"properties" must be an array or null.');
|
||||
}
|
||||
|
||||
$list = [];
|
||||
$notFound = [];
|
||||
|
||||
foreach ($requestedIds as $id) {
|
||||
// "#creationId" refers to an event created earlier in this same
|
||||
// request; an unknown one stays as-is and falls out as notFound.
|
||||
$id = $context->resolveId((string) $id) ?? (string) $id;
|
||||
|
||||
$event = false === ctype_digit($id)
|
||||
? null
|
||||
: $this->events->findOneForUser($context->user, (int) $id);
|
||||
|
||||
if (null === $event) {
|
||||
$notFound[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = $this->mapper->toJmap($event, $properties);
|
||||
}
|
||||
|
||||
return [
|
||||
'accountId' => (string) $account->id,
|
||||
'state' => CalendarState::FIXED,
|
||||
'list' => $list,
|
||||
'notFound' => $notFound,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method\Calendar;
|
||||
|
||||
use App\Jmap\Account\CalendarAccountResolver;
|
||||
use App\Jmap\Calendar\CalendarState;
|
||||
use App\Jmap\Method\JmapMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
use App\Jmap\Query\CalendarEventQueryRunner;
|
||||
|
||||
/**
|
||||
* "CalendarEvent/query": the ids of the events with an occurrence inside a
|
||||
* window, in the order those occurrences start, windowed by position/limit.
|
||||
* Clients pair it with a CalendarEvent/get on "#ids" in the same request,
|
||||
* exactly as they do for mail.
|
||||
*
|
||||
* The filter is `inCalendar`, `after` and `before`, and the last two are not
|
||||
* optional — see CalendarEventQueryRunner, which owns the read and the reason.
|
||||
*
|
||||
* Sorting is fixed. There is one useful order for a calendar and it is the one
|
||||
* the index already returns; anything else would be a sort in PHP over a window
|
||||
* the client has not seen the whole of, which is how a "sorted" list ends up
|
||||
* sorted only within a page. A `sort` argument is refused with unsupportedSort
|
||||
* rather than ignored.
|
||||
*/
|
||||
final class CalendarEventQueryMethod implements JmapMethod
|
||||
{
|
||||
private const int MAX_LIMIT = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly CalendarAccountResolver $accountResolver,
|
||||
private readonly CalendarEventQueryRunner $runner,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'CalendarEvent/query';
|
||||
}
|
||||
|
||||
public function handle(array $arguments, JmapContext $context): array
|
||||
{
|
||||
$account = $this->accountResolver->resolve($context->user, $arguments['accountId'] ?? null);
|
||||
|
||||
$filter = $arguments['filter'] ?? null;
|
||||
|
||||
if (null !== $filter && false === is_array($filter)) {
|
||||
throw new MethodException('invalidArguments', '"filter" must be an object or null.');
|
||||
}
|
||||
|
||||
if (null !== ($arguments['sort'] ?? null)) {
|
||||
throw new MethodException('unsupportedSort', 'CalendarEvent/query always sorts by the first occurrence in the window.');
|
||||
}
|
||||
|
||||
if (null !== ($arguments['anchor'] ?? null)) {
|
||||
throw new MethodException('unsupportedFilter', 'Anchor-based paging is not supported; use "position".');
|
||||
}
|
||||
|
||||
$position = $arguments['position'] ?? 0;
|
||||
|
||||
if (false === is_int($position) || $position < 0) {
|
||||
throw new MethodException('invalidArguments', '"position" must be a non-negative integer.');
|
||||
}
|
||||
|
||||
$limit = $arguments['limit'] ?? null;
|
||||
|
||||
if (null !== $limit && (false === is_int($limit) || $limit < 0)) {
|
||||
throw new MethodException('invalidArguments', '"limit" must be a non-negative integer or null.');
|
||||
}
|
||||
|
||||
if (null === $limit || $limit > self::MAX_LIMIT) {
|
||||
$limit = self::MAX_LIMIT;
|
||||
}
|
||||
|
||||
$result = $this->runner->run($context->user, $filter, $position, $limit);
|
||||
|
||||
return [
|
||||
'accountId' => (string) $account->id,
|
||||
'queryState' => CalendarState::FIXED,
|
||||
// No CalendarEvent/queryChanges, and no /changes for it to diff
|
||||
// against — CalendarState explains why there is no state to hold a
|
||||
// delta from. Clients re-run the query, which is spec-legal and is
|
||||
// what Email/query already asks for.
|
||||
'canCalculateChanges' => false,
|
||||
'position' => $result->position,
|
||||
'ids' => $result->ids,
|
||||
'total' => $result->total,
|
||||
'limit' => $limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method\Calendar;
|
||||
|
||||
use App\Entity\Calendar\Calendar;
|
||||
use App\Entity\Calendar\CalendarEvent;
|
||||
use App\Infrastructure\Messaging\Message\SyncCalendarMessage;
|
||||
use App\Jmap\Account\CalendarAccountResolver;
|
||||
use App\Jmap\Calendar\CalendarState;
|
||||
use App\Jmap\Calendar\JmapEventWriter;
|
||||
use App\Jmap\Method\JmapMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
use App\Repository\Calendar\CalendarEventRepository;
|
||||
use App\Service\Calendar\CalendarEventWriter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* "CalendarEvent/set": create, update and destroy events.
|
||||
*
|
||||
* Every write goes through CalendarEventWriter, the one place an event is
|
||||
* written — reading a JMAP object off the wire is JmapEventWriter's, and what
|
||||
* an event IS stays where the web editor and the sync engine already find it.
|
||||
* Nothing here assigns a column.
|
||||
*
|
||||
* **A synced event written here is marked for push, and that is a decision.**
|
||||
* CalendarEventWriter::markLocallyChanged() is deliberately not called by
|
||||
* write() itself, because write() is also how the sync engine applies what it
|
||||
* has just *read* from a remote — marking there would push the remote's own
|
||||
* data straight back at it. So the marking belongs to whatever made the change,
|
||||
* and a JMAP /set is a person making a change, exactly as the web editor is. Not
|
||||
* marking would mean an event edited on a phone never reaches Google or
|
||||
* Microsoft and is silently reverted by the next pull: the edit would be visible
|
||||
* in plMail for fifteen minutes and then gone, which is the precise failure
|
||||
* SyncState exists to prevent. The same seam as CalendarController::eventSave(),
|
||||
* because a second push path is how the two would drift.
|
||||
*
|
||||
* A sync is asked for immediately afterwards for the same reason that
|
||||
* controller asks: fifteen minutes is the right cadence for noticing somebody
|
||||
* else's change and the wrong one for watching your own arrive. At most one
|
||||
* message per calendar, dispatched after the flush so the worker reads a
|
||||
* committed row.
|
||||
*
|
||||
* `ifInState` is refused rather than honoured. It asks the server to promise
|
||||
* that nothing has changed since a token was issued, and the calendar state is
|
||||
* fixed (see CalendarState) — so honouring it would always answer "nothing has
|
||||
* changed", which is not something this server knows. A guard that cannot fail
|
||||
* is worse than no guard, because a client would rely on it.
|
||||
*/
|
||||
final class CalendarEventSetMethod implements JmapMethod
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CalendarAccountResolver $accountResolver,
|
||||
private readonly CalendarEventRepository $events,
|
||||
private readonly JmapEventWriter $jmapWriter,
|
||||
private readonly CalendarEventWriter $writer,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'CalendarEvent/set';
|
||||
}
|
||||
|
||||
public function handle(array $arguments, JmapContext $context): array
|
||||
{
|
||||
$account = $this->accountResolver->resolve($context->user, $arguments['accountId'] ?? null);
|
||||
|
||||
if (true === array_key_exists('ifInState', $arguments) && null !== $arguments['ifInState']) {
|
||||
throw new MethodException('invalidArguments', 'ifInState cannot be honoured: the calendar state is fixed, so it could only ever answer "nothing has changed".');
|
||||
}
|
||||
|
||||
$created = [];
|
||||
$notCreated = [];
|
||||
$updated = [];
|
||||
$notUpdated = [];
|
||||
$destroyed = [];
|
||||
$notDestroyed = [];
|
||||
|
||||
/** @var list<Calendar> $touched */
|
||||
$touched = [];
|
||||
|
||||
$this->applyCreates($context, $arguments['create'] ?? null, $created, $notCreated, $touched);
|
||||
$this->applyUpdates($context, $arguments['update'] ?? null, $updated, $notUpdated, $touched);
|
||||
$this->applyDestroys($context, $arguments['destroy'] ?? null, $destroyed, $notDestroyed, $touched);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->dispatchSync($touched);
|
||||
|
||||
return [
|
||||
'accountId' => (string) $account->id,
|
||||
'oldState' => CalendarState::FIXED,
|
||||
'newState' => CalendarState::FIXED,
|
||||
'created' => 0 === count($created) ? new \stdClass() : $created,
|
||||
'notCreated' => 0 === count($notCreated) ? new \stdClass() : $notCreated,
|
||||
'updated' => 0 === count($updated) ? new \stdClass() : $updated,
|
||||
'notUpdated' => 0 === count($notUpdated) ? new \stdClass() : $notUpdated,
|
||||
'destroyed' => $destroyed,
|
||||
'notDestroyed' => 0 === count($notDestroyed) ? new \stdClass() : $notDestroyed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $created
|
||||
* @param array<string,mixed> $notCreated
|
||||
* @param list<Calendar> $touched
|
||||
*/
|
||||
private function applyCreates(JmapContext $context, mixed $create, array &$created, array &$notCreated, array &$touched): void
|
||||
{
|
||||
if (null === $create) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === is_array($create)) {
|
||||
throw new MethodException('invalidArguments', '"create" must be an object.');
|
||||
}
|
||||
|
||||
foreach ($create as $creationId => $properties) {
|
||||
$creationId = (string) $creationId;
|
||||
|
||||
if (false === is_array($properties)) {
|
||||
$notCreated[$creationId] = ['type' => 'invalidProperties', 'description' => 'Each create must be an object.'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$event = $this->jmapWriter->create($context->user, $properties);
|
||||
} catch (MethodException $exception) {
|
||||
$notCreated[$creationId] = $exception->toError();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A create that has never been at the remote is a POST, not a PUT,
|
||||
// which markLocallyChanged() cannot tell from an event in step with
|
||||
// it — see CalendarEventWriter. A no-op on a calendar that mirrors
|
||||
// nothing, so there is no branch here.
|
||||
$this->writer->markLocallyCreated($event);
|
||||
$this->remember($touched, $event);
|
||||
|
||||
$id = (string) $event->id;
|
||||
|
||||
// Lets a later call in the same request refer to "#creationId".
|
||||
$context->recordCreatedId($creationId, $id);
|
||||
|
||||
// Server-set properties the client could not know; the spec
|
||||
// requires them back on the created object. uid is here because a
|
||||
// client that did not supply one gets the minted identity every
|
||||
// other calendar will match this event on.
|
||||
$created[$creationId] = [
|
||||
'id' => $id,
|
||||
'uid' => $event->uid,
|
||||
'calendarId' => null === $event->calendar ? null : (string) $event->calendar->id,
|
||||
'isRecurring' => $event->isRecurring,
|
||||
'sequence' => $event->sequence,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $updated
|
||||
* @param array<string,mixed> $notUpdated
|
||||
* @param list<Calendar> $touched
|
||||
*/
|
||||
private function applyUpdates(JmapContext $context, mixed $update, array &$updated, array &$notUpdated, array &$touched): void
|
||||
{
|
||||
if (null === $update) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === is_array($update)) {
|
||||
throw new MethodException('invalidArguments', '"update" must be an object.');
|
||||
}
|
||||
|
||||
foreach ($update as $id => $patch) {
|
||||
$id = (string) $id;
|
||||
$resolved = $context->resolveId($id) ?? $id;
|
||||
|
||||
if (false === is_array($patch)) {
|
||||
$notUpdated[$id] = ['type' => 'invalidPatch', 'description' => 'Each update must be an object.'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = $this->findOne($context, $resolved);
|
||||
|
||||
if (null === $event) {
|
||||
$notUpdated[$id] = ['type' => 'notFound', 'description' => 'No such CalendarEvent.'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Where the event is NOW, not where the patch may be moving it: a
|
||||
// read-only calendar refuses to give an event up as firmly as it
|
||||
// refuses to take one.
|
||||
$from = $event->calendar;
|
||||
|
||||
try {
|
||||
$this->jmapWriter->update($context->user, $event, $patch);
|
||||
} catch (MethodException $exception) {
|
||||
$notUpdated[$id] = $exception->toError();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A person edited this, so the reconciler must stop overwriting it
|
||||
// from later mail about the same booking — the same call the web
|
||||
// editor makes, and a no-op on anything not extracted.
|
||||
$this->writer->markUserEdited($event);
|
||||
$this->writer->markLocallyChanged($event);
|
||||
|
||||
$this->remember($touched, $event);
|
||||
|
||||
if (null !== $from && $from !== $event->calendar) {
|
||||
// A move owes the calendar it left a sync too, or the remote
|
||||
// goes on holding an event that is no longer there.
|
||||
$touched[] = $from;
|
||||
}
|
||||
|
||||
// null = "no properties changed beyond what the client asked for".
|
||||
$updated[$id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $destroyed
|
||||
* @param array<string,mixed> $notDestroyed
|
||||
* @param list<Calendar> $touched
|
||||
*/
|
||||
private function applyDestroys(JmapContext $context, mixed $destroy, array &$destroyed, array &$notDestroyed, array &$touched): void
|
||||
{
|
||||
if (null === $destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === is_array($destroy)) {
|
||||
throw new MethodException('invalidArguments', '"destroy" must be an array of ids.');
|
||||
}
|
||||
|
||||
foreach ($destroy as $id) {
|
||||
$id = (string) $id;
|
||||
$event = $this->findOne($context, $context->resolveId($id) ?? $id);
|
||||
|
||||
if (null === $event) {
|
||||
$notDestroyed[$id] = ['type' => 'notFound', 'description' => 'No such CalendarEvent.'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$calendar = $event->calendar;
|
||||
|
||||
if (null !== $calendar && true === $calendar->isReadOnly) {
|
||||
$notDestroyed[$id] = [
|
||||
'type' => 'forbidden',
|
||||
'description' => sprintf('Calendar "%s" is read-only; it mirrors somewhere that does not accept writes.', (string) $calendar->id),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->remember($touched, $event);
|
||||
|
||||
// A synced event is not removed: the row is the only record that
|
||||
// the remote still holds a copy, so it survives — occurrences
|
||||
// dropped, which is what makes the deletion look immediate — until
|
||||
// the remote has been told. Same call the web editor makes.
|
||||
if (true === $this->writer->markLocallyDeleted($event)) {
|
||||
$this->entityManager->remove($event);
|
||||
}
|
||||
|
||||
$destroyed[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped to the owner, so somebody else's event is notFound rather than
|
||||
* forbidden — the two are distinguishable to a client, and only one of them
|
||||
* confirms that the id exists.
|
||||
*/
|
||||
private function findOne(JmapContext $context, string $id): ?CalendarEvent
|
||||
{
|
||||
if (false === ctype_digit($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->events->findOneForUser($context->user, (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Calendar> $touched
|
||||
*/
|
||||
private function remember(array &$touched, CalendarEvent $event): void
|
||||
{
|
||||
$calendar = $event->calendar;
|
||||
|
||||
if (null === $calendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
$touched[] = $calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a sync now rather than waiting for the sweep.
|
||||
*
|
||||
* Silent on a calendar that mirrors nothing, which is most of them, and at
|
||||
* most one message per calendar however many events a single /set touched.
|
||||
*
|
||||
* @param list<Calendar> $calendars
|
||||
*/
|
||||
private function dispatchSync(array $calendars): void
|
||||
{
|
||||
$dispatched = [];
|
||||
|
||||
foreach ($calendars as $calendar) {
|
||||
if (false === $calendar->isSynced()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$calendarId = (int) $calendar->id;
|
||||
|
||||
if (true === in_array($calendarId, $dispatched, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dispatched[] = $calendarId;
|
||||
|
||||
$this->bus->dispatch(new SyncCalendarMessage($calendarId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Method\Calendar;
|
||||
|
||||
use App\Jmap\Account\CalendarAccountResolver;
|
||||
use App\Jmap\Calendar\CalendarState;
|
||||
use App\Jmap\Mapper\CalendarMapper;
|
||||
use App\Jmap\Method\JmapMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
use App\Repository\Calendar\CalendarRepository;
|
||||
|
||||
/**
|
||||
* "Calendar/get". ids = null returns every calendar the user owns, in sidebar
|
||||
* order; otherwise the named ones, with anything missing listed in notFound.
|
||||
*
|
||||
* Shaped like Mailbox/get, including the "null means all of them" default: a
|
||||
* user has a handful of calendars, not an unbounded mailbox, so there is
|
||||
* nothing here for a /query to page through and none is implemented.
|
||||
*
|
||||
* Scoped to the user rather than to the account, which is what a plMail
|
||||
* Calendar is — see CalendarAccountResolver for why one account nevertheless
|
||||
* serves them. Another user's calendar is not in the answer and is reported as
|
||||
* notFound, never as an error: "there is no calendar 41 for you" and "calendar
|
||||
* 41 is somebody else's" have to be the same sentence.
|
||||
*/
|
||||
final class CalendarGetMethod implements JmapMethod
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CalendarAccountResolver $accountResolver,
|
||||
private readonly CalendarRepository $calendars,
|
||||
private readonly CalendarMapper $mapper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Calendar/get';
|
||||
}
|
||||
|
||||
public function handle(array $arguments, JmapContext $context): array
|
||||
{
|
||||
$account = $this->accountResolver->resolve($context->user, $arguments['accountId'] ?? null);
|
||||
|
||||
$properties = $arguments['properties'] ?? null;
|
||||
|
||||
if (null !== $properties && false === is_array($properties)) {
|
||||
throw new MethodException('invalidArguments', '"properties" must be an array or null.');
|
||||
}
|
||||
|
||||
$requestedIds = $arguments['ids'] ?? null;
|
||||
$notFound = [];
|
||||
|
||||
if (null === $requestedIds) {
|
||||
$calendars = $this->calendars->findForUser($context->user);
|
||||
} else {
|
||||
if (false === is_array($requestedIds)) {
|
||||
throw new MethodException('invalidArguments', '"ids" must be an array or null.');
|
||||
}
|
||||
|
||||
$calendars = [];
|
||||
|
||||
foreach ($requestedIds as $id) {
|
||||
$id = $context->resolveId((string) $id) ?? (string) $id;
|
||||
$calendar = false === ctype_digit($id)
|
||||
? null
|
||||
: $this->calendars->findOneForUser($context->user, (int) $id);
|
||||
|
||||
if (null === $calendar) {
|
||||
$notFound[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$calendars[] = $calendar;
|
||||
}
|
||||
}
|
||||
|
||||
$list = [];
|
||||
|
||||
foreach ($calendars as $calendar) {
|
||||
$list[] = $this->mapper->toJmap($calendar, $properties);
|
||||
}
|
||||
|
||||
return [
|
||||
'accountId' => (string) $account->id,
|
||||
// Fixed, and CalendarState says why at length: calendars are
|
||||
// written from four places and only this one could record a change,
|
||||
// so a token here would sit still while a sync replaced the day.
|
||||
'state' => CalendarState::FIXED,
|
||||
'list' => $list,
|
||||
'notFound' => $notFound,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,25 @@ final class Capability
|
||||
*/
|
||||
public const string PUSH = 'urn:plmail:params:jmap:push';
|
||||
|
||||
/**
|
||||
* Vendor extension: Calendar and CalendarEvent.
|
||||
*
|
||||
* Deliberately NOT "urn:ietf:params:jmap:calendars". JMAP for Calendars is
|
||||
* an unratified draft whose object shape is still moving — properties have
|
||||
* been renamed and re-scoped between revisions — so advertising its URN
|
||||
* would promise a contract no client could rely on, and a client that
|
||||
* believed it would break on the revision after the one this was written
|
||||
* against. A vendor URN says what is true: this is plMail's calendar
|
||||
* surface, and only something written for plMail should use it.
|
||||
*
|
||||
* The same call PUSH above already made, for the same reason: RFC 8620
|
||||
* defines nowhere to put a VAPID key, so the key went behind a URN nobody
|
||||
* can mistake for a standard. Switching to the IETF URN when the draft is
|
||||
* ratified is then an addition rather than a breaking change — both can be
|
||||
* advertised while clients move across.
|
||||
*/
|
||||
public const string CALENDARS = 'urn:plmail:params:jmap:calendars';
|
||||
|
||||
/**
|
||||
* Capabilities a client is currently allowed to declare in "using".
|
||||
* Grow this list as new object types come online.
|
||||
@@ -30,5 +49,6 @@ final class Capability
|
||||
self::CORE,
|
||||
self::MAIL,
|
||||
self::SUBMISSION,
|
||||
self::CALENDARS,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Query;
|
||||
|
||||
/**
|
||||
* One page of CalendarEvent/query: the ids in sort order, where the page
|
||||
* starts, and how many matched in total.
|
||||
*
|
||||
* A DTO rather than a three-element array for the reason every DTO here exists:
|
||||
* `position` and `total` are both ints and both plausible in either slot, and
|
||||
* a caller that swapped them would page a client through a list whose length it
|
||||
* had misreported.
|
||||
*
|
||||
* @see CalendarEventQueryRunner
|
||||
*/
|
||||
final readonly class CalendarEventQueryResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $ids
|
||||
*/
|
||||
public function __construct(
|
||||
public int $position,
|
||||
public array $ids,
|
||||
public int $total,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jmap\Query;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Repository\Calendar\CalendarEventOccurrenceRepository;
|
||||
use App\Repository\Calendar\CalendarRepository;
|
||||
|
||||
/**
|
||||
* Runs CalendarEvent/query: which calendars, which window, and the translation
|
||||
* from what the index answers to what the client is told.
|
||||
*
|
||||
* The read is `CalendarEventOccurrenceRepository::findInRange()` — a `tsrange
|
||||
* &&` overlap against the GiST index on `calendar_event_occurrence`, which is
|
||||
* the same query every calendar view makes. Not a second query written here:
|
||||
* the repository's docblock explains why the naive `starts_at < :to AND ends_at
|
||||
* > :from` degrades once multi-day events exist, and a JMAP client asking the
|
||||
* same question deserves the same index rather than a rediscovery of that.
|
||||
*
|
||||
* **The result of that read is occurrence rows, and the ids published are event
|
||||
* ids.** That translation is the one place this can go wrong silently — both
|
||||
* are autoincrement ints, so an untranslated occurrence id names a real, wrong
|
||||
* event — so it happens here, once, and CalendarEventMapper's docblock carries
|
||||
* the argument for the id space it lands in.
|
||||
*
|
||||
* Two deliberate absences:
|
||||
*
|
||||
* **The window is required.** An unbounded query cannot be answered from this
|
||||
* index at all: occurrences are materialised only to
|
||||
* RecurrenceMaterialiser's horizon, so "everything" would come back looking
|
||||
* complete while stopping two years out, and a client cannot detect a truncation
|
||||
* nobody reported. Refusing is the only answer that does not lie.
|
||||
*
|
||||
* **No FilterOperator.** AND/OR/NOT over a range overlap would have to be
|
||||
* evaluated outside the index, which is the sequential scan the index exists to
|
||||
* avoid; and an OR of two windows is two queries a client can make. Refused by
|
||||
* name rather than ignored, for the reason EmailFilterCompiler refuses an
|
||||
* unknown condition: a filter quietly dropped returns too much, and the client
|
||||
* has no way to tell.
|
||||
*/
|
||||
final class CalendarEventQueryRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CalendarRepository $calendars,
|
||||
private readonly CalendarEventOccurrenceRepository $occurrences,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $filter
|
||||
*/
|
||||
public function run(User $user, ?array $filter, int $position, int $limit): CalendarEventQueryResult
|
||||
{
|
||||
if (null === $filter || 0 === count($filter)) {
|
||||
throw new MethodException(
|
||||
'unsupportedFilter',
|
||||
'CalendarEvent/query needs "after" and "before": occurrences are materialised only to a horizon, so an unbounded query would look complete while stopping at it.',
|
||||
);
|
||||
}
|
||||
|
||||
if (true === array_key_exists('operator', $filter)) {
|
||||
throw new MethodException('unsupportedFilter', 'A FilterOperator is not supported; every condition in a CalendarEvent/query filter is ANDed.');
|
||||
}
|
||||
|
||||
foreach (array_keys($filter) as $property) {
|
||||
if (false === in_array($property, ['inCalendar', 'after', 'before'], true)) {
|
||||
throw new MethodException('unsupportedFilter', sprintf('Filter condition "%s" is not supported.', $property));
|
||||
}
|
||||
}
|
||||
|
||||
$after = $this->utcDate($filter['after'] ?? null, 'after');
|
||||
$before = $this->utcDate($filter['before'] ?? null, 'before');
|
||||
|
||||
$calendarIds = $this->calendarIds($user, $filter['inCalendar'] ?? null);
|
||||
|
||||
$ids = [];
|
||||
|
||||
// findInRange() answers nothing for an empty calendar list, but it is
|
||||
// asked explicitly here: an "inCalendar" naming a calendar this user
|
||||
// does not own must select no events rather than every event, and
|
||||
// reaching the repository to discover that would depend on a contract
|
||||
// this method does not own.
|
||||
if ([] !== $calendarIds) {
|
||||
foreach ($this->occurrences->findInRange($user, $calendarIds, $after, $before) as $occurrence) {
|
||||
$eventId = $occurrence->event?->id;
|
||||
|
||||
if (null === $eventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered by the occurrence's start, so a series is placed by
|
||||
// its FIRST instance inside the window — which is where a
|
||||
// client drawing an agenda would expect to meet it — and
|
||||
// de-duplicated by keying on the id rather than appending: a
|
||||
// weekly meeting overlaps a month-long window four times and
|
||||
// is one event.
|
||||
$ids[(string) $eventId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$ids = array_keys($ids);
|
||||
$total = count($ids);
|
||||
|
||||
return new CalendarEventQueryResult(
|
||||
$position,
|
||||
array_map('strval', array_slice($ids, $position, $limit)),
|
||||
$total,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendars to search, as ids, always scoped to the user's own.
|
||||
*
|
||||
* An unowned or unknown "inCalendar" yields an empty list and therefore an
|
||||
* empty result — invisible rather than an error, which is the same answer
|
||||
* every other lookup here gives for somebody else's row. Reporting
|
||||
* "notFound" would confirm that a calendar with that id exists.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function calendarIds(User $user, mixed $inCalendar): array
|
||||
{
|
||||
if (null === $inCalendar) {
|
||||
$ids = [];
|
||||
|
||||
foreach ($this->calendars->findForUser($user) as $calendar) {
|
||||
$ids[] = (int) $calendar->id;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
if (false === is_string($inCalendar) && false === is_int($inCalendar)) {
|
||||
throw new MethodException('invalidArguments', '"inCalendar" must be a Calendar id.');
|
||||
}
|
||||
|
||||
$id = (string) $inCalendar;
|
||||
|
||||
if (false === ctype_digit($id)) {
|
||||
throw new MethodException('invalidArguments', '"inCalendar" must be a Calendar id.');
|
||||
}
|
||||
|
||||
$calendar = $this->calendars->findOneForUser($user, (int) $id);
|
||||
|
||||
if (null === $calendar) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [(int) $calendar->id];
|
||||
}
|
||||
|
||||
private function utcDate(mixed $value, string $property): \DateTimeImmutable
|
||||
{
|
||||
if (false === is_string($value) || '' === $value) {
|
||||
throw new MethodException('invalidArguments', sprintf('"%s" must be a UTCDate string.', $property));
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new \DateTimeImmutable($value);
|
||||
} catch (\Exception) {
|
||||
throw new MethodException('invalidArguments', sprintf('"%s" is not a valid UTCDate.', $value));
|
||||
}
|
||||
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'));
|
||||
}
|
||||
}
|
||||
+67
-2
@@ -3,9 +3,10 @@
|
||||
A conformant JMAP subset (RFC 8620 core, RFC 8621 mail): the request envelope,
|
||||
the state/change engine every `/changes` method builds on, the Mailbox / Email /
|
||||
Thread / Identity / EmailSubmission object methods, blob upload and download,
|
||||
and push over both EventSource and Web Push.
|
||||
and push over both EventSource and Web Push. Plus calendars, under a vendor URN
|
||||
rather than the unratified draft's.
|
||||
|
||||
**20 methods**, 6 HTTP endpoints. Tested against ltt.rs (Bearer) and Sterna
|
||||
**24 methods**, 6 HTTP endpoints. Tested against ltt.rs (Bearer) and Sterna
|
||||
Mail (Basic).
|
||||
|
||||
| | |
|
||||
@@ -17,6 +18,8 @@ Mail (Basic).
|
||||
| `Identity/` | `get`, `set` |
|
||||
| `EmailSubmission/` | `get`, `set`, `changes` |
|
||||
| `SearchSnippet/` | `get` |
|
||||
| `Calendar/` | `get` |
|
||||
| `CalendarEvent/` | `get`, `query`, `set` |
|
||||
|
||||
---
|
||||
|
||||
@@ -62,6 +65,17 @@ rather than leaking.
|
||||
`Email/set update` and `EmailSubmission/set onSuccessUpdateEmail`.
|
||||
- `Mail/JmapDraftWriter` — `Email/set create`, mirroring the web composer.
|
||||
|
||||
**Calendar glue**
|
||||
- `Account/CalendarAccountResolver` — which JMAP account serves calendars, and
|
||||
the refusal for every other one.
|
||||
- `Mapper/CalendarMapper`, `Mapper/CalendarEventMapper` — the two objects. The
|
||||
second one carries the id-space argument.
|
||||
- `Query/CalendarEventQueryRunner` — the window, the calendars, and the
|
||||
occurrence → event id translation.
|
||||
- `Calendar/JmapEventWriter` — reads a JSCalendar object off the wire and hands
|
||||
it to `CalendarEventWriter`; nothing here assigns a column.
|
||||
- `Calendar/CalendarState` — why the calendar state string is a constant.
|
||||
|
||||
**State — the sync engine**
|
||||
- `State/ChangeLog` (entity) — append-only; the autoincrement PK *is* the state
|
||||
token.
|
||||
@@ -154,6 +168,38 @@ Get these wrong and things fail quietly rather than loudly.
|
||||
did not is the failure this whole layer is careful about. `app:backfill
|
||||
category` is what fills those in, and re-running it after a change to
|
||||
`MessageCategorizer` needs no resync — it reads only persisted data.
|
||||
- **A JMAP CalendarEvent id is a `calendar_event` id — the series.** The query
|
||||
that finds events is a `tsrange &&` overlap over `calendar_event_occurrence`,
|
||||
so the ids the database hands back are *occurrence* ids and
|
||||
`CalendarEventQueryRunner` translates them. Both are autoincrement ints from
|
||||
different tables, so an untranslated one is a valid-looking id for an
|
||||
unrelated event — the same failure mode as Email.mailboxIds above, which is
|
||||
why `CalendarEventQueryMethodTest` asserts round trips rather than shapes. The
|
||||
series is the unit because that is what a JSCalendar Event *is* (rule plus
|
||||
overrides, expanded client-side), and because occurrence rows are rewritten
|
||||
wholesale on every write, so an id naming one would go stale when somebody
|
||||
corrected a title.
|
||||
- **Calendars are served from ONE account, and it is the session's primary.**
|
||||
A Calendar is user-scoped, with no per-account binding of the sort that makes
|
||||
a Mailbox id an account-local thing. Publishing the list under every connected
|
||||
account would put one calendar under three accountIds with the same id, which
|
||||
a client — keying objects by (accountId, id) — draws three times. Every other
|
||||
account answers `accountNotSupportedByMethod`.
|
||||
- **The calendar state string is fixed, and that is deliberate.** Mail's token
|
||||
works because `MailChangeRecorder` makes the change log complete. An event
|
||||
changes from four places — the sync engine, extraction, the web editor and
|
||||
`CalendarEvent/set` — and only the last is in this directory, so a log written
|
||||
here would sit still while a pull replaced the whole day. `canCalculateChanges`
|
||||
is false, there is no `/changes`, and the value is non-numeric so that a client
|
||||
still holding it gets `cannotCalculateChanges` if calendars ever do join the
|
||||
log. See `Calendar/CalendarState`.
|
||||
- **`CalendarEvent/set` marks a synced event for push, and the writer does not.**
|
||||
`CalendarEventWriter::write()` is also how a pull applies what it just read
|
||||
from a remote, so marking there would push the remote's own data back at it —
|
||||
hence `markLocallyCreated` / `markLocallyChanged` belong to whatever made the
|
||||
change. A JMAP client is a person making a change, exactly as the web editor
|
||||
is; without the mark an edit made on a phone never leaves the machine and the
|
||||
next pull silently reverts it.
|
||||
- **`seen_at` / `starred_at` are authoritative** for `$seen` / `$flagged`, not
|
||||
the `\Seen` entry in `Message::$flags`. flags is an IMAP mirror only the
|
||||
plain-IMAP path populates and is a strict subset of `seen_at`. flags *is*
|
||||
@@ -290,6 +336,25 @@ worker message, so a sync importing 50 messages sends **one** notification.
|
||||
**stemmed**: a search for `running` marks `run`, and a term whose stem
|
||||
differs from the literal text still highlights. Re-implementing either in PHP
|
||||
would let a snippet highlight something the search did not match on.
|
||||
- **Calendars are advertised as `urn:plmail:params:jmap:calendars`, not
|
||||
`urn:ietf:params:jmap:calendars`.** JMAP for Calendars is an unratified draft
|
||||
whose object shape is still moving; claiming its URN would promise a contract
|
||||
no client could rely on. Same call `urn:plmail:params:jmap:push` already made.
|
||||
- `CalendarEvent/query` **requires** `after` and `before`. Occurrences are
|
||||
materialised only to `RecurrenceMaterialiser`'s horizon, so an unbounded query
|
||||
would answer from a partial index and look complete. `inCalendar` is the only
|
||||
other condition; a FilterOperator and a `sort` are refused rather than ignored.
|
||||
- `CalendarEvent/get` caps at 100 ids, not the session's 500, and says so in the
|
||||
calendars accountCapabilities. Ids are resolved one at a time through
|
||||
`CalendarEventRepository::findOneForUser()`, which is the only lookup there
|
||||
that scopes on the owner; a `findByIdsForUser()` would make this one query.
|
||||
- `Calendar/set` is not implemented. The two provisioned roles come from
|
||||
`CalendarProvisioner` and a mirrored one from the subscribe flow, neither of
|
||||
which a JMAP create could stand in for.
|
||||
- `CalendarEvent/set` accepts a fixed JSCalendar vocabulary and refuses the rest
|
||||
by name — `participants` (an RSVP goes through `InviteResponder`, which sends
|
||||
an iTIP reply), `privacy`, `alerts`, `links`. It also refuses PatchObject
|
||||
paths (`locations/1/name`): the writer takes whole values.
|
||||
- Not implemented: `Email/copy|import|parse`, `VacationResponse/*`, `Blob/copy`.
|
||||
|
||||
---
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Jmap\Session;
|
||||
|
||||
use App\Entity\User\User;
|
||||
use App\Jmap\Account\CalendarAccountResolver;
|
||||
use App\Jmap\Protocol\Capability;
|
||||
use App\Jmap\State\StateManager;
|
||||
use App\Service\Calendar\RecurrenceMaterialiser;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,7 @@ final class SessionBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StateManager $stateManager,
|
||||
private readonly CalendarAccountResolver $calendarAccountResolver,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly string $vapidPublicKey,
|
||||
) {
|
||||
@@ -40,19 +43,32 @@ final class SessionBuilder
|
||||
|
||||
$accounts = [];
|
||||
$primaryId = null;
|
||||
// Calendars are the user's, not an account's, so exactly one account
|
||||
// publishes them — see CalendarAccountResolver, which is also what
|
||||
// every calendar method checks an accountId against. Two rules for
|
||||
// which account that is would advertise calendars where they cannot be
|
||||
// fetched.
|
||||
$calendarAccount = $this->calendarAccountResolver->accountFor($user);
|
||||
$calendarAccountId = null === $calendarAccount ? null : (string) $calendarAccount->id;
|
||||
|
||||
foreach ($user->accounts as $account) {
|
||||
$accountId = (string) $account->id;
|
||||
$primaryId ??= $accountId;
|
||||
|
||||
$accountCapabilities = [
|
||||
Capability::MAIL => $this->mailAccountCapabilities(),
|
||||
Capability::SUBMISSION => $this->submissionCapabilities(),
|
||||
];
|
||||
|
||||
if ($accountId === $calendarAccountId) {
|
||||
$accountCapabilities[Capability::CALENDARS] = $this->calendarAccountCapabilities();
|
||||
}
|
||||
|
||||
$accounts[$accountId] = [
|
||||
'name' => (string) $account->email,
|
||||
'isPersonal' => true,
|
||||
'isReadOnly' => false,
|
||||
'accountCapabilities' => [
|
||||
Capability::MAIL => $this->mailAccountCapabilities(),
|
||||
Capability::SUBMISSION => $this->submissionCapabilities(),
|
||||
],
|
||||
'accountCapabilities' => $accountCapabilities,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -62,6 +78,10 @@ final class SessionBuilder
|
||||
$primaryAccounts[Capability::MAIL] = $primaryId;
|
||||
}
|
||||
|
||||
if (null !== $calendarAccountId) {
|
||||
$primaryAccounts[Capability::CALENDARS] = $calendarAccountId;
|
||||
}
|
||||
|
||||
// Empty JMAP maps must serialise as {} rather than [].
|
||||
$accountsValue = new \stdClass();
|
||||
|
||||
@@ -81,6 +101,7 @@ final class SessionBuilder
|
||||
Capability::MAIL => new \stdClass(),
|
||||
Capability::SUBMISSION => new \stdClass(),
|
||||
Capability::PUSH => $this->pushCapabilities(),
|
||||
Capability::CALENDARS => new \stdClass(),
|
||||
],
|
||||
'accounts' => $accountsValue,
|
||||
'primaryAccounts' => $primaryAccountsValue,
|
||||
@@ -129,6 +150,40 @@ final class SessionBuilder
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a client may assume about this account's calendars.
|
||||
*
|
||||
* maxEventsInGet is lower than the Session's global maxObjectsInGet on
|
||||
* purpose and is stated here for that reason: CalendarEvent/get resolves
|
||||
* one id at a time, because the ownership-scoped lookup it uses is the only
|
||||
* one CalendarEventRepository offers, and a client obeying 500 would
|
||||
* otherwise meet a requestTooLarge it was told not to expect.
|
||||
*
|
||||
* mayCreateCalendar is false because Calendar/set is not implemented: the
|
||||
* two provisioned roles are created by CalendarProvisioner and a subscribed
|
||||
* one by the subscribe flow, neither of which a JMAP create could stand in
|
||||
* for.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function calendarAccountCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'maxEventsInGet' => 100,
|
||||
'maxEventsInSet' => 500,
|
||||
'mayCreateCalendar' => false,
|
||||
// The window CalendarEvent/query can be trusted over. Occurrences
|
||||
// are materialised to RecurrenceMaterialiser's horizon and no
|
||||
// further, so a query outside it answers from a partial index —
|
||||
// stated rather than left for a client to discover as a recurring
|
||||
// meeting that stops.
|
||||
'materialisedHorizon' => [
|
||||
'past' => RecurrenceMaterialiser::HORIZON_PAST,
|
||||
'future' => RecurrenceMaterialiser::HORIZON_FUTURE,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The VAPID public key clients pass as applicationServerKey when creating
|
||||
* a browser push subscription. Empty when Web Push is unconfigured, which
|
||||
|
||||
@@ -135,6 +135,43 @@ abstract class JmapTestCase extends KernelTestCase
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* A second mail account on the same user.
|
||||
*
|
||||
* The seed above is deliberately one account, because that is the smallest
|
||||
* world a method runs against — but "which account" is a real question for
|
||||
* anything user-scoped, and a test asking it needs a second one that is
|
||||
* genuinely the user's rather than a stranger's.
|
||||
*/
|
||||
protected function secondAccount(): Account
|
||||
{
|
||||
$suffix = uniqid('', true);
|
||||
|
||||
$account = new Account();
|
||||
$account->usr = $this->user;
|
||||
$account->name = 'Jmap Fixture (second)';
|
||||
$account->email = 'jmap-second-'.$suffix.'@example.test';
|
||||
$account->username = 'jmap-second-'.$suffix.'@example.test';
|
||||
$account->imapHost = 'localhost';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->smtpHost = 'localhost';
|
||||
$account->smtpPort = 587;
|
||||
$account->smtpEncryption = 'starttls';
|
||||
$account->password = 'x';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
// AccountResolver scopes on the inverse side, which persisting the
|
||||
// owning side alone does not populate.
|
||||
$this->user->addAccount($account);
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
protected function customLabel(string $name, ?Label $parent = null): Label
|
||||
{
|
||||
$label = new Label();
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Jmap\Method\Calendar;
|
||||
|
||||
use App\Entity\Calendar\Calendar;
|
||||
use App\Jmap\Method\Calendar\CalendarEventGetMethod;
|
||||
use App\Jmap\Method\Calendar\CalendarEventQueryMethod;
|
||||
use App\Jmap\Method\Calendar\CalendarGetMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Which id space the calendar methods speak, and whether a client can hand any
|
||||
* id back.
|
||||
*
|
||||
* CalendarEvent/query reads `calendar_event_occurrence` — a tsrange overlap
|
||||
* against the GiST index — and publishes `calendar_event` ids. Those are two
|
||||
* autoincrement sequences in two tables, so an untranslated occurrence id is
|
||||
* not a decode error: it is a *valid-looking* id for some unrelated event, and
|
||||
* the client fetches it and draws a plausible wrong answer. Email.mailboxIds
|
||||
* shipped exactly that bug with label ids where binding ids were meant, and
|
||||
* EmailMapperTest's docblock records how long it went unnoticed.
|
||||
*
|
||||
* So the assertions below are round trips rather than shape checks: every id
|
||||
* this server emits is fed back into the filter or the getter that consumes it
|
||||
* and must select **what it came from**. Deliberately not "the two id sequences
|
||||
* differ" — that is an assertion about how old the database is, which is the
|
||||
* other lesson from the same file.
|
||||
*
|
||||
* A recurring series is in the fixture on purpose. It is the case where the
|
||||
* two id spaces have visibly different cardinality — one event, many
|
||||
* occurrences — and where returning occurrence ids would give a client several
|
||||
* boxes for one meeting it cannot collapse.
|
||||
*/
|
||||
final class CalendarEventQueryMethodTest extends CalendarMethodTestCase
|
||||
{
|
||||
private CalendarEventQueryMethod $query;
|
||||
private CalendarEventGetMethod $get;
|
||||
private CalendarGetMethod $calendarGet;
|
||||
|
||||
private Calendar $work;
|
||||
private Calendar $personal;
|
||||
|
||||
private DateTimeImmutable $day;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$container = self::getContainer();
|
||||
$this->query = $container->get(CalendarEventQueryMethod::class);
|
||||
$this->get = $container->get(CalendarEventGetMethod::class);
|
||||
$this->calendarGet = $container->get(CalendarGetMethod::class);
|
||||
|
||||
$this->day = $this->baseDay();
|
||||
$this->work = $this->seedCalendar('Work');
|
||||
$this->personal = $this->seedCalendar('Personal');
|
||||
|
||||
// Two one-off events on Work inside the window, one on Personal, and a
|
||||
// weekly series whose occurrences outnumber every event in the fixture
|
||||
// several times over.
|
||||
$this->seedEvent($this->work, 'Standup', $this->day->setTime(9, 0), '+15 minutes', ['@type' => 'RecurrenceRule', 'frequency' => 'weekly']);
|
||||
$this->seedEvent($this->work, 'Design review', $this->day->setTime(11, 0));
|
||||
$this->seedEvent($this->personal, 'Dentist', $this->day->setTime(15, 0));
|
||||
// Outside every window this test asks about.
|
||||
$this->seedEvent($this->work, 'Next month', $this->day->modify('+40 days')->setTime(9, 0));
|
||||
}
|
||||
|
||||
public function testTheWindowSelectsOnlyTheEventsInsideIt(): void
|
||||
{
|
||||
$titles = $this->titlesOf($this->idsInWindow($this->day, $this->day->modify('+1 day')));
|
||||
|
||||
sort($titles);
|
||||
|
||||
self::assertSame(['Dentist', 'Design review', 'Standup'], $titles);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assertion that would have caught the original defect, in its calendar
|
||||
* form: an id read out of a response has to be one the server accepts back.
|
||||
*
|
||||
* Occurrence ids and event ids both decode, both exist, and both name a row
|
||||
* — so a wrong id space does not fail here by throwing. It fails by
|
||||
* resolving to something the id did not come from, which is what these two
|
||||
* assertions are about.
|
||||
*/
|
||||
public function testEveryQueriedIdResolvesToTheEventItCameFrom(): void
|
||||
{
|
||||
$ids = $this->idsInWindow($this->day, $this->day->modify('+1 day'));
|
||||
|
||||
self::assertNotSame([], $ids, 'the fixture must produce ids for this to assert anything');
|
||||
|
||||
$result = $this->get->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'ids' => $ids,
|
||||
], $this->context());
|
||||
|
||||
self::assertSame([], $result['notFound'], 'a queried id that CalendarEvent/get cannot resolve is an id from another table');
|
||||
self::assertCount(count($ids), $result['list']);
|
||||
|
||||
foreach ($result['list'] as $event) {
|
||||
self::assertContains($event['id'], $ids, 'CalendarEvent/get answered with an id nobody asked for');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same round trip through the filter rather than the getter: an event id
|
||||
* belongs to the calendar it was queried under, and re-querying that
|
||||
* calendar alone finds it again.
|
||||
*/
|
||||
public function testAQueriedIdIsFoundAgainByTheCalendarItCameFrom(): void
|
||||
{
|
||||
foreach ([$this->work, $this->personal] as $calendar) {
|
||||
$ids = $this->idsInWindow($this->day, $this->day->modify('+1 day'), (string) $calendar->id);
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$result = $this->get->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'ids' => [$id],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame(
|
||||
(string) $calendar->id,
|
||||
$result['list'][0]['calendarId'] ?? null,
|
||||
sprintf('event "%s" was queried on calendar %d but says it lives elsewhere', $id, (int) $calendar->id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other id space this publishes: a Calendar id has to be usable as
|
||||
* "inCalendar", and has to select that calendar's events rather than
|
||||
* another's.
|
||||
*/
|
||||
public function testEveryCalendarIdIsAUsableInCalendarFilter(): void
|
||||
{
|
||||
$calendars = $this->calendarGet->handle(['accountId' => $this->accountId()], $this->context());
|
||||
|
||||
$seen = [];
|
||||
|
||||
foreach ($calendars['list'] as $calendar) {
|
||||
$titles = $this->titlesOf($this->idsInWindow($this->day, $this->day->modify('+1 day'), $calendar['id']));
|
||||
|
||||
sort($titles);
|
||||
$seen[$calendar['name']] = $titles;
|
||||
}
|
||||
|
||||
ksort($seen);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
'Personal' => ['Dentist'],
|
||||
'Work' => ['Design review', 'Standup'],
|
||||
],
|
||||
$seen,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A weekly meeting overlapping a month-long window is one event, not one per
|
||||
* week. Returning an id per occurrence would draw four meetings a client
|
||||
* has no way to collapse — and it is the shape a naive translation produces,
|
||||
* because the range read genuinely answers four rows.
|
||||
*/
|
||||
public function testARecurringSeriesIsOneIdHoweverOftenItOccurs(): void
|
||||
{
|
||||
$from = $this->day;
|
||||
$to = $this->day->modify('+35 days');
|
||||
|
||||
$ids = $this->idsInWindow($from, $to, (string) $this->work->id);
|
||||
|
||||
self::assertSame(['Design review', 'Standup'], $this->sortedTitles($ids));
|
||||
self::assertSame(count($ids), count(array_unique($ids)), 'ids must not repeat');
|
||||
|
||||
// The assertion above only means something while the window genuinely
|
||||
// holds more occurrences than events; without this the test would keep
|
||||
// passing if the fixture stopped recurring.
|
||||
self::assertGreaterThan(
|
||||
count($ids),
|
||||
$this->occurrencesInWindow($this->work, $from, $to),
|
||||
'the window must overlap more occurrence rows than it does events',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody else's calendar selects nothing, rather than being reported as
|
||||
* missing. "There is no calendar 41 for you" and "calendar 41 belongs to
|
||||
* somebody else" must be the same answer, or the error message is a
|
||||
* membership oracle.
|
||||
*/
|
||||
public function testAnotherUsersCalendarSelectsNothingRatherThanFailing(): void
|
||||
{
|
||||
$theirs = $this->seedCalendar('Theirs', false, $this->otherUser());
|
||||
$this->seedEvent($theirs, 'Their meeting', $this->day->setTime(10, 0));
|
||||
|
||||
self::assertSame([], $this->idsInWindow($this->day, $this->day->modify('+1 day'), (string) $theirs->id));
|
||||
}
|
||||
|
||||
/** And it is not merely absent from a filtered query — an unfiltered one cannot reach it either. */
|
||||
public function testAnotherUsersEventsAreNotInAnUnfilteredQuery(): void
|
||||
{
|
||||
$theirs = $this->seedCalendar('Theirs', false, $this->otherUser());
|
||||
$this->seedEvent($theirs, 'Their meeting', $this->day->setTime(10, 0));
|
||||
|
||||
self::assertNotContains('Their meeting', $this->titlesOf($this->idsInWindow($this->day, $this->day->modify('+1 day'))));
|
||||
}
|
||||
|
||||
/**
|
||||
* An unbounded query is refused rather than answered.
|
||||
*
|
||||
* Occurrences exist only inside RecurrenceMaterialiser's horizon, so
|
||||
* "everything" would come back looking complete while stopping two years
|
||||
* out — and a client cannot detect a truncation nobody reported.
|
||||
*/
|
||||
public function testAQueryWithNoWindowIsRefusedRatherThanTruncated(): void
|
||||
{
|
||||
$this->expectException(MethodException::class);
|
||||
|
||||
$this->query->handle(['accountId' => $this->accountId()], $this->context());
|
||||
}
|
||||
|
||||
/** A condition that is not understood is refused, never quietly dropped: dropping one returns too much. */
|
||||
public function testAnUnknownFilterConditionIsRefused(): void
|
||||
{
|
||||
$this->expectException(MethodException::class);
|
||||
|
||||
$this->query->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'filter' => [
|
||||
'after' => $this->day->format(DATE_ATOM),
|
||||
'before' => $this->day->modify('+1 day')->format(DATE_ATOM),
|
||||
'title' => 'Standup',
|
||||
],
|
||||
], $this->context());
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function idsInWindow(DateTimeImmutable $after, DateTimeImmutable $before, ?string $inCalendar = null): array
|
||||
{
|
||||
$filter = [
|
||||
'after' => $after->format(DATE_ATOM),
|
||||
'before' => $before->format(DATE_ATOM),
|
||||
];
|
||||
|
||||
if (null !== $inCalendar) {
|
||||
$filter['inCalendar'] = $inCalendar;
|
||||
}
|
||||
|
||||
$result = $this->query->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'filter' => $filter,
|
||||
], $this->context());
|
||||
|
||||
return $result['ids'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $ids
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function titlesOf(array $ids): array
|
||||
{
|
||||
if ([] === $ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = $this->get->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'ids' => $ids,
|
||||
], $this->context());
|
||||
|
||||
$titles = [];
|
||||
|
||||
foreach ($result['list'] as $event) {
|
||||
$titles[] = (string) ($event['title'] ?? '');
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
/** The rows the range read actually overlaps, which is what the ids are collapsed from. */
|
||||
private function occurrencesInWindow(Calendar $calendar, DateTimeImmutable $from, DateTimeImmutable $to): int
|
||||
{
|
||||
return (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM calendar_event_occurrence
|
||||
WHERE calendar_id = :calendarId AND span && tsrange(:from, :to, \'[)\')',
|
||||
[
|
||||
'calendarId' => (int) $calendar->id,
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $ids
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function sortedTitles(array $ids): array
|
||||
{
|
||||
$titles = $this->titlesOf($ids);
|
||||
sort($titles);
|
||||
|
||||
return $titles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Jmap\Method\Calendar;
|
||||
|
||||
use App\Domain\Enum\Calendar\CalendarRole;
|
||||
use App\Domain\Enum\Calendar\SyncState;
|
||||
use App\Entity\Calendar\Calendar;
|
||||
use App\Entity\Calendar\CalendarEvent;
|
||||
use App\Jmap\Method\Calendar\CalendarEventSetMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Repository\Calendar\CalendarEventRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* A write from a JMAP client is the same write the web editor makes.
|
||||
*
|
||||
* Three claims, each of which fails silently if it is wrong.
|
||||
*
|
||||
* **It goes through CalendarEventWriter.** An event is a canonical JSCalendar
|
||||
* object plus columns projected from it plus the occurrence rows a view reads,
|
||||
* and only the writer produces all three. A /set that assigned columns would
|
||||
* insert a row that looks correct in the database and appears on no calendar at
|
||||
* all — so the assertions below are about the occurrences, not about the row.
|
||||
*
|
||||
* **A read-only calendar refuses.** Accepting the edit and letting CalendarPusher
|
||||
* discard it later would leave a client that was told "updated" showing an event
|
||||
* the next pull silently reverts.
|
||||
*
|
||||
* **A synced event is marked for push.** The writer deliberately does not mark
|
||||
* inside write(), because write() is also how a pull applies what it just read;
|
||||
* so every caller that is a person marks, and a JMAP client is a person. Without
|
||||
* it an edit made on a phone never leaves the machine and is reverted by the
|
||||
* next pull fifteen minutes later.
|
||||
*/
|
||||
final class CalendarEventSetMethodTest extends CalendarMethodTestCase
|
||||
{
|
||||
private CalendarEventSetMethod $method;
|
||||
private CalendarEventRepository $events;
|
||||
|
||||
private Calendar $calendar;
|
||||
private DateTimeImmutable $day;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$container = self::getContainer();
|
||||
$this->method = $container->get(CalendarEventSetMethod::class);
|
||||
$this->events = $container->get(CalendarEventRepository::class);
|
||||
|
||||
$this->day = $this->baseDay();
|
||||
$this->calendar = $this->seedCalendar('Work');
|
||||
}
|
||||
|
||||
public function testACreatedEventHasTheOccurrenceRowAViewReads(): void
|
||||
{
|
||||
$event = $this->createdEvent(['title' => 'Kickoff', 'start' => $this->local(9)]);
|
||||
|
||||
self::assertSame(1, $this->occurrenceCount($event), 'an event with no occurrence row is on no calendar');
|
||||
}
|
||||
|
||||
/**
|
||||
* A recurring create materialises the whole series, not one row: the
|
||||
* occurrences are what a view reads, so a series that produced a single row
|
||||
* would draw one meeting and lose the rest without failing anything.
|
||||
*/
|
||||
public function testACreatedSeriesMaterialisesEveryOccurrence(): void
|
||||
{
|
||||
$event = $this->createdEvent([
|
||||
'title' => 'Standup',
|
||||
'start' => $this->local(9),
|
||||
'duration' => 'PT15M',
|
||||
'recurrenceRules' => [['@type' => 'RecurrenceRule', 'frequency' => 'weekly', 'count' => 4]],
|
||||
]);
|
||||
|
||||
self::assertTrue($event->isRecurring);
|
||||
self::assertSame(4, $this->occurrenceCount($event));
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical object and the columns say the same thing, which is the
|
||||
* whole reason the writer is the only way in. A create that set $title
|
||||
* alone would export a blank .ics and read correctly everywhere else.
|
||||
*/
|
||||
public function testTheCanonicalObjectAndTheColumnsAgree(): void
|
||||
{
|
||||
$event = $this->createdEvent([
|
||||
'title' => 'Kickoff',
|
||||
'start' => $this->local(9),
|
||||
'locations' => ['1' => ['@type' => 'Location', 'name' => 'Room 2']],
|
||||
]);
|
||||
|
||||
self::assertSame('Kickoff', $event->title);
|
||||
self::assertSame('Kickoff', $event->jscalendar['title']);
|
||||
self::assertSame('Room 2', $event->location);
|
||||
self::assertSame('Room 2', $event->jscalendar['locations']['1']['name']);
|
||||
}
|
||||
|
||||
/** A client that supplied no uid gets the one every other calendar will match this event on. */
|
||||
public function testACreateWithoutAUidIsGivenOne(): void
|
||||
{
|
||||
$created = $this->create(['new' => ['calendarId' => (string) $this->calendar->id, 'title' => 'Kickoff', 'start' => $this->local(9)]]);
|
||||
|
||||
self::assertNotSame('', $created['created']['new']['uid']);
|
||||
}
|
||||
|
||||
public function testAReadOnlyCalendarRefusesACreate(): void
|
||||
{
|
||||
$mirror = $this->seedCalendar('Mirror', true);
|
||||
|
||||
$result = $this->create(['new' => [
|
||||
'calendarId' => (string) $mirror->id,
|
||||
'title' => 'Not mine to write',
|
||||
'start' => $this->local(9),
|
||||
]]);
|
||||
|
||||
self::assertSame([], $result['created'] instanceof \stdClass ? [] : $result['created']);
|
||||
self::assertSame('forbidden', $result['notCreated']['new']['type']);
|
||||
self::assertSame([], $this->events->findByUidForUser($this->user, 'not-mine-to-write'), 'nothing may be written');
|
||||
}
|
||||
|
||||
public function testAReadOnlyCalendarRefusesAnUpdate(): void
|
||||
{
|
||||
$mirror = $this->seedCalendar('Mirror', true);
|
||||
$event = $this->seedEvent($mirror, 'Mirrored meeting', $this->day->setTime(9, 0));
|
||||
|
||||
$result = $this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'update' => [(string) $event->id => ['title' => 'Renamed']],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame('forbidden', $result['notUpdated'][(string) $event->id]['type']);
|
||||
self::assertSame('Mirrored meeting', $event->title);
|
||||
}
|
||||
|
||||
public function testAReadOnlyCalendarRefusesADestroy(): void
|
||||
{
|
||||
$mirror = $this->seedCalendar('Mirror', true);
|
||||
$event = $this->seedEvent($mirror, 'Mirrored meeting', $this->day->setTime(9, 0));
|
||||
|
||||
$result = $this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'destroy' => [(string) $event->id],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame([], $result['destroyed']);
|
||||
self::assertSame('forbidden', $result['notDestroyed'][(string) $event->id]['type']);
|
||||
self::assertNotNull($this->events->findOneForUser($this->user, (int) $event->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody else's event is missing, not refused. notFound and forbidden are
|
||||
* distinguishable to a client, and only one of them confirms the id exists.
|
||||
*/
|
||||
public function testAnotherUsersEventIsInvisibleRatherThanAnError(): void
|
||||
{
|
||||
$theirs = $this->seedCalendar('Theirs', false, $this->otherUser());
|
||||
$event = $this->seedEvent($theirs, 'Their meeting', $this->day->setTime(9, 0));
|
||||
|
||||
$result = $this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'update' => [(string) $event->id => ['title' => 'Renamed']],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame('notFound', $result['notUpdated'][(string) $event->id]['type']);
|
||||
self::assertSame('Their meeting', $event->title, 'their event must be untouched');
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that is never marked is an edit that never leaves: the pusher
|
||||
* sweeps on this column alone, and the next pull would overwrite the row
|
||||
* with the remote's older copy.
|
||||
*/
|
||||
public function testAnEditOnASyncedCalendarIsMarkedForPush(): void
|
||||
{
|
||||
$mirrored = $this->mirroredCalendar();
|
||||
$event = $this->seedEvent($mirrored, 'Mirrored meeting', $this->day->setTime(9, 0));
|
||||
$event->syncState = SyncState::Clean;
|
||||
$this->em->flush();
|
||||
|
||||
$this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'update' => [(string) $event->id => ['title' => 'Moved to Thursday']],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame(SyncState::PendingUpdate, $event->syncState);
|
||||
}
|
||||
|
||||
/**
|
||||
* A create is a POST at the remote and an update is a PUT, and the state
|
||||
* alone cannot tell them apart — a brand-new event is Clean, exactly like
|
||||
* one in step with the remote. Marking a create as an update would push an
|
||||
* update for a resource the remote has never heard of.
|
||||
*/
|
||||
public function testACreateOnASyncedCalendarIsMarkedAsACreate(): void
|
||||
{
|
||||
$mirrored = $this->mirroredCalendar();
|
||||
|
||||
$event = $this->createdEvent(['title' => 'Kickoff', 'start' => $this->local(9)], $mirrored);
|
||||
|
||||
self::assertSame(SyncState::PendingCreate, $event->syncState);
|
||||
}
|
||||
|
||||
/** An event on a calendar that mirrors nothing owes nobody a write, and must not be queued for one. */
|
||||
public function testAnEditOnALocalCalendarIsNotMarkedForPush(): void
|
||||
{
|
||||
$event = $this->createdEvent(['title' => 'Kickoff', 'start' => $this->local(9)]);
|
||||
|
||||
self::assertSame(SyncState::Clean, $event->syncState);
|
||||
}
|
||||
|
||||
/**
|
||||
* A property this server cannot store is refused by name. Dropping it
|
||||
* quietly would tell a client it had invited somebody, with no way for it
|
||||
* to discover otherwise.
|
||||
*/
|
||||
public function testAPropertyThatCannotBeStoredIsRefusedRatherThanDropped(): void
|
||||
{
|
||||
$result = $this->create(['new' => [
|
||||
'calendarId' => (string) $this->calendar->id,
|
||||
'title' => 'Kickoff',
|
||||
'start' => $this->local(9),
|
||||
'participants' => ['someone@example.test' => ['@type' => 'Participant']],
|
||||
]]);
|
||||
|
||||
self::assertSame('invalidProperties', $result['notCreated']['new']['type']);
|
||||
self::assertSame(['participants'], $result['notCreated']['new']['properties']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A start carrying a zone is refused rather than reinterpreted. JSCalendar's
|
||||
* LocalDateTime has no offset; a trailing Z says UTC while `timeZone` says
|
||||
* something else, and guessing between them moves a meeting by hours in
|
||||
* silence.
|
||||
*/
|
||||
public function testAStartWithAZoneIsRefusedRatherThanGuessedAt(): void
|
||||
{
|
||||
$result = $this->create(['new' => [
|
||||
'calendarId' => (string) $this->calendar->id,
|
||||
'title' => 'Kickoff',
|
||||
'start' => $this->day->setTime(9, 0)->format('Y-m-d\TH:i:s\Z'),
|
||||
]]);
|
||||
|
||||
self::assertSame('invalidProperties', $result['notCreated']['new']['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* An update names only what it changes; everything else is re-supplied from
|
||||
* what is stored. The writer derives the canonical object from its
|
||||
* arguments, so a missing description passed through as null would delete
|
||||
* one the client never mentioned.
|
||||
*/
|
||||
public function testAnUpdateLeavesTheThingsItDidNotName(): void
|
||||
{
|
||||
$event = $this->createdEvent([
|
||||
'title' => 'Kickoff',
|
||||
'start' => $this->local(9),
|
||||
'description' => 'Bring the deck',
|
||||
'locations' => ['1' => ['@type' => 'Location', 'name' => 'Room 2']],
|
||||
]);
|
||||
|
||||
$this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'update' => [(string) $event->id => ['title' => 'Kickoff (moved)']],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame('Kickoff (moved)', $event->title);
|
||||
self::assertSame('Bring the deck', $event->jscalendar['description']);
|
||||
self::assertSame('Room 2', $event->location);
|
||||
}
|
||||
|
||||
/** ifInState could only ever answer "nothing has changed", so it is refused rather than believed. */
|
||||
public function testIfInStateIsRefusedRatherThanAlwaysSatisfied(): void
|
||||
{
|
||||
$this->expectException(MethodException::class);
|
||||
|
||||
$this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'ifInState' => 'fixed',
|
||||
'destroy' => [],
|
||||
], $this->context());
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $create
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function create(array $create): array
|
||||
{
|
||||
return $this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'create' => $create,
|
||||
], $this->context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $properties
|
||||
*/
|
||||
private function createdEvent(array $properties, ?Calendar $calendar = null): CalendarEvent
|
||||
{
|
||||
$properties['calendarId'] = (string) ($calendar ?? $this->calendar)->id;
|
||||
|
||||
$result = $this->create(['new' => $properties]);
|
||||
|
||||
self::assertArrayNotHasKey('new', $result['notCreated'] instanceof \stdClass ? [] : $result['notCreated'], 'the create was refused');
|
||||
|
||||
$event = $this->events->findOneForUser($this->user, (int) $result['created']['new']['id']);
|
||||
|
||||
self::assertInstanceOf(CalendarEvent::class, $event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/** A calendar that genuinely mirrors a remote — role and remoteId both, which is what isSynced() asks. */
|
||||
private function mirroredCalendar(): Calendar
|
||||
{
|
||||
$calendar = $this->seedCalendar('Mirrored');
|
||||
$calendar->role = CalendarRole::Remote;
|
||||
$calendar->remoteId = 'remote-'.uniqid('', true);
|
||||
$this->em->flush();
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
|
||||
/** A JSCalendar LocalDateTime on the fixture's day. */
|
||||
private function local(int $hour): string
|
||||
{
|
||||
return $this->day->setTime($hour, 0)->format('Y-m-d\TH:i:s');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Jmap\Method\Calendar;
|
||||
|
||||
use App\Jmap\Method\Calendar\CalendarGetMethod;
|
||||
use App\Jmap\Protocol\Exception\MethodException;
|
||||
use App\Jmap\Protocol\JmapContext;
|
||||
|
||||
/**
|
||||
* Calendar/get answers with the user's calendars and with nobody else's.
|
||||
*
|
||||
* A plMail Calendar is user-scoped — there is no per-account binding the way a
|
||||
* Mailbox has one — so the only thing standing between one user's calendar list
|
||||
* and another's is that every lookup here is scoped to the owner. A lookup by
|
||||
* id alone would be indistinguishable in every other respect: it returns a real
|
||||
* calendar, with a real name, and nothing in the response says whose.
|
||||
*
|
||||
* The second claim is about which account serves them. Calendars belong to the
|
||||
* user, so publishing them under every connected account would put one calendar
|
||||
* under three accountIds; a client keys objects by (accountId, id) and would
|
||||
* draw it three times. One account serves them and the rest answer
|
||||
* accountNotSupportedByMethod.
|
||||
*/
|
||||
final class CalendarGetMethodTest extends CalendarMethodTestCase
|
||||
{
|
||||
private CalendarGetMethod $method;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->method = self::getContainer()->get(CalendarGetMethod::class);
|
||||
}
|
||||
|
||||
public function testItListsTheUsersOwnCalendars(): void
|
||||
{
|
||||
$this->seedCalendar('Work');
|
||||
$this->seedCalendar('Personal');
|
||||
|
||||
self::assertSame(['Work', 'Personal'], $this->names($this->all()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure this exists for: a lookup that forgot the owner returns a
|
||||
* calendar that reads exactly like one of the user's own.
|
||||
*/
|
||||
public function testAnotherUsersCalendarIsNotListed(): void
|
||||
{
|
||||
$this->seedCalendar('Work');
|
||||
$this->seedCalendar('Theirs', false, $this->otherUser());
|
||||
|
||||
self::assertSame(['Work'], $this->names($this->all()));
|
||||
}
|
||||
|
||||
/**
|
||||
* And asking for it by id is notFound rather than forbidden or an error:
|
||||
* both other answers confirm the id exists.
|
||||
*/
|
||||
public function testAnotherUsersCalendarAskedForByIdIsNotFound(): void
|
||||
{
|
||||
$theirs = $this->seedCalendar('Theirs', false, $this->otherUser());
|
||||
|
||||
$result = $this->method->handle([
|
||||
'accountId' => $this->accountId(),
|
||||
'ids' => [(string) $theirs->id],
|
||||
], $this->context());
|
||||
|
||||
self::assertSame([], $result['list']);
|
||||
self::assertSame([(string) $theirs->id], $result['notFound']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only calendar says so through myRights, which is where every JMAP
|
||||
* client already looks — rather than through a second flag that a client
|
||||
* would have to know to consult.
|
||||
*/
|
||||
public function testAReadOnlyCalendarWithholdsTheWriteRights(): void
|
||||
{
|
||||
$this->seedCalendar('Mirror', true);
|
||||
|
||||
$rights = $this->all()['list'][0]['myRights'];
|
||||
|
||||
self::assertTrue($rights['mayReadItems']);
|
||||
self::assertFalse($rights['mayAddItems']);
|
||||
self::assertFalse($rights['mayUpdateAll']);
|
||||
self::assertFalse($rights['mayRemoveItems']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A second mail account is not a second calendar list. Serving the same
|
||||
* calendars from both would publish each one twice, under two accountIds
|
||||
* and with the same id — which a client has no way to recognise as one
|
||||
* calendar.
|
||||
*/
|
||||
public function testASecondAccountDoesNotAlsoServeTheCalendars(): void
|
||||
{
|
||||
$this->seedCalendar('Work');
|
||||
|
||||
$second = $this->secondAccount();
|
||||
|
||||
$this->expectException(MethodException::class);
|
||||
|
||||
$this->method->handle([
|
||||
'accountId' => (string) $second->id,
|
||||
'ids' => null,
|
||||
], new JmapContext($this->user));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function all(): array
|
||||
{
|
||||
return $this->method->handle(['accountId' => $this->accountId()], $this->context());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $result
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function names(array $result): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
foreach ($result['list'] as $calendar) {
|
||||
$names[] = (string) $calendar['name'];
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Jmap\Method\Calendar;
|
||||
|
||||
use App\Domain\Enum\Calendar\CalendarRole;
|
||||
use App\Entity\Calendar\Calendar;
|
||||
use App\Entity\Calendar\CalendarEvent;
|
||||
use App\Entity\User\User;
|
||||
use App\Service\Calendar\CalendarEventWriter;
|
||||
use App\Tests\Jmap\JmapTestCase;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* The world the calendar methods run against: the JMAP fixture's user and mail
|
||||
* account, plus calendars and events written the way the application writes
|
||||
* them.
|
||||
*
|
||||
* Events are seeded through CalendarEventWriter rather than by assigning
|
||||
* columns, and that is not a convenience — an event with no occurrence rows is
|
||||
* an event no calendar view can see, so a fixture that skipped the writer would
|
||||
* let a broken query pass by testing against data the application could never
|
||||
* produce.
|
||||
*
|
||||
* Times are relative to now. RecurrenceMaterialiser only writes occurrences
|
||||
* inside a horizon around the current instant, so a fixture pinned to a literal
|
||||
* date is a suite that starts failing on a particular morning.
|
||||
*/
|
||||
abstract class CalendarMethodTestCase extends JmapTestCase
|
||||
{
|
||||
protected CalendarEventWriter $writer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->writer = self::getContainer()->get(CalendarEventWriter::class);
|
||||
}
|
||||
|
||||
/** Midnight UTC in a few days' time, the anchor every fixture hangs off. */
|
||||
protected function baseDay(): DateTimeImmutable
|
||||
{
|
||||
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))
|
||||
->modify('+3 days')
|
||||
->setTime(0, 0);
|
||||
}
|
||||
|
||||
protected function seedCalendar(string $name, bool $isReadOnly = false, ?User $user = null): Calendar
|
||||
{
|
||||
$calendar = new Calendar();
|
||||
$calendar->usr = $user ?? $this->user;
|
||||
$calendar->name = $name;
|
||||
$calendar->role = CalendarRole::Custom;
|
||||
$calendar->timeZone = 'UTC';
|
||||
$calendar->isReadOnly = $isReadOnly;
|
||||
|
||||
$this->em->persist($calendar);
|
||||
$this->em->flush();
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $recurrenceRule
|
||||
*/
|
||||
protected function seedEvent(
|
||||
Calendar $calendar,
|
||||
string $title,
|
||||
DateTimeImmutable $startsAt,
|
||||
string $duration = '+1 hour',
|
||||
?array $recurrenceRule = null,
|
||||
): CalendarEvent {
|
||||
$event = new CalendarEvent();
|
||||
$event->uid = sprintf('%s-%s@plmail.test', str_replace(' ', '-', strtolower($title)), uniqid('', true));
|
||||
|
||||
$this->writer->write(
|
||||
event: $event,
|
||||
calendar: $calendar,
|
||||
user: $calendar->usr ?? $this->user,
|
||||
title: $title,
|
||||
startsAt: $startsAt,
|
||||
endsAt: $startsAt->modify($duration),
|
||||
timeZone: 'UTC',
|
||||
recurrenceRule: $recurrenceRule,
|
||||
);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* A second user with their own account and calendar — the "somebody else"
|
||||
* every isolation assertion here needs.
|
||||
*/
|
||||
protected function otherUser(): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'calendar-other-'.uniqid('', true).'@example.test';
|
||||
$user->nameFirst = 'Other';
|
||||
$user->nameLast = 'Person';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = 'x';
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many occurrence rows an event has, read from the database rather than
|
||||
* from the entity's collection — the collection can be populated in memory
|
||||
* by the write that just happened, and the claim being made is that the
|
||||
* rows exist.
|
||||
*/
|
||||
protected function occurrenceCount(CalendarEvent $event): int
|
||||
{
|
||||
return (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM calendar_event_occurrence WHERE event_id = :eventId',
|
||||
['eventId' => (int) $event->id],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Jmap\Session;
|
||||
|
||||
use App\Jmap\Method\MethodRegistry;
|
||||
use App\Jmap\Protocol\Capability;
|
||||
use App\Jmap\Session\SessionBuilder;
|
||||
use App\Tests\Jmap\JmapTestCase;
|
||||
|
||||
/**
|
||||
* Calendars are advertised under a vendor URN, and under exactly one account.
|
||||
*
|
||||
* **Not "urn:ietf:params:jmap:calendars".** JMAP for Calendars is an unratified
|
||||
* draft whose object shape is still moving, so claiming its URN would promise a
|
||||
* contract this server cannot hold and a client written against a later revision
|
||||
* would break on. The push extension made the same call for the same reason, and
|
||||
* the test asserts the vendor spelling rather than merely "some calendar
|
||||
* capability" — a well-meaning change to the standard URN is exactly the edit
|
||||
* this is here to stop.
|
||||
*
|
||||
* The second claim is arithmetic rather than taste. A plMail Calendar belongs to
|
||||
* the user, a JMAP account is one mail account, and a client keys every object
|
||||
* by (accountId, id) — so advertising calendars on all three of a user's
|
||||
* accounts would draw each calendar three times with no way to tell they are
|
||||
* one.
|
||||
*/
|
||||
final class CalendarCapabilityTest extends JmapTestCase
|
||||
{
|
||||
private SessionBuilder $sessions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->sessions = self::getContainer()->get(SessionBuilder::class);
|
||||
}
|
||||
|
||||
public function testTheSessionAdvertisesTheVendorCalendarUrn(): void
|
||||
{
|
||||
$session = $this->sessions->build($this->user);
|
||||
|
||||
self::assertArrayHasKey('urn:plmail:params:jmap:calendars', $session['capabilities']);
|
||||
self::assertArrayNotHasKey(
|
||||
'urn:ietf:params:jmap:calendars',
|
||||
$session['capabilities'],
|
||||
'the draft URN promises a shape that is still moving',
|
||||
);
|
||||
}
|
||||
|
||||
/** A capability a client may not declare in "using" is a capability it cannot call. */
|
||||
public function testAClientMayDeclareItInUsing(): void
|
||||
{
|
||||
self::assertContains(Capability::CALENDARS, Capability::SUPPORTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* And a capability nothing can serve is worse than no capability, because a
|
||||
* client discovers it by calling.
|
||||
*
|
||||
* Methods reach the registry by DI tag, so a class that exists, compiles and
|
||||
* is fully tested is still unreachable if autoconfiguration stops applying to
|
||||
* its directory — a failure that shows up as unknownMethod at runtime and
|
||||
* nowhere in a suite that calls the classes directly.
|
||||
*/
|
||||
public function testEveryAdvertisedMethodIsRegistered(): void
|
||||
{
|
||||
$registry = self::getContainer()->get(MethodRegistry::class);
|
||||
|
||||
foreach (['Calendar/get', 'CalendarEvent/get', 'CalendarEvent/query', 'CalendarEvent/set'] as $name) {
|
||||
self::assertNotNull($registry->get($name), sprintf('"%s" is advertised but not registered', $name));
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheCalendarAccountIsTheOneTheSessionNamesAsPrimary(): void
|
||||
{
|
||||
$session = $this->sessions->build($this->user);
|
||||
|
||||
self::assertSame($this->accountId(), $session['primaryAccounts'][Capability::CALENDARS]);
|
||||
self::assertArrayHasKey(
|
||||
Capability::CALENDARS,
|
||||
$session['accounts'][$this->accountId()]['accountCapabilities'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The other accounts stay mail-only. Without this the same calendar is
|
||||
* published once per connected account, under the same id, and a client
|
||||
* draws it as many times as the user has mailboxes.
|
||||
*/
|
||||
public function testASecondAccountDoesNotAlsoAdvertiseCalendars(): void
|
||||
{
|
||||
$second = $this->secondAccount();
|
||||
|
||||
$session = $this->sessions->build($this->user);
|
||||
|
||||
self::assertArrayNotHasKey(
|
||||
Capability::CALENDARS,
|
||||
$session['accounts'][(string) $second->id]['accountCapabilities'],
|
||||
);
|
||||
self::assertArrayHasKey(
|
||||
Capability::MAIL,
|
||||
$session['accounts'][(string) $second->id]['accountCapabilities'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The get limit is advertised because it is lower than the Session's global
|
||||
* maxObjectsInGet: a client obeying 500 would otherwise meet a
|
||||
* requestTooLarge it was told not to expect.
|
||||
*/
|
||||
public function testTheGetLimitIsStatedRatherThanDiscovered(): void
|
||||
{
|
||||
$session = $this->sessions->build($this->user);
|
||||
$calendars = $session['accounts'][$this->accountId()]['accountCapabilities'][Capability::CALENDARS];
|
||||
|
||||
self::assertLessThan($session['capabilities'][Capability::CORE]['maxObjectsInGet'], $calendars['maxEventsInGet']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user