feat(mail): show the reader's own categories as inbox tabs

A category the reader made is a tab beside the five, shown while it holds mail
(or is being looked at) and otherwise only as a drop target. A conversation is
in exactly one tab: its own when it has been filed in one, else the built-in
one, so a carrier's notice is listed in Shipping and no longer in Promotions.
The grouped counts, the new-mail dots, the sender hint, the select-all view and
the counts endpoint all key a tab the same way.

InboxTab is the one value for "a tab", whose key for a built-in is exactly what
it always was. InboxTabs resolves a key against the asking reader's own
categories, answers a key that cannot be custom without a query, and reads the
categories once per request. Another reader's category, or a deleted one, is no
tab and opens the inbox like any unknown key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
maniandClaude Sonnet 5.5 committed 2026-10-12 14:32:13 +02:00
1 parent 34ffa5590a
commit b2b9a90840
11 files changed
+1067 -83

No files matched your search

+30 -11
View File
@@ -3,6 +3,7 @@
namespace App\Controller\Mail;
use App\Controller\ChecksCsrf;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ListSortOrder;
use App\Domain\Enum\Mail\MessageCategory;
@@ -22,6 +23,7 @@ use App\Security\Voter\OwnershipVoter;
use App\Service\Ai\ThreadSummariser;
use App\Service\Ai\ThreadSummaryStore;
use App\Service\Ai\ThreadTranscript;
use App\Service\Mail\InboxTabs;
use App\Service\Mail\NewMailMarkers;
use App\Service\Mail\SidebarCounts;
use App\Service\Mail\ThreadListRenderer;
@@ -53,6 +55,7 @@ final class MailController extends AbstractController
private readonly ThreadSummaryStore $summaries,
private readonly ThreadRows $threadRows,
private readonly ThreadSenderFacts $senderFacts,
private readonly InboxTabs $inboxTabs,
)
{
}
@@ -214,8 +217,14 @@ final class MailController extends AbstractController
// ValueError out of an enum here surfaced as a 500 on ?tab=quatsch.
// Falling back to Primary shows the inbox, which is what the person
// following that link was after.
$tab = MessageCategory::tryFrom((string) $request->query->get('tab', ''))
?? MessageCategory::Primary;
//
// Resolved against the reader's OWN tabs as well as the five: a key for
// another person's category, or for one since deleted, is no tab here
// and takes the same fall-back — see InboxTabs::resolve().
$tab = $user instanceof User
? $this->inboxTabs->resolve($user, (string) $request->query->get('tab', ''))
: null;
$tab ??= InboxTab::builtIn(MessageCategory::Primary);
// One list, for somebody who has switched the tabs off (#29). `?tab=`
// is then a link from before the switch, or from a bookmark, and it
@@ -257,10 +266,15 @@ final class MailController extends AbstractController
// shows while it holds anything at all, and the tab being LOOKED AT
// stays even when its last thread just left, so the ground does not
// vanish underfoot; it disappears on the next natural navigation.
//
// The reader's own tabs follow the five and obey the same rule: one
// shows while it holds mail, or while it is being looked at.
$allTabs = $tabbed && $user instanceof User ? $this->inboxTabs->allFor($user) : [];
$tabs = false === $tabbed ? [] : array_values(array_filter(
MessageCategory::cases(),
static fn (MessageCategory $case): bool => MessageCategory::Primary === $case
|| $case === $tab
$allTabs,
static fn (InboxTab $case): bool => MessageCategory::Primary === $case->builtIn
|| $case->value === $tab?->value
|| ($tabTotals[$case->value] ?? 0) > 0,
));
@@ -273,9 +287,11 @@ final class MailController extends AbstractController
// Forums stays empty forever because nothing can be dropped there until
// something is already there. These render as ghost tabs, visible only
// while a drag is in flight. See mail/inbox.html.twig.
$shownKeys = array_map(static fn (InboxTab $shown): string => $shown->value, $tabs);
$dropTabs = false === $tabbed ? [] : array_values(array_filter(
MessageCategory::cases(),
static fn (MessageCategory $case): bool => false === in_array($case, $tabs, true),
$allTabs,
static fn (InboxTab $case): bool => false === in_array($case->value, $shownKeys, true),
));
$this->threadRows->preload($threads);
@@ -770,10 +786,13 @@ final class MailController extends AbstractController
$tabbed = $countsUser instanceof User && true === $countsUser->categorySorting->tabs;
$tabUnread = $tabbed ? $this->threadRepository->countUnreadByCategoryForUnifiedInbox($countsUser) : [];
foreach ($tabbed ? MessageCategory::cases() : [] as $category) {
$payload[NewMailMarkers::categoryKey($category)] = $newMail->forCategory($category);
$payload[NewMailMarkers::categorySendersKey($category)] = implode(', ', $newMail->sendersForCategory($category));
$payload['category:' . $category->value] = $tabUnread[$category->value] ?? 0;
// The reader's own tabs ride on the same payload as the five, under the
// same three keys: the strip patches itself by key, and a tab drawn with
// no matching key here is one whose dot and number never update.
foreach ($tabbed && $countsUser instanceof User ? $this->inboxTabs->allFor($countsUser) : [] as $tab) {
$payload[NewMailMarkers::categoryKey($tab)] = $newMail->forTab($tab);
$payload[NewMailMarkers::categorySendersKey($tab)] = implode(', ', $newMail->sendersForTab($tab));
$payload['category:' . $tab->value] = $tabUnread[$tab->value] ?? 0;
}
// forRoleBadge(), not forRole(): Trash and Drafts show a total rather
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO\Mail;
use App\Domain\Enum\Mail\MessageCategory;
/**
* One tab of the inbox: either one of the five built-in categories, or one the
* reader made themselves.
*
* The two live in different columns on purpose — a built-in is
* MessageThread::$category, whose enum mapping JMAP's closed vocabulary is built
* on, and a reader's own is MessageThread::$userCategory — so there is no single
* type that holds both, and every surface that names "a tab" would otherwise
* grow its own union. This is the one place that does.
*
* `value` is the key, and it is the ONE string every surface agrees on: the
* `?tab=` in the URL, the `data-dnd-category` a conversation is dropped on, the
* keys the counts endpoint patches the strip by. A built-in's key is exactly
* what it was before custom tabs existed — a bookmark, a cached page or an open
* tab must not change meaning — and a custom key is `c` and the category's id,
* which no built-in name can collide with (none starts `c` and ends in digits).
*
* It is named `value` so the templates, which read `tabCase.value` of an enum
* case today, read it off this unchanged.
*/
final readonly class InboxTab
{
/** What a custom key starts with, before the category's id. */
public const string CUSTOM_PREFIX = 'c';
private function __construct(
public string $value,
public ?MessageCategory $builtIn,
public ?int $categoryId,
public ?string $name,
) {
}
public static function builtIn(MessageCategory $category): self
{
return new self($category->value, $category, null, null);
}
public static function custom(int $categoryId, string $name): self
{
return new self(self::CUSTOM_PREFIX . $categoryId, null, $categoryId, $name);
}
/**
* The tab a key names, or null when it names none the reader has.
*
* Null rather than a throw because the key arrives in a URL anybody can
* edit, and a stale or mistyped one is a link from before something was
* deleted — it opens the inbox, the same answer an unknown built-in gets.
*
* An id is the reader's only if it is in $namesById. That lookup is the
* ownership check: a custom key for somebody else's category, or one that
* was deleted, simply is not a tab here.
*
* @param array<int,string> $namesById the reader's own categories, id => name
*/
public static function parse(string $key, array $namesById): ?self
{
$category = MessageCategory::tryFrom($key);
if (null !== $category) {
return self::builtIn($category);
}
if (false === self::looksCustom($key, $match)) {
return null;
}
$id = (int) $match[1];
if (false === array_key_exists($id, $namesById)) {
return null;
}
return self::custom($id, $namesById[$id]);
}
/**
* Whether a key has the shape of a custom tab's — the cheap test that lets a
* caller skip looking up the reader's categories for a key that could not
* name one. A built-in name, an empty string and a mistyped URL are all
* answered without touching the database.
*
* Digits only, and no leading sign or zero games: `c1x`, `c-12` and `C12` are
* not keys this class ever produced, so none of them is a tab.
*
* @param array<int,string>|null $match filled with the regex match, so parse()
* reads the id without matching twice
*/
public static function looksCustom(string $key, ?array &$match = null): bool
{
return 1 === preg_match('/^' . self::CUSTOM_PREFIX . '([1-9][0-9]*)$/', $key, $match);
}
public function isCustom(): bool
{
return null !== $this->categoryId;
}
/** The same tab, whatever it has since been renamed. */
public function is(self $other): bool
{
return $this->value === $other->value;
}
}
+80 -58
View File
@@ -8,6 +8,7 @@ use App\Domain\DTO\ParsedSearchQuery;
use App\Domain\Enum\Ai\SemanticSkipReason;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ListSortOrder;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Enum\Mail\SearchSortOrder;
use App\Entity\Label\Label;
@@ -135,7 +136,7 @@ class MessageThreadRepository extends ServiceEntityRepository
* tabs off — see CategorySorting::$tabs. Null and not a default argument,
* so that every caller has to say which of the two it means.
*/
public function findForUnifiedInbox(UserInterface $user, ?MessageCategory $category, int $page = 1, int $perPage = 50, ListSortOrder $sort = ListSortOrder::Newest, bool $unreadOnly = false): array
public function findForUnifiedInbox(UserInterface $user, MessageCategory|InboxTab|null $category, int $page = 1, int $perPage = 50, ListSortOrder $sort = ListSortOrder::Newest, bool $unreadOnly = false): array
{
$offset = ($page - 1) * $perPage;
@@ -166,18 +167,81 @@ class MessageThreadRepository extends ServiceEntityRepository
* count so the two cannot come to mean different things — a pager offering
* a page the list does not have is what that looks like.
*/
private function narrowToCategory(QueryBuilder $qb, ?MessageCategory $category): void
private function narrowToCategory(QueryBuilder $qb, MessageCategory|InboxTab|null $category): void
{
if (null === $category) {
return;
}
$qb->andWhere('t.category = :category')
->setParameter('category', $category);
$tab = $category instanceof MessageCategory ? InboxTab::builtIn($category) : $category;
// A thread is in exactly ONE tab: the reader's own when it has been
// filed in one, else the built-in one. So a custom tab is a plain match
// on the reader's column, and a built-in tab must also exclude whatever
// has left it for one of the reader's — otherwise a carrier's notice is
// listed in Promotions AND in Shipping, and the tab counts stop adding
// up to the inbox.
if (true === $tab->isCustom()) {
$qb->andWhere('IDENTITY(t.userCategory) = :userCategory')
->setParameter('userCategory', $tab->categoryId);
return;
}
$qb->andWhere('t.userCategory IS NULL')
->andWhere('t.category = :category')
->setParameter('category', $tab->builtIn);
}
/**
* Which tab a grouped row belongs to: the reader's own when the thread is
* filed in one, else the built-in category.
*
* One definition, because the three grouped counts and the sender hint must
* key a tab identically or the strip's numbers and its dots describe
* different tabs. The key is InboxTab's, which for a built-in is the value
* it always was.
*/
private function tabKeyOf(mixed $category, mixed $userCategoryId): string
{
if (null !== $userCategoryId) {
return InboxTab::CUSTOM_PREFIX . (int) $userCategoryId;
}
if ($category instanceof MessageCategory) {
return $category->value;
}
return (string) $category;
}
/**
* Fold grouped rows into tab => count.
*
* Added up rather than assigned: the query groups by the built-in category
* AND the reader's tab, so one of the reader's tabs arrives as several rows
* — one per built-in category its threads happen to have — and keeping the
* last of them would report a fraction of the tab.
*
* @param iterable<array<string,mixed>> $rows
*
* @return array<string,int>
*/
private function foldByTab(iterable $rows, string $countField): array
{
$counts = [];
foreach ($rows as $row) {
$key = $this->tabKeyOf($row['category'], $row['userCategory'] ?? null);
$counts[$key] = ($counts[$key] ?? 0) + (int) $row[$countField];
}
return $counts;
}
/** Same two joins as findForUnifiedInbox(), so the same reason to keep it. */
public function countForUnifiedInbox(UserInterface $user, ?MessageCategory $category, bool $unreadOnly = false): int
public function countForUnifiedInbox(UserInterface $user, MessageCategory|InboxTab|null $category, bool $unreadOnly = false): int
{
// COUNT(DISTINCT t.id), not select-DISTINCT: the label join is to-many,
// and ->distinct() would put the DISTINCT on the aggregate rather than
@@ -221,33 +285,20 @@ class MessageThreadRepository extends ServiceEntityRepository
public function countByCategoryForUnifiedInbox(UserInterface $user): array
{
$qb = $this->createQueryBuilder('t')
->select('t.category AS category', 'COUNT(DISTINCT t.id) AS threadCount')
->select('t.category AS category', 'IDENTITY(t.userCategory) AS userCategory', 'COUNT(DISTINCT t.id) AS threadCount')
->join('t.account', 'a')
->join('t.labels', 'l')
->where('a.usr = :user')
->andWhere('a.isActive = true')
->andWhere('l.role = :inbox')
->groupBy('t.category')
->addGroupBy('t.userCategory')
->setParameter('user', $user)
->setParameter('inbox', LabelRole::Inbox);
$this->excludeTrashed($qb);
$rows = $qb->getQuery()->getResult();
$counts = [];
foreach ($rows as $row) {
$categoryValue = $row['category'];
if ($categoryValue instanceof MessageCategory) {
$categoryValue = $categoryValue->value;
}
$counts[$categoryValue] = (int) $row['threadCount'];
}
return $counts;
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount');
}
/**
@@ -268,7 +319,7 @@ class MessageThreadRepository extends ServiceEntityRepository
public function countUnreadByCategoryForUnifiedInbox(UserInterface $user): array
{
$qb = $this->createQueryBuilder('t')
->select('t.category AS category', 'COUNT(DISTINCT t.id) AS threadCount')
->select('t.category AS category', 'IDENTITY(t.userCategory) AS userCategory', 'COUNT(DISTINCT t.id) AS threadCount')
->join('t.account', 'a')
->join('t.labels', 'l')
->where('a.usr = :user')
@@ -276,26 +327,13 @@ class MessageThreadRepository extends ServiceEntityRepository
->andWhere('l.role = :inbox')
->andWhere('t.unreadCount > 0')
->groupBy('t.category')
->addGroupBy('t.userCategory')
->setParameter('user', $user)
->setParameter('inbox', LabelRole::Inbox);
$this->excludeTrashed($qb);
$rows = $qb->getQuery()->getResult();
$counts = [];
foreach ($rows as $row) {
$categoryValue = $row['category'];
if ($categoryValue instanceof MessageCategory) {
$categoryValue = $categoryValue->value;
}
$counts[$categoryValue] = (int) $row['threadCount'];
}
return $counts;
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount');
}
/**
@@ -931,32 +969,21 @@ class MessageThreadRepository extends ServiceEntityRepository
public function countNewByCategoryForUnifiedInbox(UserInterface $user, \DateTimeImmutable $now): array
{
$qb = $this->createQueryBuilder('t')
->select('t.category AS category', 'COUNT(DISTINCT t.id) AS newCount')
->select('t.category AS category', 'IDENTITY(t.userCategory) AS userCategory', 'COUNT(DISTINCT t.id) AS newCount')
->join('t.account', 'a')
->join('t.labels', 'l')
->where('a.usr = :user')
->andWhere('a.isActive = true')
->andWhere('l.role = :inbox')
->groupBy('t.category')
->addGroupBy('t.userCategory')
->setParameter('user', $user)
->setParameter('inbox', LabelRole::Inbox);
$this->restrictToNew($qb, $now);
$this->excludeTrashed($qb);
$counts = [];
foreach ($qb->getQuery()->getResult() as $row) {
$category = $row['category'];
if ($category instanceof MessageCategory) {
$category = $category->value;
}
$counts[(string) $category] = (int) $row['newCount'];
}
return $counts;
return $this->foldByTab($qb->getQuery()->getResult(), 'newCount');
}
/**
@@ -986,6 +1013,7 @@ class MessageThreadRepository extends ServiceEntityRepository
->select(
't.id AS threadId',
't.category AS category',
'IDENTITY(t.userCategory) AS userCategory',
't.lastMessageAt AS arrivedAt',
'm.fromName AS fromName',
'm.fromAddress AS fromAddress',
@@ -1018,12 +1046,6 @@ class MessageThreadRepository extends ServiceEntityRepository
continue;
}
$category = $row['category'];
if ($category instanceof MessageCategory) {
$category = $category->value;
}
$name = trim((string) ($row['fromName'] ?? ''));
if ('' === $name) {
@@ -1031,7 +1053,7 @@ class MessageThreadRepository extends ServiceEntityRepository
}
$threads[$id] = [
'category' => (string) $category,
'category' => $this->tabKeyOf($row['category'], $row['userCategory'] ?? null),
'arrivedAt' => $row['arrivedAt'],
'name' => $name,
'at' => $at,
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\MessageCategory;
use App\Repository\Mail\InboxCategoryRepository;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* The tabs a reader has: the five built-in ones and the ones they made.
*
* One place that knows how a tab key in a URL, a drop target or a posted body
* becomes a tab, because each of those is untrusted input and each of them used
* to be `MessageCategory::tryFrom()`. The ownership check lives here too: a
* custom key resolves only against the categories THIS reader owns, so another
* person's category, or one that has since been deleted, is simply not a tab.
*
* NOBODY WHO NEVER MADE A CATEGORY PAYS FOR THIS. A key that could not be a
* custom one — every built-in name, an empty string, a mistyped URL — is
* answered without a query, and the reader's categories are read at most once
* per request however many callers ask. The inbox page has a query budget
* (ThreadListQueryBudgetTest) and this feature must not spend it on the people
* it is not for.
*
* Memoised for the request, and resettable for the reason NewMailMarkers is: a
* worker serves many readers one after another, and a memo that outlived its
* request would show the first reader's tabs to the second.
*/
final class InboxTabs implements ResetInterface
{
/** @var array<string, list<InboxTab>> user identifier => their own tabs */
private array $custom = [];
public function __construct(
private readonly InboxCategoryRepository $categories,
) {
}
public function reset(): void
{
$this->custom = [];
}
/**
* The reader's own tabs, in the order they arranged them.
*
* @return list<InboxTab>
*/
public function customFor(UserInterface $user): array
{
return $this->custom[$user->getUserIdentifier()] ??= $this->read($user);
}
/**
* Every tab the reader could be shown: the built-in five, then their own.
*
* @return list<InboxTab>
*/
public function allFor(UserInterface $user): array
{
return [
...array_map(InboxTab::builtIn(...), MessageCategory::cases()),
...$this->customFor($user),
];
}
/**
* The tab a key names for this reader, or null when it names none of theirs.
*/
public function resolve(UserInterface $user, string $key): ?InboxTab
{
$builtIn = MessageCategory::tryFrom($key);
if (null !== $builtIn) {
return InboxTab::builtIn($builtIn);
}
if (false === InboxTab::looksCustom($key)) {
return null;
}
$names = [];
foreach ($this->customFor($user) as $tab) {
$names[(int) $tab->categoryId] = (string) $tab->name;
}
return InboxTab::parse($key, $names);
}
/**
* @return list<InboxTab>
*/
private function read(UserInterface $user): array
{
$tabs = [];
foreach ($this->categories->findForUserOrdered($user) as $category) {
$tabs[] = InboxTab::custom((int) $category->id, (string) $category->name);
}
return $tabs;
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageCategory;
use App\Entity\Mail\MessageThread;
@@ -60,6 +61,7 @@ final readonly class ListViewResolver
public function __construct(
private MessageThreadRepository $threads,
private LabelRepository $labels,
private InboxTabs $tabs,
) {
}
@@ -109,8 +111,12 @@ final readonly class ListViewResolver
// all, delete" has to mean the list the server would have drawn —
// all of it for somebody without tabs, never Primary alone because
// the page sent an empty string (#29).
//
// The reader's own tabs count: the value is a tab key, and one of
// theirs resolves to their category's conversations, while a key
// that is not theirs falls back to Primary like any unknown one.
$category = true === $user->categorySorting->tabs
? MessageCategory::tryFrom($value) ?? MessageCategory::Primary
? $this->tabs->resolve($user, $value) ?? InboxTab::builtIn(MessageCategory::Primary)
: null;
return $this->threads->findForUnifiedInbox($user, $category, $page, self::CHUNK, unreadOnly: $unreadOnly);
+24 -4
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageCategory;
use App\Entity\Label\Label;
@@ -116,6 +117,17 @@ class NewMailMarkers implements ResetInterface
}
public function forCategory(MessageCategory $category): int
{
return $this->forTab(InboxTab::builtIn($category));
}
/**
* New conversations in one tab — a built-in one, or one the reader made.
*
* forCategory() above is this for a built-in, kept because every existing
* caller names one.
*/
public function forTab(InboxTab $tab): int
{
if (null === $this->byCategory) {
$user = $this->security->getUser();
@@ -125,7 +137,7 @@ class NewMailMarkers implements ResetInterface
: $this->threadRepository->countNewByCategoryForUnifiedInbox($user, $this->now());
}
return $this->byCategory[$category->value] ?? 0;
return $this->byCategory[$tab->value] ?? 0;
}
/**
@@ -139,6 +151,14 @@ class NewMailMarkers implements ResetInterface
* @return list<string> newest arrival first, at most three
*/
public function sendersForCategory(MessageCategory $category): array
{
return $this->sendersForTab(InboxTab::builtIn($category));
}
/**
* @return list<string> newest arrival first, at most three
*/
public function sendersForTab(InboxTab $tab): array
{
if (null === $this->sendersByCategory) {
$user = $this->security->getUser();
@@ -148,7 +168,7 @@ class NewMailMarkers implements ResetInterface
: $this->threadRepository->newSendersByCategoryForUnifiedInbox($user, $this->now());
}
return $this->sendersByCategory[$category->value] ?? [];
return $this->sendersByCategory[$tab->value] ?? [];
}
/**
@@ -216,7 +236,7 @@ class NewMailMarkers implements ResetInterface
* the string is built here rather than in both places, which is how
* "node:" keys once outlived the markup that read them.
*/
public static function categoryKey(MessageCategory $category): string
public static function categoryKey(MessageCategory|InboxTab $category): string
{
return 'new:category:' . $category->value;
}
@@ -226,7 +246,7 @@ class NewMailMarkers implements ResetInterface
* the unread badge keys: their values are STRINGS — joined sender names —
* and a patcher expecting a number must never be able to reach one.
*/
public static function categorySendersKey(MessageCategory $category): string
public static function categorySendersKey(MessageCategory|InboxTab $category): string
{
return 'senders:category:' . $category->value;
}
+19 -9
View File
@@ -86,6 +86,16 @@
forums: 'text-violet-600 dark:text-violet-400',
} %}
{# The tabs the reader made themselves have no identity colour or glyph of
their own — the five above are Gmail's, and a category is a name somebody
typed — so they share one neutral set. A folder because that is what a
tab you made is: somewhere you put things. Slate rather than an accent
tone, so a custom tab never reads as one of Gmail's. #}
{% set customIcon = 'M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z' %}
{% set customIconSolid = 'M19.5 21a3 3 0 0 0 3-3v-4.5a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3V18a3 3 0 0 0 3 3h15ZM1.5 10.146V6a3 3 0 0 1 3-3h5.379a2.25 2.25 0 0 1 1.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 0 1 3 3v1.146A4.483 4.483 0 0 0 19.5 9h-15a4.483 4.483 0 0 0-3 1.146Z' %}
{% set customTone = 'bg-slate-600 text-white' %}
{% set customIconTone = 'text-slate-600 dark:text-slate-400' %}
{# A lone Primary tab is a heading pretending to be a choice — when no
other category has mail, the whole row stays home. #}
{# What a tab link carries with it.
@@ -144,9 +154,9 @@
<nav class="relative flex text-sm overflow-x-auto overflow-y-hidden pb-px -mb-px [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-controller="ui--scroll-active">
{% for tabCase in tabs %}
{% set isActive = tabCase === tab %}
{% set tabName = ('tabs.' ~ tabCase.value)|trans %}
{% set newCount = newMail.forCategory(tabCase) %}
{% set isActive = tab is not null and tabCase.value == tab.value %}
{% set tabName = tabCase.isCustom ? tabCase.name : ('tabs.' ~ tabCase.value)|trans %}
{% set newCount = newMail.forTab(tabCase) %}
{# Rendered on every tab except the one being looked at. The tint answers "where
is the mail I cannot see"; the active tab is the one place on
@@ -167,8 +177,8 @@
know which of the five a given tab wears. #}
{% set tintable = false === isActive %}
{% set unreadHere = tintable ? (tab_unread[tabCase.value]|default(0)) : 0 %}
{% set iconTone = tabIconTones[tabCase.value]|default('') %}
{% set senderNames = newCount > 0 ? newMail.sendersForCategory(tabCase)|join(', ') : '' %}
{% set iconTone = tabIconTones[tabCase.value]|default(customIconTone) %}
{% set senderNames = newCount > 0 ? newMail.sendersForTab(tabCase)|join(', ') : '' %}
{# min-h rather than padding for the vertical size: the tab is
one line without a sender hint and two with one, and a
@@ -192,12 +202,12 @@
{% if isActive %}
<svg class="w-5 h-5 shrink-0{{ unreadHere > 0 ? ' ' ~ iconTone : '' }}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
{% if tintable %}data-tab-icon data-count-key="category:{{ tabCase.value }}" data-tone-class="{{ iconTone }}"{% endif %}>
<path fill-rule="evenodd" clip-rule="evenodd" d="{{ tabIconsSolid[tabCase.value] }}"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="{{ tabIconsSolid[tabCase.value]|default(customIconSolid) }}"/>
</svg>
{% else %}
<svg class="w-5 h-5 shrink-0{{ unreadHere > 0 ? ' ' ~ iconTone : '' }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"
{% if tintable %}data-tab-icon data-count-key="category:{{ tabCase.value }}" data-tone-class="{{ iconTone }}"{% endif %}>
<path stroke-linecap="round" stroke-linejoin="round" d="{{ tabIcons[tabCase.value] }}"/>
<path stroke-linecap="round" stroke-linejoin="round" d="{{ tabIcons[tabCase.value]|default(customIcon) }}"/>
</svg>
{% endif %}
@@ -258,7 +268,7 @@
carries the data-count-key the counts endpoint
emits, and the patcher rewrites text as well as
visibility. #}
<span class="ml-1.5 inline-flex align-middle">{{ newMarkers.new_pill(newCount, 'new:category:' ~ tabCase.value, tabName, tabTones[tabCase.value] ?? null) }}</span>
<span class="ml-1.5 inline-flex align-middle">{{ newMarkers.new_pill(newCount, 'new:category:' ~ tabCase.value, tabName, tabTones[tabCase.value] ?? customTone) }}</span>
</span>
{# Who the new mail is from — Gmail's second line.
@@ -303,7 +313,7 @@
class="flex items-center shrink-0 px-3 @xl:px-5 py-1.5 min-h-14 text-sm font-medium
text-ink-faint border-b-2 border-dashed border-transparent
whitespace-nowrap transition-colors"
>{{ ('tabs.' ~ tabCase.value)|trans }}</span>
>{{ tabCase.isCustom ? tabCase.name : ('tabs.' ~ tabCase.value)|trans }}</span>
{% endfor %}
</nav>
</div>
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageCategory;
use App\Entity\Mail\InboxCategory;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
/**
* The inbox of somebody who has a tab of their own.
*
* What is pinned is what the reader sees and can do with it: the tab exists
* while it holds mail and not before (the strip's own rule), it lists its own
* conversations and the built-in tab it left no longer does, an empty one is
* still somewhere to drop a conversation, and a key that is not theirs opens the
* inbox rather than another person's tab or an error.
*
* The counts endpoint is here because the strip patches itself from it after
* every sync, by key: a tab drawn with no matching key in that payload is a tab
* whose dot and number never update.
*/
final class InboxCustomTabTest extends WebTestCase
{
use SeedsMarkerFixtures;
private KernelBrowser $client;
private Connection $connection;
private InboxCategory $shipping;
protected function setUp(): void
{
$this->client = static::createClient();
$this->client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
$this->client->loginUser($this->user);
$this->shipping = $this->category($this->user, 'Shipping');
}
protected function tearDown(): void
{
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testACategoryHoldingMailIsATabOfItsOwnAndListsItsMail(): void
{
$this->filed('a carrier notice', $this->shipping);
$this->thread('an ordinary promotion', MessageCategory::Promotions, unread: 1);
$this->thread('a normal one', unread: 1);
$crawler = $this->client->request('GET', '/mail/inbox');
self::assertResponseIsSuccessful();
$strip = $crawler->filter('[data-list-region="tabs"]');
self::assertStringContainsString('Shipping', $strip->text(), 'a category holding mail has no tab');
self::assertContains('tab=c' . $this->shipping->id, $this->tabQueries($crawler));
$this->client->request('GET', '/mail/inbox?tab=c' . $this->shipping->id);
self::assertResponseIsSuccessful();
$own = (string) $this->client->getResponse()->getContent();
self::assertStringContainsString('a carrier notice', $own);
self::assertStringNotContainsString('an ordinary promotion', $own);
self::assertStringNotContainsString('a normal one', $own);
}
/**
* The conversation is in ONE tab. Promotions must not list the notice that
* has a tab of its own, though its built-in category is still Promotions —
* and it must still list the promotion that has none.
*/
public function testTheBuiltInTabNoLongerListsWhatLeftItForTheReadersOwn(): void
{
$this->filed('a carrier notice', $this->shipping, MessageCategory::Promotions);
$this->thread('an ordinary promotion', MessageCategory::Promotions, unread: 1);
$this->client->request('GET', '/mail/inbox?tab=promotions');
self::assertResponseIsSuccessful();
$promotions = (string) $this->client->getResponse()->getContent();
self::assertStringContainsString('an ordinary promotion', $promotions);
self::assertStringNotContainsString('a carrier notice', $promotions, 'a conversation is listed in two tabs');
}
public function testACategoryHoldingNothingHasNoTabButIsStillSomewhereToDrop(): void
{
$this->thread('a normal one', unread: 1);
$this->thread('a promo', MessageCategory::Promotions, unread: 1);
$crawler = $this->client->request('GET', '/mail/inbox');
self::assertResponseIsSuccessful();
self::assertNotContains('tab=c' . $this->shipping->id, $this->tabQueries($crawler), 'an empty category was drawn as a tab');
$ghost = $crawler->filter('[data-dnd-category="c' . $this->shipping->id . '"][data-dnd-ghost]');
self::assertCount(1, $ghost, 'an empty category cannot be dropped onto');
self::assertStringContainsString('Shipping', $ghost->text());
}
public function testAKeyThatIsNotTheirsOpensTheInboxRatherThanSomebodyElsesTab(): void
{
$stranger = $this->category($this->seedUser(), 'Not yours');
$this->thread('a normal one', unread: 1);
$this->client->request('GET', '/mail/inbox?tab=c' . $stranger->id);
self::assertResponseIsSuccessful();
self::assertStringContainsString('a normal one', (string) $this->client->getResponse()->getContent());
}
public function testTheCountsEndpointCarriesTheReadersTabForTheStripToPatch(): void
{
$this->filed('a carrier notice', $this->shipping, MessageCategory::Promotions, unread: 1);
$this->client->request('GET', '/mail/sidebar/counts');
self::assertResponseIsSuccessful();
$payload = json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
$key = 'c' . $this->shipping->id;
self::assertSame(1, $payload['category:' . $key] ?? null, 'the unread count of the tab is missing');
self::assertArrayHasKey('new:category:' . $key, $payload);
self::assertArrayHasKey('senders:category:' . $key, $payload);
// And the built-in it was filed away from is not counting it.
self::assertSame(0, $payload['category:promotions'] ?? null);
}
// ── fixture ──────────────────────────────────────────────────────────────
/** @return list<string> the query string of every tab link in the strip */
private function tabQueries(Crawler $crawler): array
{
return $crawler
->filter('[data-list-region="tabs"] a[href*="/mail/inbox"]')
->each(static fn (Crawler $node): string => (string) parse_url((string) $node->attr('href'), PHP_URL_QUERY));
}
private function filed(
string $subject,
InboxCategory $category,
MessageCategory $builtIn = MessageCategory::Promotions,
int $unread = 1,
): MessageThread {
$thread = $this->thread($subject, $builtIn, unread: $unread);
$thread->userCategory = $category;
$this->em->flush();
return $thread;
}
private function category(User $owner, string $name): InboxCategory
{
$category = new InboxCategory();
$category->usr = $owner;
$category->name = $name;
$this->em->persist($category);
$this->em->flush();
return $category;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Domain\DTO\Mail;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\MessageCategory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* What a tab IS, now that it is either one of the five built-ins or one the
* reader made.
*
* The key is the one string every surface agrees on — the URL's `?tab=`, the
* drop target a conversation is dragged onto, the keys the counts endpoint
* patches the strip by — so the two properties worth pinning are that a built-in
* key is EXACTLY what it was before this existed (nothing already bookmarked,
* cached or patched may change), and that a custom key can never be mistaken for
* a built-in one or for somebody else's category.
*/
final class InboxTabTest extends TestCase
{
public function testABuiltInTabKeepsTheKeyItAlwaysHad(): void
{
foreach (MessageCategory::cases() as $category) {
$tab = InboxTab::builtIn($category);
self::assertSame($category->value, $tab->value);
self::assertFalse($tab->isCustom());
self::assertSame($category, $tab->builtIn);
self::assertNull($tab->categoryId);
}
}
public function testACustomTabIsKeyedByItsIdAndCarriesItsName(): void
{
$tab = InboxTab::custom(12, 'Shipping');
self::assertSame('c12', $tab->value);
self::assertTrue($tab->isCustom());
self::assertSame(12, $tab->categoryId);
self::assertSame('Shipping', $tab->name);
self::assertNull($tab->builtIn);
}
/**
* @param array<int,string> $namesById the reader's own categories
*/
#[DataProvider('parseCases')]
public function testParsing(string $key, array $namesById, ?string $expectedValue): void
{
self::assertSame($expectedValue, InboxTab::parse($key, $namesById)?->value);
}
/**
* @return iterable<string, array{string, array<int,string>, ?string}>
*/
public static function parseCases(): iterable
{
$own = [12 => 'Shipping', 40 => 'Receipts'];
yield 'a built-in' => ['promotions', $own, 'promotions'];
yield 'one of the reader\'s own' => ['c12', $own, 'c12'];
yield 'another one of theirs' => ['c40', $own, 'c40'];
// The two that matter: an id the reader does not own is somebody else's
// category or a deleted one, and either way it is not a tab they have.
yield 'a custom id that is not theirs' => ['c99', $own, null];
yield 'a custom key with nothing of their own' => ['c12', [], null];
yield 'not a tab at all' => ['quatsch', $own, null];
yield 'empty' => ['', $own, null];
yield 'the prefix alone' => ['c', $own, null];
yield 'a malformed id' => ['c1x', $own, null];
yield 'a negative id' => ['c-12', $own, null];
yield 'a built-in name wearing the prefix' => ['cprimary', $own, null];
yield 'the prefix in capitals' => ['C12', $own, null];
}
public function testTwoTabsAreTheSameTabWhenTheirKeysAre(): void
{
self::assertTrue(InboxTab::custom(12, 'Shipping')->is(InboxTab::custom(12, 'Renamed since')));
self::assertFalse(InboxTab::custom(12, 'Shipping')->is(InboxTab::custom(13, 'Shipping')));
self::assertFalse(InboxTab::custom(12, 'Shipping')->is(InboxTab::builtIn(MessageCategory::Primary)));
self::assertTrue(InboxTab::builtIn(MessageCategory::Primary)->is(InboxTab::builtIn(MessageCategory::Primary)));
}
}
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository\Mail;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
use App\Entity\Mail\InboxCategory;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Repository\Mail\MessageThreadRepository;
use App\Service\Label\ThreadLabelSynchronizer;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Every question the tab strip asks, answered for a reader who has a tab of
* their own.
*
* ONE FIXTURE, CHOSEN TO BE THE AWKWARD CASE. A carrier's notice has the
* built-in category Promotions AND sits in the reader's Shipping tab; beside it
* is a genuine promotion whose built-in category is also Promotions and which is
* in no tab of the reader's. A query that read only the built-in column would
* put both in Promotions and leave Shipping empty; one that read only the
* reader's would drop every ordinary promotion. A thread is in exactly one tab,
* and which one is "the reader's, if it has one".
*
* The built-in tabs are asserted by name as well as the new one, so a refactor
* that made Shipping work by breaking Promotions cannot pass.
*
* Hand-derived figures, from the fixture below:
*
* thread built-in reader's tab unread new
* carrierNew promotions Shipping yes yes
* promoNew promotions - yes yes
* primaryRead primary - no no
* updateRead updates Shipping no no
*
* Shipping = {carrierNew, updateRead} -> 2 threads, 1 unread, 1 new
* Promotions = {promoNew} -> 1 thread, 1 unread, 1 new
* Primary = {primaryRead} -> 1 thread, 0 unread, 0 new
* Updates = {} -> updateRead left it for Shipping
*/
final class InboxTabQueriesTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private MessageThreadRepository $repository;
private User $user;
private Account $account;
private Label $inbox;
private InboxCategory $shipping;
private MessageThread $carrierNew;
private MessageThread $promoNew;
private MessageThread $primaryRead;
private MessageThread $updateRead;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->repository = $container->get(MessageThreadRepository::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount($this->user);
$this->inbox = $this->seedInboxLabel();
$this->shipping = new InboxCategory();
$this->shipping->usr = $this->user;
$this->shipping->name = 'Shipping';
$this->em->persist($this->shipping);
$this->em->flush();
$this->carrierNew = $this->inboxThread('carrier, new', MessageCategory::Promotions, $this->shipping, unread: 1);
$this->promoNew = $this->inboxThread('promo, new', MessageCategory::Promotions, null, unread: 1);
$this->primaryRead = $this->inboxThread('primary, read', MessageCategory::Primary, null, unread: 0);
$this->updateRead = $this->inboxThread('update, read', MessageCategory::Updates, $this->shipping, unread: 0);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testTheReadersOwnTabHoldsWhatWasFiledInItWhateverItsBuiltInCategory(): void
{
$shipping = InboxTab::custom((int) $this->shipping->id, 'Shipping');
self::assertSame(
['carrier, new', 'update, read'],
$this->subjects($this->repository->findForUnifiedInbox($this->user, $shipping)),
);
self::assertSame(2, $this->repository->countForUnifiedInbox($this->user, $shipping));
}
public function testABuiltInTabNoLongerHoldsWhatLeftItForTheReadersOwn(): void
{
$promotions = InboxTab::builtIn(MessageCategory::Promotions);
self::assertSame(['promo, new'], $this->subjects($this->repository->findForUnifiedInbox($this->user, $promotions)));
self::assertSame(1, $this->repository->countForUnifiedInbox($this->user, $promotions));
self::assertSame(
['primary, read'],
$this->subjects($this->repository->findForUnifiedInbox($this->user, InboxTab::builtIn(MessageCategory::Primary))),
);
self::assertSame(
[],
$this->subjects($this->repository->findForUnifiedInbox($this->user, InboxTab::builtIn(MessageCategory::Updates))),
'updateRead is in Shipping, so Updates is empty',
);
}
/**
* Every existing caller passes the enum, and every one of them must keep
* getting what it always got — narrowed now, since a thread in the reader's
* tab is not in the built-in one.
*/
public function testTheEnumStillWorksAsATabArgument(): void
{
self::assertSame(
['promo, new'],
$this->subjects($this->repository->findForUnifiedInbox($this->user, MessageCategory::Promotions)),
);
self::assertSame(1, $this->repository->countForUnifiedInbox($this->user, MessageCategory::Promotions));
}
public function testNoTabAtAllIsTheWholeInbox(): void
{
self::assertSame(4, $this->repository->countForUnifiedInbox($this->user, null));
}
public function testTheGroupedCountsKeyTheReadersTabAndAddItsThreadsUpAcrossBuiltIns(): void
{
$key = 'c' . $this->shipping->id;
// carrierNew (promotions) and updateRead (updates) are one tab's two
// threads: the figure is 2, not a last-row-wins 1.
self::assertEquals(
[$key => 2, 'promotions' => 1, 'primary' => 1],
$this->sorted($this->repository->countByCategoryForUnifiedInbox($this->user)),
);
self::assertEquals(
[$key => 1, 'promotions' => 1],
$this->sorted($this->repository->countUnreadByCategoryForUnifiedInbox($this->user)),
);
self::assertEquals(
[$key => 1, 'promotions' => 1],
$this->sorted($this->repository->countNewByCategoryForUnifiedInbox($this->user, new DateTimeImmutable())),
);
}
public function testTheSendersHintNamesWhoWroteIntoTheReadersTab(): void
{
$senders = $this->repository->newSendersByCategoryForUnifiedInbox($this->user, new DateTimeImmutable());
self::assertSame(['Carrier Co'], $senders['c' . $this->shipping->id] ?? null);
self::assertSame(['Promo Co'], $senders['promotions'] ?? null);
}
// ── fixture ──────────────────────────────────────────────────────────────
/**
* @param array<string,int> $counts
*
* @return array<string,int>
*/
private function sorted(array $counts): array
{
ksort($counts);
return $counts;
}
/**
* @param list<MessageThread> $threads
*
* @return list<string>
*/
private function subjects(array $threads): array
{
$subjects = array_map(static fn (MessageThread $thread): string => (string) $thread->subject, $threads);
sort($subjects);
return $subjects;
}
private function inboxThread(string $subject, MessageCategory $builtIn, ?InboxCategory $own, int $unread): MessageThread
{
$at = new DateTimeImmutable('-1 hour');
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = $subject;
$thread->normalizedSubject = mb_strtolower($subject);
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = $at;
$thread->category = $builtIn;
$thread->userCategory = $own;
$thread->unreadCount = $unread;
$thread->messageCount = 1;
$this->em->persist($thread);
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->subject = $subject;
$message->fromAddress = 'sender@example.test';
$message->fromName = str_contains($subject, 'carrier') ? 'Carrier Co' : 'Promo Co';
$message->receivedAt = $at;
$message->sentAt = $at;
$message->hasAttachments = false;
$message->flags = [];
$message->category = $builtIn;
$message->addLabel($this->inbox);
$thread->addMessage($message);
$this->em->persist($message);
$this->em->flush();
self::getContainer()->get(ThreadLabelSynchronizer::class)->sync($thread);
$this->em->flush();
return $thread;
}
private function seedInboxLabel(): Label
{
$label = new Label();
$label->usr = $this->user;
$label->name = 'Inbox';
$label->role = LabelRole::Inbox;
$label->isVisible = true;
$this->em->persist($label);
$this->em->flush();
return $label;
}
private function seedAccount(User $user): Account
{
$account = new Account();
$account->usr = $user;
$account->name = 'Tab fixture';
$account->email = 'tabs@example.test';
$account->username = uniqid('tabs-', true);
$account->imapHost = 'imap.example.test';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->authType = 'password';
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedUser(): User
{
$user = new User();
$user->email = 'tabs-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Tabs';
$user->nameLast = 'Fixture';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Mail\MessageCategory;
use App\Entity\Mail\InboxCategory;
use App\Entity\User\User;
use App\Repository\Mail\InboxCategoryRepository;
use App\Service\Mail\InboxTabs;
use PHPUnit\Framework\TestCase;
/**
* The cost and the privacy of resolving a tab.
*
* Cost: the inbox has a query budget, and somebody who never made a category
* must not pay for a feature they are not using. So the repository is a mock
* that FAILS when it is asked for something it should not have been — every
* refusal below is a case where the answer is known without a query.
*
* Privacy: the categories read are the asking reader's, and a worker that serves
* two readers in a row must not answer the second from the first's memo.
*/
final class InboxTabsTest extends TestCase
{
public function testABuiltInKeyAndAKeyThatCannotBeCustomCostNoQuery(): void
{
$repository = $this->createMock(InboxCategoryRepository::class);
$repository->expects(self::never())->method('findForUserOrdered');
$tabs = new InboxTabs($repository);
$reader = $this->user('reader');
self::assertSame('promotions', $tabs->resolve($reader, 'promotions')?->value);
self::assertNull($tabs->resolve($reader, ''));
self::assertNull($tabs->resolve($reader, 'quatsch'));
self::assertNull($tabs->resolve($reader, 'c1x'));
self::assertNull($tabs->resolve($reader, 'cprimary'));
}
public function testReadingTheReadersCategoriesHappensOnceHoweverManyAsk(): void
{
$repository = $this->createMock(InboxCategoryRepository::class);
$repository->expects(self::once())->method('findForUserOrdered')->willReturn([]);
$tabs = new InboxTabs($repository);
$reader = $this->user('reader');
$tabs->resolve($reader, 'c12');
$tabs->resolve($reader, 'c40');
$tabs->customFor($reader);
$tabs->allFor($reader);
}
/**
* The presence companion for the two above: a key that could be custom, from
* a reader who has such a category, DOES resolve — so "never queries" cannot
* be a resolver that never resolves.
*/
public function testAKeyThatIsTheirsResolvesAndAnotherReadersDoesNot(): void
{
$shipping = $this->category('Shipping', 12);
$repository = $this->createStub(InboxCategoryRepository::class);
$repository->method('findForUserOrdered')->willReturnCallback(
static fn (User $user): array => 'reader' === $user->getUserIdentifier() ? [$shipping] : [],
);
$tabs = new InboxTabs($repository);
self::assertSame('c12', $tabs->resolve($this->user('reader'), 'c12')?->value);
self::assertSame('Shipping', $tabs->resolve($this->user('reader'), 'c12')?->name);
self::assertNull($tabs->resolve($this->user('stranger'), 'c12'), 'resolved a category the asking reader does not own');
}
public function testAllForListsTheFiveThenTheReadersOwnInTheirOrder(): void
{
$repository = $this->createStub(InboxCategoryRepository::class);
$repository->method('findForUserOrdered')->willReturn([$this->category('Shipping', 12), $this->category('Receipts', 40)]);
$values = array_map(
static fn ($tab): string => $tab->value,
(new InboxTabs($repository))->allFor($this->user('reader')),
);
self::assertSame(
[...array_map(static fn (MessageCategory $c): string => $c->value, MessageCategory::cases()), 'c12', 'c40'],
$values,
);
}
public function testAResetForgetsTheLastReader(): void
{
$repository = $this->createMock(InboxCategoryRepository::class);
$repository->expects(self::exactly(2))->method('findForUserOrdered')->willReturn([]);
$tabs = new InboxTabs($repository);
$tabs->customFor($this->user('reader'));
$tabs->reset();
$tabs->customFor($this->user('reader'));
}
private function user(string $email): User
{
$user = new User();
$user->email = $email;
return $user;
}
/** A category with an id: the property is private(set), so it is persisted-shaped by reflection. */
private function category(string $name, int $id): InboxCategory
{
$category = new InboxCategory();
$category->name = $name;
new \ReflectionProperty(InboxCategory::class, 'id')->setValue($category, $id);
return $category;
}
}