Skip to content

Commit dfba8ad

Browse files
authored
[2.x] fix: distinguish a missing announcement excerpt from an empty one (#4902)
The announcements fetcher read each excerpt from the `firstPost` include and ran it through `makeExcerpt()` regardless of whether that post arrived. With no post, `Arr::get(null, ...)` yields `''` and the excerpt became an empty string — the same value a post with no text would produce. That is how the admin announcements widget came to render every card blank without anyone noticing: the response looked well-formed and said, in effect, "these announcements have no content". The actual fault was upstream, where fof/gamification had narrowed the `firstPost` eager load and stopped the include being serialized at all. The excerpt is now null where the post never arrived, and an empty string only where the post is genuinely empty. The widget already guards on a falsy excerpt, so nothing renders differently — but a future recurrence is legible instead of silent. Tests: that the request asks for the relationships the excerpt and author are read from — nothing asserted that before, so a lost include would not have been noticed; that a missing include yields null; and that an empty post still yields an empty string, so the distinction holds in both directions.
1 parent 5b60c40 commit dfba8ad

3 files changed

Lines changed: 101 additions & 6 deletions

File tree

framework/core/js/src/admin/components/AnnouncementItem.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export interface AnnouncementData {
1313
createdAt: string;
1414
isSticky: boolean;
1515
url: string;
16-
excerpt: string;
16+
/** Null where the announcement's first post did not arrive, as against an empty string for a post with no text. */
17+
excerpt: string | null;
1718
authorName: string | null;
1819
avatarUrl: string | null;
1920
}

framework/core/src/Announcements/AnnouncementsFetcher.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,14 @@ public function fetch(): array
9595
'createdAt' => $createdAt,
9696
'isSticky' => (bool) Arr::get($discussion, 'attributes.isSticky', false),
9797
'url' => 'https://discuss.flarum.org/d/'.$slug,
98-
'excerpt' => $this->makeExcerpt(Arr::get($firstPost, 'attributes.contentHtml', '')),
98+
// Null where the post never arrived, as against an empty string
99+
// for a post that genuinely has no text. Collapsing the two hid
100+
// a real fault: discuss.flarum.org stopped serializing the
101+
// `firstPost` include, and every forum's announcements widget
102+
// rendered blank cards that read as "these posts are empty".
103+
'excerpt' => $firstPost === null
104+
? null
105+
: $this->makeExcerpt(Arr::get($firstPost, 'attributes.contentHtml', '')),
99106
'authorName' => Arr::get($user, 'attributes.displayName'),
100107
'avatarUrl' => Arr::get($user, 'attributes.avatarUrl'),
101108
];

framework/core/tests/unit/Announcements/AnnouncementsFetcherTest.php

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use GuzzleHttp\Exception\ConnectException;
1717
use GuzzleHttp\Handler\MockHandler;
1818
use GuzzleHttp\HandlerStack;
19+
use GuzzleHttp\Middleware;
1920
use GuzzleHttp\Psr7\Request;
2021
use GuzzleHttp\Psr7\Response;
2122
use Mockery as m;
@@ -32,16 +33,22 @@ protected function setUp(): void
3233
$this->appInfo->shouldReceive('identifyDatabaseVersion')->andReturn('8.0.32');
3334
}
3435

35-
private function makeFetcher(array $responses): AnnouncementsFetcher
36+
/**
37+
* @param array $history Populated with the requests actually sent, so a test
38+
* can assert what was asked for and not only what came
39+
* back.
40+
*/
41+
private function makeFetcher(array $responses, array &$history = []): AnnouncementsFetcher
3642
{
37-
$mock = new MockHandler($responses);
38-
$client = new Client(['handler' => HandlerStack::create($mock)]);
43+
$stack = HandlerStack::create(new MockHandler($responses));
44+
$stack->push(Middleware::history($history));
45+
46+
$client = new Client(['handler' => $stack]);
3947

4048
$fetcher = new AnnouncementsFetcher($this->appInfo);
4149

4250
// Inject the mock client via reflection
4351
$ref = new \ReflectionProperty($fetcher, 'client');
44-
$ref->setAccessible(true);
4552
$ref->setValue($fetcher, $client);
4653

4754
return $fetcher;
@@ -189,6 +196,86 @@ public function test_skips_discussions_missing_required_fields(): void
189196
$this->assertEquals('Valid', $result[0]['title']);
190197
}
191198

199+
/**
200+
* The excerpt and author are read out of `included`, which only arrives if
201+
* the request asks for those relationships. Every other test here hands the
202+
* transform a well-formed `included` array, so none of them would notice the
203+
* request losing its `include`.
204+
*/
205+
public function test_requests_the_relationships_the_excerpt_and_author_need(): void
206+
{
207+
$history = [];
208+
$fetcher = $this->makeFetcher([$this->makeApiResponse([$this->makeDiscussion()])], $history);
209+
210+
$fetcher->fetch();
211+
212+
$this->assertCount(1, $history);
213+
214+
$query = [];
215+
parse_str($history[0]['request']->getUri()->getQuery(), $query);
216+
217+
$this->assertArrayHasKey('include', $query, 'The request did not ask for any relationships.');
218+
219+
$includes = array_map('trim', explode(',', $query['include']));
220+
221+
$this->assertContains('firstPost', $includes, 'Without firstPost there is no content to excerpt.');
222+
$this->assertContains('user', $includes, 'Without user there is no author name or avatar.');
223+
}
224+
225+
/**
226+
* A well-formed response whose `included` is empty, because the relationship
227+
* was not serialized — which is what discuss.flarum.org returned while
228+
* fof/gamification narrowed the `firstPost` eager load, leaving every
229+
* announcement card in every forum's admin panel blank.
230+
*
231+
* "The excerpt never arrived" and "the post has no text" are different
232+
* things, and collapsing both to an empty string is why that went unnoticed.
233+
* Only the absence of an excerpt is reportable, so absence is what it says.
234+
*/
235+
public function test_excerpt_is_null_when_the_first_post_was_not_included(): void
236+
{
237+
$fetcher = $this->makeFetcher([
238+
$this->makeApiResponse(
239+
[$this->makeDiscussion([], [
240+
'firstPost' => ['data' => ['type' => 'posts', 'id' => '10']],
241+
])],
242+
// Declared on the discussion, absent from `included`.
243+
[]
244+
),
245+
]);
246+
247+
$result = $fetcher->fetch();
248+
249+
$this->assertNull(
250+
$result[0]['excerpt'],
251+
'A missing include produced an empty-string excerpt, indistinguishable from a post with no content.'
252+
);
253+
}
254+
255+
/**
256+
* A post that genuinely has no text still reports an empty excerpt rather
257+
* than null, so the two cases stay distinguishable in both directions.
258+
*/
259+
public function test_excerpt_is_empty_when_the_first_post_has_no_content(): void
260+
{
261+
$fetcher = $this->makeFetcher([
262+
$this->makeApiResponse(
263+
[$this->makeDiscussion([], [
264+
'firstPost' => ['data' => ['type' => 'posts', 'id' => '10']],
265+
])],
266+
[[
267+
'type' => 'posts',
268+
'id' => '10',
269+
'attributes' => ['contentHtml' => ''],
270+
]]
271+
),
272+
]);
273+
274+
$result = $fetcher->fetch();
275+
276+
$this->assertSame('', $result[0]['excerpt']);
277+
}
278+
192279
public function test_throws_on_network_failure(): void
193280
{
194281
$fetcher = $this->makeFetcher([

0 commit comments

Comments
 (0)