From 5a1e616389f1020dfd8dc5c96e33cd326a47c657 Mon Sep 17 00:00:00 2001 From: IanM Date: Tue, 14 Apr 2026 17:32:46 +0100 Subject: [PATCH 1/7] =?UTF-8?q?feat(gambits):=20localization=20alias=20sup?= =?UTF-8?q?port=20=E2=80=94=20English=20keywords=20always=20work=20regardl?= =?UTF-8?q?ess=20of=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `canonicalKey()` and `canonicalPattern()` to `BooleanGambit` and `KeyValueGambit`. `GambitManager.match()` now tries the canonical English pattern as an alias when the translated pattern doesn't match, so English keywords like `is:hidden` or `author:behz` are always accepted even when the site locale has translated them. Fixes `SubscriptionFilter` accepting raw locale strings from the frontend; it now only accepts the stable canonical values `follow`/`ignore`. `SubscriptionGambit.toFilter()` maps matched keywords to canonical values before sending them to the API. Closes #4566 --- .../query/discussions/SubscriptionGambit.ts | 26 +- .../src/Filter/SubscriptionFilter.php | 22 +- .../discussions/SubscriptionFilterTest.php | 339 +++++++++++++ framework/core/js/jest.config.cjs | 9 +- framework/core/js/src/common/GambitManager.ts | 7 + framework/core/js/src/common/query/IGambit.ts | 36 ++ .../core/js/tests/__mocks__/emptyModule.js | 1 + framework/core/js/tests/__mocks__/nanoid.js | 1 + .../integration/common/GambitManager.test.ts | 258 ++++++++++ .../common/gambits/IGambit.test.ts | 461 ++++++++++++++++++ 10 files changed, 1150 insertions(+), 10 deletions(-) create mode 100644 extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php create mode 100644 framework/core/js/tests/__mocks__/emptyModule.js create mode 100644 framework/core/js/tests/__mocks__/nanoid.js create mode 100644 framework/core/js/tests/integration/common/gambits/IGambit.test.ts diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts index 4fd449a7c5..0bccb27dd6 100644 --- a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -9,11 +9,26 @@ export default class SubscriptionGambit extends BooleanGambit { ]; } + canonicalKey(): string[] { + return ['following', 'ignoring']; + } + toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + this.filterKey(); + const filterKey = (negate ? '-' : '') + this.filterKey(); + + // Map the matched surface keyword to the canonical internal DB value. + // This ensures SubscriptionFilter always receives 'follow' or 'ignore' + // regardless of which locale keyword was used. + const allFollowKeys = [ + 'following', + 'followed', + app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true), + ]; + + const value = allFollowKeys.includes(matches[1]) ? 'follow' : 'ignore'; return { - [key]: matches[1], + [filterKey]: value, }; } @@ -22,7 +37,12 @@ export default class SubscriptionGambit extends BooleanGambit { } fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:${value}`; + const key = this.key(); + // value is the canonical DB value ('follow' or 'ignore'); map back to + // the locale-appropriate surface keyword for display in the search bar. + const keyword = value === 'follow' ? key[0] : key[1]; + + return `${negate ? '-' : ''}is:${keyword}`; } enabled(): boolean { diff --git a/extensions/subscriptions/src/Filter/SubscriptionFilter.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php index 993949ebd7..be57ef9972 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -32,9 +32,19 @@ public function filter(SearchState $state, string|array $value, bool $negate): v { $value = $this->asString($value); - preg_match('/^(follow|ignor)(?:ing|ed)$/i', $value, $matches); - - $this->constrain($state->getQuery(), $state->getActor(), $matches[1], $negate); + $subscriptionType = match (true) { + in_array($value, ['follow', 'following', 'followed'], true) => 'follow', + in_array($value, ['ignore', 'ignoring', 'ignored'], true) => 'ignore', + default => null, + }; + + if ($subscriptionType === null) { + // Unrecognised value — match nothing rather than everything. + $state->getQuery()->whereRaw('0 = 1'); + return; + } + + $this->constrain($state->getQuery(), $state->getActor(), $subscriptionType, $negate); } protected function constrain(Builder $query, User $actor, string $subscriptionType, bool $negate): void @@ -42,9 +52,9 @@ protected function constrain(Builder $query, User $actor, string $subscriptionTy $method = $negate ? 'whereNotIn' : 'whereIn'; $query->$method('discussions.id', function ($query) use ($actor, $subscriptionType) { $query->select('discussion_id') - ->from('discussion_user') - ->where('user_id', $actor->id) - ->where('subscription', $subscriptionType === 'follow' ? 'follow' : 'ignore'); + ->from('discussion_user') + ->where('user_id', $actor->id) + ->where('subscription', $subscriptionType); }); } } diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php new file mode 100644 index 0000000000..9e4a70852b --- /dev/null +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php @@ -0,0 +1,339 @@ +extension('flarum-subscriptions'); + + $this->prepareDatabase([ + User::class => [ + $this->normalUser(), + ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], + ], + Discussion::class => [ + ['id' => 1, 'title' => 'Followed by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => 'Ignored by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1], + ['id' => 3, 'title' => 'No subscription', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 3, 'comment_count' => 1], + ], + Post::class => [ + ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ['id' => 2, 'number' => 1, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ['id' => 3, 'number' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ], + 'discussion_user' => [ + ['discussion_id' => 1, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'follow'], + ['discussion_id' => 2, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'ignore'], + ], + ]); + } + + // ------------------------------------------------------------------------- + // filter[subscription]=following + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_following_returns_only_followed_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'following']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1'], $ids); + } + + #[Test] + public function subscription_filter_following_returns_nothing_for_guest(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams(['filter' => ['subscription' => 'following']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids); + } + + // ------------------------------------------------------------------------- + // filter[subscription]=ignoring + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_ignoring_returns_only_ignored_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'ignoring']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2'], $ids); + } + + // ------------------------------------------------------------------------- + // Negation + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_following_negated_excludes_followed_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'following']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + // Discussions 2 and 3 — everything except the followed one + $this->assertEqualsCanonicalizing(['2', '3'], $ids); + $this->assertNotContains('1', $ids); + } + + #[Test] + public function subscription_filter_ignoring_negated_excludes_ignored_discussions(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'ignoring']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + // Discussions 1 and 3 — everything except the ignored one + $this->assertEqualsCanonicalizing(['1', '3'], $ids); + $this->assertNotContains('2', $ids); + } + + // ------------------------------------------------------------------------- + // Value variants accepted by SubscriptionFilter's regex + // ------------------------------------------------------------------------- + + public static function followVariantsProvider(): array + { + return [ + 'following' => ['following'], + 'followed' => ['followed'], + ]; + } + + #[Test] + #[DataProvider('followVariantsProvider')] + public function subscription_filter_accepts_follow_variants(string $value): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => $value]]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1'], $ids, "Value '{$value}' should match followed discussions"); + } + + public static function ignoreVariantsProvider(): array + { + return [ + 'ignoring' => ['ignoring'], + 'ignored' => ['ignored'], + ]; + } + + #[Test] + #[DataProvider('ignoreVariantsProvider')] + public function subscription_filter_accepts_ignore_variants(string $value): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => $value]]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2'], $ids, "Value '{$value}' should match ignored discussions"); + } + + // ------------------------------------------------------------------------- + // Unrecognised value is silently ignored (no crash, empty result) + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_with_unrecognised_value_returns_empty_not_crash(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => '跟随']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'An unrecognised subscription value should return no results, not crash'); + } + + // ========================================================================= + // TDD: Intended behaviour after the localization alias fix + // + // The JS GambitManager will always send the canonical internal value + // ('follow' or 'ignore') as the filter value — never the surface keyword. + // SubscriptionFilter must therefore accept these canonical values directly. + // Unrecognised values must return 200 with empty results, not 500. + // + // All tests in this section will FAIL until SubscriptionFilter is updated. + // ========================================================================= + + // ------------------------------------------------------------------------- + // Canonical internal values ('follow' / 'ignore') accepted directly + // + // After the fix, SubscriptionGambit.toFilter() will pass the canonical + // internal value rather than the surface keyword, so SubscriptionFilter + // must recognise these. + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_accepts_canonical_follow_value(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'follow']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1'], $ids, "'follow' (canonical) should match followed discussions"); + } + + #[Test] + public function subscription_filter_accepts_canonical_ignore_value(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'ignore']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2'], $ids, "'ignore' (canonical) should match ignored discussions"); + } + + #[Test] + public function subscription_filter_canonical_follow_negated(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'follow']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['2', '3'], $ids); + $this->assertNotContains('1', $ids); + } + + #[Test] + public function subscription_filter_canonical_ignore_negated(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['-subscription' => 'ignore']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEqualsCanonicalizing(['1', '3'], $ids); + $this->assertNotContains('2', $ids); + } + + // ------------------------------------------------------------------------- + // Unrecognised value: 200 + empty (no crash) + // + // Once the null-check is added, any value that doesn't match a known + // subscription type must be silently ignored and return no results. + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_filter_with_unrecognised_value_returns_empty(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => '跟随']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'An unrecognised subscription value should return no results, not crash'); + } + + #[Test] + public function subscription_filter_with_empty_value_returns_empty(): void + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => '']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'An empty subscription value should return no results, not crash'); + } +} diff --git a/framework/core/js/jest.config.cjs b/framework/core/js/jest.config.cjs index a2d3d0f14b..3cea9d4423 100644 --- a/framework/core/js/jest.config.cjs +++ b/framework/core/js/jest.config.cjs @@ -1 +1,8 @@ -module.exports = require('@flarum/jest-config')({}); +module.exports = require('@flarum/jest-config')({ + moduleNameMapper: { + // webpack expose-loader syntax is meaningless in Jest — map to empty stubs + '^expose-loader.*$': '/tests/__mocks__/emptyModule.js', + // nanoid is ESM-only; stub it with a simple CJS id generator for tests + '^nanoid$': '/tests/__mocks__/nanoid.js', + }, +}); diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 2a6051c0ca..4f11bf774d 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -48,6 +48,13 @@ export default class GambitManager { const pattern = new RegExp(`^(-?)${gambit.pattern()}$`, 'i'); let matches = bit.match(pattern); + // If the translated pattern didn't match, try the canonical English + // pattern as an alias — so English always works regardless of locale. + if (!matches && gambit.canonicalPattern() !== gambit.pattern()) { + const canonicalPattern = new RegExp(`^(-?)${gambit.canonicalPattern()}$`, 'i'); + matches = bit.match(canonicalPattern); + } + if (matches) { const negate = matches[1] === '-'; diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts index 108e7e4c4d..65f67d411b 100644 --- a/framework/core/js/src/common/query/IGambit.ts +++ b/framework/core/js/src/common/query/IGambit.ts @@ -82,6 +82,16 @@ export abstract class BooleanGambit implements IGambit { abstract key(): string | string[]; abstract filterKey(): string; + /** + * The hardcoded English keyword(s) for this gambit, used as a canonical + * alias so that English always works regardless of the active locale. + * Defaults to key(), which is correct when the gambit is not translated. + * Override this in subclasses that translate key() via the translator. + */ + canonicalKey(): string | string[] { + return this.key(); + } + booleanKey(): 'is' | 'has' | 'allows' { return 'is'; } @@ -101,6 +111,18 @@ export abstract class BooleanGambit implements IGambit { return `${is}:(${key})`; } + canonicalPattern(): string { + // Always use the untranslated English group key and canonical keyword(s) + // so that English input works regardless of the active locale. + let key = this.canonicalKey(); + + if (Array.isArray(key)) { + key = key.join('|'); + } + + return `is:(${key})`; + } + toFilter(_matches: string[], negate: boolean): Record { const key = (negate ? '-' : '') + this.filterKey(); @@ -136,6 +158,16 @@ export abstract class KeyValueGambit implements IGambit { abstract hint(): string; abstract filterKey(): string; + /** + * The hardcoded English keyword for this gambit, used as a canonical + * alias so that English always works regardless of the active locale. + * Defaults to key(), which is correct when the gambit is not translated. + * Override this in subclasses that translate key() via the translator. + */ + canonicalKey(): string { + return this.key(); + } + valuePattern(): string { return '(.+)'; } @@ -154,6 +186,10 @@ export abstract class KeyValueGambit implements IGambit { return `${key}:` + this.valuePattern(); } + canonicalPattern(): string { + return `${this.canonicalKey()}:` + this.valuePattern(); + } + toFilter(matches: string[], negate: boolean): Record { const key = (negate ? '-' : '') + this.filterKey(); diff --git a/framework/core/js/tests/__mocks__/emptyModule.js b/framework/core/js/tests/__mocks__/emptyModule.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/framework/core/js/tests/__mocks__/emptyModule.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/framework/core/js/tests/__mocks__/nanoid.js b/framework/core/js/tests/__mocks__/nanoid.js new file mode 100644 index 0000000000..7e79f0b3fb --- /dev/null +++ b/framework/core/js/tests/__mocks__/nanoid.js @@ -0,0 +1 @@ +let n = 0; module.exports = { nanoid: () => String(++n) }; diff --git a/framework/core/js/tests/integration/common/GambitManager.test.ts b/framework/core/js/tests/integration/common/GambitManager.test.ts index 6e3ef68894..3057dfb49a 100644 --- a/framework/core/js/tests/integration/common/GambitManager.test.ts +++ b/framework/core/js/tests/integration/common/GambitManager.test.ts @@ -1,5 +1,6 @@ import bootstrapForum from '@flarum/jest-config/src/bootstrap/forum'; import GambitManager from '../../../src/common/GambitManager'; +import { BooleanGambit, KeyValueGambit } from '../../../src/common/query/IGambit'; import { app } from '../../../src/forum'; const gambits = new GambitManager(); @@ -11,6 +12,10 @@ describe('GambitManager', () => { app.boot(); }); + // ------------------------------------------------------------------------- + // Existing behaviour — must not regress + // ------------------------------------------------------------------------- + test('gambits are converted to filters', function () { expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden author:behz' })).toStrictEqual({ q: 'lorem', @@ -43,4 +48,257 @@ describe('GambitManager', () => { } ); }); + + // ------------------------------------------------------------------------- + // Canonical keyword matching — English keys always work + // ------------------------------------------------------------------------- + + test('canonical English author keyword always matches', function () { + expect(gambits.apply('discussions', { q: 'author:admin' })).toStrictEqual({ + q: '', + author: 'admin', + }); + }); + + test('canonical English created keyword always matches', function () { + expect(gambits.apply('discussions', { q: 'created:2024-01-01' })).toStrictEqual({ + q: '', + created: '2024-01-01', + }); + }); + + test('canonical English is:hidden always matches', function () { + expect(gambits.apply('discussions', { q: 'is:hidden' })).toStrictEqual({ + q: '', + hidden: true, + }); + }); + + test('canonical English is:unread always matches', function () { + expect(gambits.apply('discussions', { q: 'is:unread' })).toStrictEqual({ + q: '', + unread: true, + }); + }); + + test('canonical English email keyword always matches for users', function () { + expect(gambits.apply('users', { q: 'email:admin@machine.local' })).toStrictEqual({ + q: '', + email: 'admin@machine.local', + }); + }); + + test('canonical English group keyword always matches for users', function () { + expect(gambits.apply('users', { q: 'group:admins' })).toStrictEqual({ + q: '', + group: 'admins', + }); + }); + + // ------------------------------------------------------------------------- + // Unrecognised keywords are left in the query string untouched + // ------------------------------------------------------------------------- + + test('unknown keyword is not consumed and remains in q', function () { + expect(gambits.apply('discussions', { q: 'lorem suivi:admin' })).toStrictEqual({ + q: 'lorem suivi:admin', + }); + }); + + test('unknown boolean keyword is not consumed', function () { + expect(gambits.apply('discussions', { q: 'is:versteckt' })).toStrictEqual({ + q: 'is:versteckt', + }); + }); + + // ------------------------------------------------------------------------- + // from() — filter object back to query string + // ------------------------------------------------------------------------- + + test('from() converts filter object back to a query string', function () { + const q = gambits.from('discussions', '', { author: 'admin', hidden: true }); + expect(q).toContain('author:admin'); + expect(q).toContain('is:hidden'); + }); + + test('from() ignores unknown filter keys', function () { + const q = gambits.from('discussions', 'lorem', { unknownKey: 'value' }); + expect(q).toBe('lorem'); + }); + + // ------------------------------------------------------------------------- + // match() — callback is called correctly + // ------------------------------------------------------------------------- + + test('match() calls the callback with the correct gambit, matches and negate=false', function () { + const calls: Array<{ filterKey: string; negate: boolean }> = []; + + gambits.match('discussions', 'author:behz', (gambit, _matches, negate) => { + calls.push({ filterKey: gambit.filterKey(), negate }); + }); + + expect(calls).toStrictEqual([{ filterKey: 'author', negate: false }]); + }); + + test('match() calls the callback with negate=true for a dashed gambit', function () { + const calls: Array<{ filterKey: string; negate: boolean }> = []; + + gambits.match('discussions', '-is:hidden', (gambit, _matches, negate) => { + calls.push({ filterKey: gambit.filterKey(), negate }); + }); + + expect(calls).toStrictEqual([{ filterKey: 'hidden', negate: true }]); + }); + + test('match() returns remaining query with matched gambits removed', function () { + const remaining = gambits.match('discussions', 'hello author:behz world', () => {}); + expect(remaining).toBe('hello world'); + }); + + test('match() returns full query unchanged when nothing matches', function () { + const remaining = gambits.match('discussions', 'hello world', () => {}); + expect(remaining).toBe('hello world'); + }); + + // ------------------------------------------------------------------------- + // apply() — filter accumulation edge cases + // ------------------------------------------------------------------------- + + test('apply() preserves other filter keys already on the object', function () { + expect(gambits.apply('discussions', { q: 'author:behz', tag: 'foo' })).toStrictEqual({ + q: '', + author: 'behz', + tag: 'foo', + }); + }); + + test('apply() with empty q returns filter unchanged', function () { + expect(gambits.apply('discussions', { q: '' })).toStrictEqual({ q: '' }); + }); + + test('apply() with unknown resource type returns filter unchanged', function () { + expect(gambits.apply('widgets', { q: 'author:behz' })).toStrictEqual({ q: 'author:behz' }); + }); + + // ------------------------------------------------------------------------- + // TDD: Localization alias support + // + // When a gambit's key() returns a translated value (e.g. '作者' for 'author'), + // both the translated keyword AND the English canonical keyword must match. + // The filter output must be identical in both cases. + // + // These tests will FAIL until GambitManager.match() is updated to try both + // the translated pattern and the canonical English pattern. + // ------------------------------------------------------------------------- + + describe('localization alias support', () => { + // Gambits whose key() returns a non-English translation but canonicalKey() + // returns the hardcoded English keyword — the pattern all translated gambits follow. + class TranslatedBooleanGambit extends BooleanGambit { + key() { return '隐藏'; } // Chinese for "hidden" + canonicalKey() { return 'hidden'; } + filterKey() { return 'hidden'; } + } + + class TranslatedKeyValueGambit extends KeyValueGambit { + key() { return '作者'; } // Chinese for "author" + canonicalKey() { return 'author'; } + hint() { return '用户名'; } + filterKey() { return 'author'; } + } + + let localizedGambits: GambitManager; + + beforeAll(() => { + localizedGambits = new GambitManager(); + localizedGambits.gambits = { + discussions: [TranslatedBooleanGambit, TranslatedKeyValueGambit], + }; + }); + + test('translated BooleanGambit keyword matches', () => { + expect(localizedGambits.apply('discussions', { q: 'is:隐藏' })).toStrictEqual({ + q: '', + hidden: true, + }); + }); + + test('English canonical BooleanGambit keyword still matches when key() is translated', () => { + // "is:hidden" must still work even though key() returns '隐藏' + expect(localizedGambits.apply('discussions', { q: 'is:hidden' })).toStrictEqual({ + q: '', + hidden: true, + }); + }); + + test('translated BooleanGambit and English canonical produce identical filter output', () => { + const translated = localizedGambits.apply('discussions', { q: 'is:隐藏' }); + const canonical = localizedGambits.apply('discussions', { q: 'is:hidden' }); + expect(translated).toStrictEqual(canonical); + }); + + test('translated KeyValueGambit keyword matches', () => { + expect(localizedGambits.apply('discussions', { q: '作者:behz' })).toStrictEqual({ + q: '', + author: 'behz', + }); + }); + + test('English canonical KeyValueGambit keyword still matches when key() is translated', () => { + // "author:behz" must still work even though key() returns '作者' + expect(localizedGambits.apply('discussions', { q: 'author:behz' })).toStrictEqual({ + q: '', + author: 'behz', + }); + }); + + test('translated KeyValueGambit and English canonical produce identical filter output', () => { + const translated = localizedGambits.apply('discussions', { q: '作者:behz' }); + const canonical = localizedGambits.apply('discussions', { q: 'author:behz' }); + expect(translated).toStrictEqual(canonical); + }); + + test('negation works with translated keyword', () => { + expect(localizedGambits.apply('discussions', { q: '-is:隐藏' })).toStrictEqual({ + q: '', + '-hidden': true, + }); + }); + + test('negation works with canonical keyword when key() is translated', () => { + expect(localizedGambits.apply('discussions', { q: '-is:hidden' })).toStrictEqual({ + q: '', + '-hidden': true, + }); + }); + + test('match() fires callback for translated keyword', () => { + const calls: string[] = []; + localizedGambits.match('discussions', 'is:隐藏', (gambit) => { + calls.push(gambit.filterKey()); + }); + expect(calls).toStrictEqual(['hidden']); + }); + + test('match() fires callback for canonical keyword when key() is translated', () => { + const calls: string[] = []; + localizedGambits.match('discussions', 'is:hidden', (gambit) => { + calls.push(gambit.filterKey()); + }); + expect(calls).toStrictEqual(['hidden']); + }); + + test('completely unrelated keyword is still not consumed', () => { + expect(localizedGambits.apply('discussions', { q: 'is:versteckt' })).toStrictEqual({ + q: 'is:versteckt', + }); + }); + + test('gambit with key() equal to the canonical English key does not double-match', () => { + // When key() already returns the English word, there is no alias — it should + // match once and not be applied twice. + const result = gambits.apply('discussions', { q: 'is:hidden' }); + expect(result).toStrictEqual({ q: '', hidden: true }); + }); + }); }); diff --git a/framework/core/js/tests/integration/common/gambits/IGambit.test.ts b/framework/core/js/tests/integration/common/gambits/IGambit.test.ts new file mode 100644 index 0000000000..734e6209cb --- /dev/null +++ b/framework/core/js/tests/integration/common/gambits/IGambit.test.ts @@ -0,0 +1,461 @@ +/** + * Tests for the individual gambit classes and the BooleanGambit / KeyValueGambit + * base class behaviour: pattern generation, toFilter, fromFilter, suggestion. + * + * These tests pin the current English-keyword behaviour so that any future + * localization alias work cannot silently regress the canonical paths. + */ +import bootstrapForum from '@flarum/jest-config/src/bootstrap/forum'; +import { app } from '../../../../src/forum'; + +import { BooleanGambit, KeyValueGambit, GambitType } from '../../../../src/common/query/IGambit'; +import AuthorGambit from '../../../../src/common/query/discussions/AuthorGambit'; +import CreatedGambit from '../../../../src/common/query/discussions/CreatedGambit'; +import HiddenGambit from '../../../../src/common/query/discussions/HiddenGambit'; +import UnreadGambit from '../../../../src/common/query/discussions/UnreadGambit'; +import EmailGambit from '../../../../src/common/query/users/EmailGambit'; +import GroupGambit from '../../../../src/common/query/users/GroupGambit'; + +beforeAll(() => { + bootstrapForum(); + app.boot(); +}); + +// --------------------------------------------------------------------------- +// AuthorGambit (KeyValueGambit) +// --------------------------------------------------------------------------- + +describe('AuthorGambit', () => { + let gambit: AuthorGambit; + + beforeEach(() => { + gambit = new AuthorGambit(); + }); + + test('type is KeyValue', () => { + expect(gambit.type).toBe(GambitType.KeyValue); + }); + + test('key() returns the English canonical key', () => { + expect(gambit.key()).toBe('author'); + }); + + test('filterKey() returns "author"', () => { + expect(gambit.filterKey()).toBe('author'); + }); + + test('pattern() returns "author:(.+)"', () => { + expect(gambit.pattern()).toBe('author:(.+)'); + }); + + test('toFilter() maps a match to the correct filter object', () => { + expect(gambit.toFilter(['author:behz', 'behz'], false)).toStrictEqual({ author: 'behz' }); + }); + + test('toFilter() negates the filter key when negate=true', () => { + expect(gambit.toFilter(['author:behz', 'behz'], true)).toStrictEqual({ '-author': 'behz' }); + }); + + test('fromFilter() reconstructs the gambit string', () => { + expect(gambit.fromFilter('behz', false)).toBe('author:behz'); + }); + + test('fromFilter() prefixes with dash when negated', () => { + expect(gambit.fromFilter('behz', true)).toBe('-author:behz'); + }); + + test('suggestion() returns key and hint', () => { + const s = gambit.suggestion(); + expect(s).toHaveProperty('key', 'author'); + expect(s).toHaveProperty('hint'); + }); + + test('enabled() is always true', () => { + expect(gambit.enabled()).toBe(true); + }); + + test('pattern matches a valid author string', () => { + expect('author:behz').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match an unrelated string', () => { + expect('auteur:behz').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// CreatedGambit (KeyValueGambit with custom valuePattern) +// --------------------------------------------------------------------------- + +describe('CreatedGambit', () => { + let gambit: CreatedGambit; + + beforeEach(() => { + gambit = new CreatedGambit(); + }); + + test('key() returns "created"', () => { + expect(gambit.key()).toBe('created'); + }); + + test('filterKey() returns "created"', () => { + expect(gambit.filterKey()).toBe('created'); + }); + + test('pattern matches a single date', () => { + expect('created:2024-01-15').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern matches a date range', () => { + expect('created:2024-01-01..2024-12-31').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match a plain word', () => { + expect('created:yesterday').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('toFilter() passes the date string through', () => { + expect(gambit.toFilter(['created:2024-01-15', '2024-01-15'], false)).toStrictEqual({ created: '2024-01-15' }); + }); + + test('toFilter() passes a range string through', () => { + expect(gambit.toFilter(['created:2024-01-01..2024-12-31', '2024-01-01..2024-12-31'], false)).toStrictEqual({ + created: '2024-01-01..2024-12-31', + }); + }); + + test('fromFilter() reconstructs the gambit string', () => { + expect(gambit.fromFilter('2024-01-15', false)).toBe('created:2024-01-15'); + }); +}); + +// --------------------------------------------------------------------------- +// HiddenGambit (BooleanGambit) +// --------------------------------------------------------------------------- + +describe('HiddenGambit', () => { + let gambit: HiddenGambit; + + beforeEach(() => { + gambit = new HiddenGambit(); + }); + + test('type is Grouped', () => { + expect(gambit.type).toBe(GambitType.Grouped); + }); + + test('key() returns "hidden"', () => { + expect(gambit.key()).toBe('hidden'); + }); + + test('filterKey() returns "hidden"', () => { + expect(gambit.filterKey()).toBe('hidden'); + }); + + test('booleanKey() returns "is"', () => { + expect(gambit.booleanKey()).toBe('is'); + }); + + test('groupKey() returns the translated "is" group key', () => { + expect(gambit.groupKey()).toBe('is'); + }); + + test('pattern() returns "is:(hidden)"', () => { + expect(gambit.pattern()).toBe('is:(hidden)'); + }); + + test('toFilter() returns { hidden: true }', () => { + expect(gambit.toFilter(['is:hidden', 'hidden'], false)).toStrictEqual({ hidden: true }); + }); + + test('toFilter() returns { "-hidden": true } when negated', () => { + expect(gambit.toFilter(['is:hidden', 'hidden'], true)).toStrictEqual({ '-hidden': true }); + }); + + test('fromFilter() reconstructs "is:hidden"', () => { + expect(gambit.fromFilter('hidden', false)).toBe('is:hidden'); + }); + + test('fromFilter() reconstructs "-is:hidden" when negated', () => { + expect(gambit.fromFilter('hidden', true)).toBe('-is:hidden'); + }); + + test('suggestion() returns group and key', () => { + const s = gambit.suggestion(); + expect(s).toHaveProperty('group', 'is'); + expect(s).toHaveProperty('key', 'hidden'); + }); + + test('pattern matches "is:hidden"', () => { + expect('is:hidden').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match a different keyword', () => { + expect('is:versteckt').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// UnreadGambit (BooleanGambit) +// --------------------------------------------------------------------------- + +describe('UnreadGambit', () => { + let gambit: UnreadGambit; + + beforeEach(() => { + gambit = new UnreadGambit(); + }); + + test('key() returns "unread"', () => { + expect(gambit.key()).toBe('unread'); + }); + + test('filterKey() returns "unread"', () => { + expect(gambit.filterKey()).toBe('unread'); + }); + + test('pattern() returns "is:(unread)"', () => { + expect(gambit.pattern()).toBe('is:(unread)'); + }); + + test('toFilter() returns { unread: true }', () => { + expect(gambit.toFilter(['is:unread', 'unread'], false)).toStrictEqual({ unread: true }); + }); + + test('pattern matches "is:unread"', () => { + expect('is:unread').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// EmailGambit (KeyValueGambit) +// --------------------------------------------------------------------------- + +describe('EmailGambit', () => { + let gambit: EmailGambit; + + beforeEach(() => { + gambit = new EmailGambit(); + }); + + test('key() returns "email"', () => { + expect(gambit.key()).toBe('email'); + }); + + test('filterKey() returns "email"', () => { + expect(gambit.filterKey()).toBe('email'); + }); + + test('pattern() returns "email:(.+)"', () => { + expect(gambit.pattern()).toBe('email:(.+)'); + }); + + test('toFilter() maps correctly', () => { + expect(gambit.toFilter(['email:foo@bar.com', 'foo@bar.com'], false)).toStrictEqual({ email: 'foo@bar.com' }); + }); + + test('fromFilter() reconstructs correctly', () => { + expect(gambit.fromFilter('foo@bar.com', false)).toBe('email:foo@bar.com'); + }); + + test('pattern matches a valid email string', () => { + expect('email:foo@bar.com').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// GroupGambit (KeyValueGambit) +// --------------------------------------------------------------------------- + +describe('GroupGambit', () => { + let gambit: GroupGambit; + + beforeEach(() => { + gambit = new GroupGambit(); + }); + + test('key() returns "group"', () => { + expect(gambit.key()).toBe('group'); + }); + + test('filterKey() returns "group"', () => { + expect(gambit.filterKey()).toBe('group'); + }); + + test('pattern() returns "group:(.+)"', () => { + expect(gambit.pattern()).toBe('group:(.+)'); + }); + + test('toFilter() maps correctly', () => { + expect(gambit.toFilter(['group:admins', 'admins'], false)).toStrictEqual({ group: 'admins' }); + }); + + test('fromFilter() reconstructs correctly', () => { + expect(gambit.fromFilter('admins', false)).toBe('group:admins'); + }); +}); + +// --------------------------------------------------------------------------- +// BooleanGambit base class — array key support +// --------------------------------------------------------------------------- + +describe('BooleanGambit with array key()', () => { + class MultiKeyGambit extends BooleanGambit { + key() { + return ['foo', 'bar']; + } + filterKey() { + return 'multi'; + } + } + + let gambit: MultiKeyGambit; + + beforeEach(() => { + gambit = new MultiKeyGambit(); + }); + + test('pattern() joins multiple keys with | inside the group', () => { + expect(gambit.pattern()).toBe('is:(foo|bar)'); + }); + + test('pattern matches first key', () => { + expect('is:foo').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern matches second key', () => { + expect('is:bar').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match an unlisted key', () => { + expect('is:baz').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// KeyValueGambit base class — custom valuePattern +// --------------------------------------------------------------------------- + +describe('KeyValueGambit with custom valuePattern()', () => { + class DateOnlyGambit extends KeyValueGambit { + key() { return 'on'; } + hint() { return 'YYYY-MM-DD'; } + filterKey() { return 'on'; } + valuePattern() { return '(\\d{4}-\\d{2}-\\d{2})'; } + } + + let gambit: DateOnlyGambit; + + beforeEach(() => { + gambit = new DateOnlyGambit(); + }); + + test('pattern() uses the custom valuePattern', () => { + expect(gambit.pattern()).toBe('on:(\\d{4}-\\d{2}-\\d{2})'); + }); + + test('pattern matches a valid date', () => { + expect('on:2024-01-01').toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); + + test('pattern does not match free text', () => { + expect('on:yesterday').not.toMatch(new RegExp(`^(-?)${gambit.pattern()}$`, 'i')); + }); +}); + +// --------------------------------------------------------------------------- +// TDD: canonicalKey() — the English fallback key +// +// Each gambit base class must expose a canonicalKey() method that returns the +// hardcoded English keyword, independent of what key() returns (which may be +// translated). GambitManager will use this to build an alias pattern so that +// the English keyword always works regardless of locale. +// +// These tests will FAIL until canonicalKey() is added to BooleanGambit and +// KeyValueGambit in IGambit.ts. +// --------------------------------------------------------------------------- + +describe('TDD: BooleanGambit canonicalKey()', () => { + class TranslatedBooleanGambit extends BooleanGambit { + // Simulates a gambit whose key() returns a Chinese translation. + // canonicalKey() is overridden to return the hardcoded English keyword — + // this is the pattern every translated gambit subclass must follow. + key() { return '隐藏'; } + canonicalKey() { return 'hidden'; } + filterKey() { return 'hidden'; } + } + + class UntranslatedBooleanGambit extends BooleanGambit { + // No canonicalKey() override — defaults to key(), which is already English. + key() { return 'hidden'; } + filterKey() { return 'hidden'; } + } + + test('canonicalKey() exists on BooleanGambit', () => { + const gambit = new UntranslatedBooleanGambit(); + expect(typeof gambit.canonicalKey).toBe('function'); + }); + + test('canonicalKey() returns a string or string[] like key()', () => { + const gambit = new UntranslatedBooleanGambit(); + const canonical = gambit.canonicalKey(); + expect(typeof canonical === 'string' || Array.isArray(canonical)).toBe(true); + }); + + test('canonicalKey() returns the English keyword when key() is English', () => { + const gambit = new UntranslatedBooleanGambit(); + expect(gambit.canonicalKey()).toBe('hidden'); + }); + + test('canonicalKey() returns the English keyword even when key() is translated', () => { + const gambit = new TranslatedBooleanGambit(); + // key() returns '隐藏', but canonicalKey() must still return 'hidden' + expect(gambit.canonicalKey()).toBe('hidden'); + }); + + test('canonicalPattern() builds the English fallback pattern', () => { + const gambit = new TranslatedBooleanGambit(); + expect(typeof (gambit as any).canonicalPattern).toBe('function'); + expect((gambit as any).canonicalPattern()).toBe('is:(hidden)'); + }); +}); + +describe('TDD: KeyValueGambit canonicalKey()', () => { + class TranslatedKeyValueGambit extends KeyValueGambit { + // Simulates a gambit whose key() returns a Chinese translation. + // canonicalKey() is overridden to return the hardcoded English keyword — + // this is the pattern every translated gambit subclass must follow. + key() { return '作者'; } + canonicalKey() { return 'author'; } + hint() { return '用户名'; } + filterKey() { return 'author'; } + } + + class UntranslatedKeyValueGambit extends KeyValueGambit { + // No canonicalKey() override — defaults to key(), which is already English. + key() { return 'author'; } + hint() { return 'username'; } + filterKey() { return 'author'; } + } + + test('canonicalKey() exists on KeyValueGambit', () => { + const gambit = new UntranslatedKeyValueGambit(); + expect(typeof gambit.canonicalKey).toBe('function'); + }); + + test('canonicalKey() returns the English keyword when key() is English', () => { + const gambit = new UntranslatedKeyValueGambit(); + expect(gambit.canonicalKey()).toBe('author'); + }); + + test('canonicalKey() returns the English keyword even when key() is translated', () => { + const gambit = new TranslatedKeyValueGambit(); + expect(gambit.canonicalKey()).toBe('author'); + }); + + test('canonicalPattern() builds the English fallback pattern', () => { + const gambit = new TranslatedKeyValueGambit(); + expect(typeof (gambit as any).canonicalPattern).toBe('function'); + expect((gambit as any).canonicalPattern()).toBe('author:(.+)'); + }); +}); From 019c9f73b47399ddc00c13e3443f9d5aab7d4f71 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 14 Apr 2026 16:33:23 +0000 Subject: [PATCH 2/7] Apply fixes from StyleCI --- extensions/subscriptions/src/Filter/SubscriptionFilter.php | 7 ++++--- .../integration/api/discussions/SubscriptionFilterTest.php | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/extensions/subscriptions/src/Filter/SubscriptionFilter.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php index be57ef9972..d3f45e369e 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -33,14 +33,15 @@ public function filter(SearchState $state, string|array $value, bool $negate): v $value = $this->asString($value); $subscriptionType = match (true) { - in_array($value, ['follow', 'following', 'followed'], true) => 'follow', - in_array($value, ['ignore', 'ignoring', 'ignored'], true) => 'ignore', - default => null, + in_array($value, ['follow', 'following', 'followed'], true) => 'follow', + in_array($value, ['ignore', 'ignoring', 'ignored'], true) => 'ignore', + default => null, }; if ($subscriptionType === null) { // Unrecognised value — match nothing rather than everything. $state->getQuery()->whereRaw('0 = 1'); + return; } diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php index 9e4a70852b..42af0d4b55 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php @@ -156,7 +156,7 @@ public static function followVariantsProvider(): array { return [ 'following' => ['following'], - 'followed' => ['followed'], + 'followed' => ['followed'], ]; } @@ -180,7 +180,7 @@ public static function ignoreVariantsProvider(): array { return [ 'ignoring' => ['ignoring'], - 'ignored' => ['ignored'], + 'ignored' => ['ignored'], ]; } From 26732788f363887135c58f3c7c2f3d1fbe2da8c1 Mon Sep 17 00:00:00 2001 From: IanM Date: Tue, 14 Apr 2026 17:40:21 +0100 Subject: [PATCH 3/7] fix(gambits): add canonicalPattern() to IGambit interface --- framework/core/js/src/common/query/IGambit.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts index 65f67d411b..f561d6002b 100644 --- a/framework/core/js/src/common/query/IGambit.ts +++ b/framework/core/js/src/common/query/IGambit.ts @@ -11,6 +11,13 @@ export default interface IGambit { */ pattern(): string; + /** + * The canonical English pattern for this gambit, used as an alias so that + * English always works regardless of the active locale. + * Implementations should derive this from canonicalKey() rather than key(). + */ + canonicalPattern(): string; + /** * This is the method to transform a gambit into a filter format. */ From 434adc1d896513d62c260af7dd25c8699b4b8ce7 Mon Sep 17 00:00:00 2001 From: IanM Date: Tue, 14 Apr 2026 17:48:57 +0100 Subject: [PATCH 4/7] feat(subscriptions): Subscription extender for third-party subscription types Adds a Flarum\Subscriptions\Extend\Subscription extender so third-party extensions can register custom subscription types (e.g. 'lurk') with their accepted filter aliases: (new Extend\Subscription()) ->addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked']) SubscriptionFilter now resolves values through a container-bound registry ('flarum-subscriptions.subscription_types') rather than a hardcoded match expression. The built-in 'follow' and 'ignore' types are seeded by the subscriptions extension's own extend.php via the same extender. --- extensions/subscriptions/extend.php | 7 +++ .../subscriptions/src/Extend/Subscription.php | 61 +++++++++++++++++++ .../src/Filter/SubscriptionFilter.php | 34 ++++++++--- .../discussions/SubscriptionFilterTest.php | 58 ++++++++++++++++++ 4 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 extensions/subscriptions/src/Extend/Subscription.php diff --git a/extensions/subscriptions/extend.php b/extensions/subscriptions/extend.php index f563b6b81f..fb852cf47f 100644 --- a/extensions/subscriptions/extend.php +++ b/extensions/subscriptions/extend.php @@ -20,6 +20,7 @@ use Flarum\Post\Event\Restored; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Subscriptions\Api\UserResourceFields; +use Flarum\Subscriptions\Extend\Subscription; use Flarum\Subscriptions\Filter\SubscriptionFilter; use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage; use Flarum\Subscriptions\Listener; @@ -64,6 +65,12 @@ ->listen(Posted::class, Listener\FollowAfterReply::class) ->listen(Started::class, Listener\FollowAfterCreate::class), + // Register the built-in subscription types. Third-party extensions can add + // their own types via (new Flarum\Subscriptions\Extend\Subscription())->addSubscriptionType(). + (new Subscription()) + ->addSubscriptionType('follow', ['follow', 'following', 'followed']) + ->addSubscriptionType('ignore', ['ignore', 'ignoring', 'ignored']), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) ->addFilter(DiscussionSearcher::class, SubscriptionFilter::class) ->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class), diff --git a/extensions/subscriptions/src/Extend/Subscription.php b/extensions/subscriptions/src/Extend/Subscription.php new file mode 100644 index 0000000000..7f9b663975 --- /dev/null +++ b/extensions/subscriptions/src/Extend/Subscription.php @@ -0,0 +1,61 @@ +addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked']) + * ``` + * + * @param string $canonicalValue The value stored in the database. + * @param string|string[] $aliases One or more filter values that map to this canonical value. + */ + public function addSubscriptionType(string $canonicalValue, string|array $aliases): self + { + $this->types[$canonicalValue] = array_merge( + $this->types[$canonicalValue] ?? [], + (array) $aliases, + ); + + return $this; + } + + public function extend(Container $container, ?Extension $extension = null): void + { + // Ensure the registry exists before extending it. + $container->bindIf('flarum-subscriptions.subscription_types', fn () => []); + + $container->extend('flarum-subscriptions.subscription_types', function (array $types) { + foreach ($this->types as $canonical => $aliases) { + $types[$canonical] = array_unique(array_merge($types[$canonical] ?? [], $aliases)); + } + + return $types; + }); + } +} diff --git a/extensions/subscriptions/src/Filter/SubscriptionFilter.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php index d3f45e369e..c10ad56b1e 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -14,6 +14,7 @@ use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Eloquent\Builder; /** @@ -23,6 +24,11 @@ class SubscriptionFilter implements FilterInterface { use ValidateFilterTrait; + public function __construct( + private readonly Container $container, + ) { + } + public function getFilterKey(): string { return 'subscription'; @@ -32,20 +38,34 @@ public function filter(SearchState $state, string|array $value, bool $negate): v { $value = $this->asString($value); - $subscriptionType = match (true) { - in_array($value, ['follow', 'following', 'followed'], true) => 'follow', - in_array($value, ['ignore', 'ignoring', 'ignored'], true) => 'ignore', - default => null, - }; + $canonicalValue = $this->resolveCanonicalValue($value); - if ($subscriptionType === null) { + if ($canonicalValue === null) { // Unrecognised value — match nothing rather than everything. $state->getQuery()->whereRaw('0 = 1'); return; } - $this->constrain($state->getQuery(), $state->getActor(), $subscriptionType, $negate); + $this->constrain($state->getQuery(), $state->getActor(), $canonicalValue, $negate); + } + + /** + * Resolve a filter value to its canonical database value using the + * registered subscription type registry. Returns null if unrecognised. + */ + protected function resolveCanonicalValue(string $value): ?string + { + /** @var array $types */ + $types = $this->container->make('flarum-subscriptions.subscription_types'); + + foreach ($types as $canonical => $aliases) { + if (in_array($value, $aliases, true)) { + return $canonical; + } + } + + return null; } protected function constrain(Builder $query, User $actor, string $subscriptionType, bool $negate): void diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php index 42af0d4b55..948fd56dbd 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php @@ -12,6 +12,7 @@ use Carbon\Carbon; use Flarum\Discussion\Discussion; use Flarum\Post\Post; +use Flarum\Subscriptions\Extend\Subscription; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; @@ -336,4 +337,61 @@ public function subscription_filter_with_empty_value_returns_empty(): void $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); $this->assertEmpty($ids, 'An empty subscription value should return no results, not crash'); } + + // ------------------------------------------------------------------------- + // Subscription extender — third-party type registration + // ------------------------------------------------------------------------- + + #[Test] + public function subscription_extender_registers_custom_type(): void + { + $this->extend( + (new Subscription()) + ->addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked']) + ); + + // Seed a discussion with the custom 'lurk' subscription value. + $this->prepareDatabase([ + Discussion::class => [ + ['id' => 10, 'title' => 'Lurked by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 10, 'comment_count' => 1], + ], + Post::class => [ + ['id' => 10, 'number' => 1, 'discussion_id' => 10, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ], + 'discussion_user' => [ + ['discussion_id' => 10, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'lurk'], + ], + ]); + + foreach (['lurk', 'lurking', 'lurked'] as $alias) { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => $alias]]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), "Alias '{$alias}': {$body}"); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertContains('10', $ids, "Alias '{$alias}' should match the lurked discussion"); + $this->assertNotContains('1', $ids); + $this->assertNotContains('2', $ids); + } + } + + #[Test] + public function subscription_extender_custom_type_unregistered_alias_still_returns_empty(): void + { + // Without registering 'lurk', the value should still return nothing. + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams(['filter' => ['subscription' => 'lurk']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertEmpty($ids, 'Unregistered custom type should return no results'); + } } From 3e6c5f3bf8b0078903920ffb1d50bf092f86273a Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 14 Apr 2026 16:49:10 +0000 Subject: [PATCH 5/7] Apply fixes from StyleCI --- extensions/subscriptions/src/Extend/Subscription.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/subscriptions/src/Extend/Subscription.php b/extensions/subscriptions/src/Extend/Subscription.php index 7f9b663975..27810cc7d8 100644 --- a/extensions/subscriptions/src/Extend/Subscription.php +++ b/extensions/subscriptions/src/Extend/Subscription.php @@ -9,9 +9,9 @@ namespace Flarum\Subscriptions\Extend; +use Flarum\Extend\ExtenderInterface; use Flarum\Extension\Extension; use Illuminate\Contracts\Container\Container; -use Flarum\Extend\ExtenderInterface; class Subscription implements ExtenderInterface { From d250aad70a24228f045aca1a599ee8cac078b153 Mon Sep 17 00:00:00 2001 From: IanM Date: Tue, 14 Apr 2026 17:58:37 +0100 Subject: [PATCH 6/7] fix: resolve CI failures from canonicalPattern interface and test fixtures - Add canonicalPattern() to the IGambit object literal in GambitsAutocomplete.tsx so it satisfies the updated interface (TS2345) - Add __esModule: true to the nanoid Jest mock so Jest's CJS/ESM interop resolves the named export correctly - Move lurk discussion/post/discussion_user fixtures into setUp() so the subscription_extender_registers_custom_type test can find them --- .../api/discussions/SubscriptionFilterTest.php | 17 ++++------------- .../js/src/common/utils/GambitsAutocomplete.tsx | 1 + framework/core/js/tests/__mocks__/nanoid.js | 3 ++- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php index 948fd56dbd..2abbb630e8 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php @@ -45,15 +45,18 @@ protected function setUp(): void ['id' => 1, 'title' => 'Followed by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], ['id' => 2, 'title' => 'Ignored by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1], ['id' => 3, 'title' => 'No subscription', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 3, 'comment_count' => 1], + ['id' => 10, 'title' => 'Lurked by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 10, 'comment_count' => 1], ], Post::class => [ ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], ['id' => 2, 'number' => 1, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], ['id' => 3, 'number' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], + ['id' => 10, 'number' => 1, 'discussion_id' => 10, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], ], 'discussion_user' => [ ['discussion_id' => 1, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'follow'], ['discussion_id' => 2, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'ignore'], + ['discussion_id' => 10, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'lurk'], ], ]); } @@ -350,19 +353,7 @@ public function subscription_extender_registers_custom_type(): void ->addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked']) ); - // Seed a discussion with the custom 'lurk' subscription value. - $this->prepareDatabase([ - Discussion::class => [ - ['id' => 10, 'title' => 'Lurked by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 10, 'comment_count' => 1], - ], - Post::class => [ - ['id' => 10, 'number' => 1, 'discussion_id' => 10, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], - ], - 'discussion_user' => [ - ['discussion_id' => 10, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'lurk'], - ], - ]); - + // Discussion 10 with subscription='lurk' is seeded in setUp(). foreach (['lurk', 'lurking', 'lurked'] as $alias) { $response = $this->send( $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) diff --git a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx index 6379d612d2..5a41567136 100644 --- a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx +++ b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx @@ -45,6 +45,7 @@ export default class GambitsAutocomplete { .join(', '), }), pattern: () => '', + canonicalPattern: () => '', filterKey: () => '', toFilter: () => [], fromFilter: () => '', diff --git a/framework/core/js/tests/__mocks__/nanoid.js b/framework/core/js/tests/__mocks__/nanoid.js index 7e79f0b3fb..39eb240701 100644 --- a/framework/core/js/tests/__mocks__/nanoid.js +++ b/framework/core/js/tests/__mocks__/nanoid.js @@ -1 +1,2 @@ -let n = 0; module.exports = { nanoid: () => String(++n) }; +let n = 0; +module.exports = { __esModule: true, nanoid: () => String(++n) }; From 123480780e5face4991f3962e44317d3a4eb3820 Mon Sep 17 00:00:00 2001 From: IanM Date: Tue, 14 Apr 2026 19:54:22 +0100 Subject: [PATCH 7/7] fix: use ESM mock files and fix PHP test fixture isolation Jest runs with --experimental-vm-modules which does real ESM linking, so CJS mock files cannot satisfy named ESM imports. Replace emptyModule.js and nanoid.js with proper .mjs equivalents using ESM export syntax. Fix SubscriptionFilterTest: seed the 'lurk-user' fixture under user 3 (not user 2) to avoid polluting the negation tests' result sets; switch negation assertions from assertEqualsCanonicalizing to assertContains/assertNotContains so they remain correct regardless of how many discussions exist. Replace the lurk custom-type test (which required seeding an invalid enum value) with a test that registers an additional alias for the existing 'follow' type, which is a valid use of the extender and exercises the same code path. --- .../discussions/SubscriptionFilterTest.php | 63 ++++++++++--------- framework/core/js/jest.config.cjs | 6 +- .../core/js/tests/__mocks__/emptyModule.js | 1 - .../core/js/tests/__mocks__/emptyModule.mjs | 1 + framework/core/js/tests/__mocks__/nanoid.js | 2 - framework/core/js/tests/__mocks__/nanoid.mjs | 2 + 6 files changed, 39 insertions(+), 36 deletions(-) delete mode 100644 framework/core/js/tests/__mocks__/emptyModule.js create mode 100644 framework/core/js/tests/__mocks__/emptyModule.mjs delete mode 100644 framework/core/js/tests/__mocks__/nanoid.js create mode 100644 framework/core/js/tests/__mocks__/nanoid.mjs diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php index 2abbb630e8..0ea3cb7d7b 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscriptionFilterTest.php @@ -45,7 +45,7 @@ protected function setUp(): void ['id' => 1, 'title' => 'Followed by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], ['id' => 2, 'title' => 'Ignored by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1], ['id' => 3, 'title' => 'No subscription', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 3, 'comment_count' => 1], - ['id' => 10, 'title' => 'Lurked by normal', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 10, 'comment_count' => 1], + ['id' => 10, 'title' => 'Also followed by acme', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 10, 'comment_count' => 1], ], Post::class => [ ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo

'], @@ -56,7 +56,9 @@ protected function setUp(): void 'discussion_user' => [ ['discussion_id' => 1, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'follow'], ['discussion_id' => 2, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'ignore'], - ['discussion_id' => 10, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'lurk'], + // User 3 (acme) follows discussion 10 — kept separate from user 2's subscriptions + // so the negation tests (which assert exact ID sets for user 2) are not affected. + ['discussion_id' => 10, 'user_id' => 3, 'last_read_post_number' => 1, 'subscription' => 'follow'], ], ]); } @@ -130,9 +132,10 @@ public function subscription_filter_following_negated_excludes_followed_discussi $this->assertEquals(200, $response->getStatusCode(), $body); $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); - // Discussions 2 and 3 — everything except the followed one - $this->assertEqualsCanonicalizing(['2', '3'], $ids); + // The followed discussion must be excluded; everything else is included. $this->assertNotContains('1', $ids); + $this->assertContains('2', $ids); + $this->assertContains('3', $ids); } #[Test] @@ -147,9 +150,10 @@ public function subscription_filter_ignoring_negated_excludes_ignored_discussion $this->assertEquals(200, $response->getStatusCode(), $body); $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); - // Discussions 1 and 3 — everything except the ignored one - $this->assertEqualsCanonicalizing(['1', '3'], $ids); + // The ignored discussion must be excluded; everything else is included. $this->assertNotContains('2', $ids); + $this->assertContains('1', $ids); + $this->assertContains('3', $ids); } // ------------------------------------------------------------------------- @@ -284,8 +288,9 @@ public function subscription_filter_canonical_follow_negated(): void $this->assertEquals(200, $response->getStatusCode(), $body); $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); - $this->assertEqualsCanonicalizing(['2', '3'], $ids); $this->assertNotContains('1', $ids); + $this->assertContains('2', $ids); + $this->assertContains('3', $ids); } #[Test] @@ -300,8 +305,9 @@ public function subscription_filter_canonical_ignore_negated(): void $this->assertEquals(200, $response->getStatusCode(), $body); $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); - $this->assertEqualsCanonicalizing(['1', '3'], $ids); $this->assertNotContains('2', $ids); + $this->assertContains('1', $ids); + $this->assertContains('3', $ids); } // ------------------------------------------------------------------------- @@ -346,43 +352,40 @@ public function subscription_filter_with_empty_value_returns_empty(): void // ------------------------------------------------------------------------- #[Test] - public function subscription_extender_registers_custom_type(): void + public function subscription_extender_registers_additional_alias_for_existing_type(): void { + // Register a third-party alias ('subscribed') that maps to the built-in 'follow' canonical. + // User 3 (acme) follows discussion 10 — seeded in setUp(). $this->extend( (new Subscription()) - ->addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked']) + ->addSubscriptionType('follow', ['subscribed']) ); - // Discussion 10 with subscription='lurk' is seeded in setUp(). - foreach (['lurk', 'lurking', 'lurked'] as $alias) { - $response = $this->send( - $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) - ->withQueryParams(['filter' => ['subscription' => $alias]]) - ); - - $body = $response->getBody()->getContents(); - $this->assertEquals(200, $response->getStatusCode(), "Alias '{$alias}': {$body}"); - - $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); - $this->assertContains('10', $ids, "Alias '{$alias}' should match the lurked discussion"); - $this->assertNotContains('1', $ids); - $this->assertNotContains('2', $ids); - } + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 3]) + ->withQueryParams(['filter' => ['subscription' => 'subscribed']]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); + $this->assertContains('10', $ids, "'subscribed' alias should resolve to 'follow' and match discussion 10"); } #[Test] - public function subscription_extender_custom_type_unregistered_alias_still_returns_empty(): void + public function subscription_extender_unregistered_alias_returns_empty(): void { - // Without registering 'lurk', the value should still return nothing. + // Without registering 'subscribed', it should return no results. $response = $this->send( - $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) - ->withQueryParams(['filter' => ['subscription' => 'lurk']]) + $this->request('GET', '/api/discussions', ['authenticatedAs' => 3]) + ->withQueryParams(['filter' => ['subscription' => 'subscribed']]) ); $body = $response->getBody()->getContents(); $this->assertEquals(200, $response->getStatusCode(), $body); $ids = Arr::pluck(json_decode($body, true)['data'], 'id'); - $this->assertEmpty($ids, 'Unregistered custom type should return no results'); + $this->assertEmpty($ids, 'Unregistered alias should return no results'); } } diff --git a/framework/core/js/jest.config.cjs b/framework/core/js/jest.config.cjs index 3cea9d4423..8b4a4bbdaf 100644 --- a/framework/core/js/jest.config.cjs +++ b/framework/core/js/jest.config.cjs @@ -1,8 +1,8 @@ module.exports = require('@flarum/jest-config')({ moduleNameMapper: { // webpack expose-loader syntax is meaningless in Jest — map to empty stubs - '^expose-loader.*$': '/tests/__mocks__/emptyModule.js', - // nanoid is ESM-only; stub it with a simple CJS id generator for tests - '^nanoid$': '/tests/__mocks__/nanoid.js', + '^expose-loader.*$': '/tests/__mocks__/emptyModule.mjs', + // nanoid is ESM-only; stub it with a simple ESM id generator for tests + '^nanoid$': '/tests/__mocks__/nanoid.mjs', }, }); diff --git a/framework/core/js/tests/__mocks__/emptyModule.js b/framework/core/js/tests/__mocks__/emptyModule.js deleted file mode 100644 index f053ebf797..0000000000 --- a/framework/core/js/tests/__mocks__/emptyModule.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/framework/core/js/tests/__mocks__/emptyModule.mjs b/framework/core/js/tests/__mocks__/emptyModule.mjs new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/framework/core/js/tests/__mocks__/emptyModule.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/framework/core/js/tests/__mocks__/nanoid.js b/framework/core/js/tests/__mocks__/nanoid.js deleted file mode 100644 index 39eb240701..0000000000 --- a/framework/core/js/tests/__mocks__/nanoid.js +++ /dev/null @@ -1,2 +0,0 @@ -let n = 0; -module.exports = { __esModule: true, nanoid: () => String(++n) }; diff --git a/framework/core/js/tests/__mocks__/nanoid.mjs b/framework/core/js/tests/__mocks__/nanoid.mjs new file mode 100644 index 0000000000..88f77efdad --- /dev/null +++ b/framework/core/js/tests/__mocks__/nanoid.mjs @@ -0,0 +1,2 @@ +let n = 0; +export const nanoid = () => String(++n);