diff --git a/app/Community/Actions/FetchDynamicShortcodeContentAction.php b/app/Community/Actions/FetchDynamicShortcodeContentAction.php
index f1298183ed..4ff9c70969 100644
--- a/app/Community/Actions/FetchDynamicShortcodeContentAction.php
+++ b/app/Community/Actions/FetchDynamicShortcodeContentAction.php
@@ -29,6 +29,7 @@ public function execute(
array $gameIds = [],
array $hubIds = [],
array $eventIds = [],
+ string $convertedBody = '',
): ShortcodeDynamicEntitiesData {
return new ShortcodeDynamicEntitiesData(
users: $this->fetchUsers($usernames)->all(),
@@ -37,6 +38,7 @@ public function execute(
games: $this->fetchGames($gameIds)->all(),
hubs: $this->fetchHubs($hubIds)->all(),
events: $this->fetchEvents($eventIds)->all(),
+ convertedBody: $convertedBody,
);
}
diff --git a/app/Community/Controllers/Api/ForumTopicCommentApiController.php b/app/Community/Controllers/Api/ForumTopicCommentApiController.php
index ce1cdaeda6..0d8dee74de 100644
--- a/app/Community/Controllers/Api/ForumTopicCommentApiController.php
+++ b/app/Community/Controllers/Api/ForumTopicCommentApiController.php
@@ -69,6 +69,9 @@ public function update(
// Convert [game={legacy_hub_id}] to [hub={game_set_id}].
$newPayload = Shortcode::convertLegacyGameHubShortcodesToHubShortcodes($newPayload);
+ // Convert [game=X?set=Y] to [game=backingGameId].
+ $newPayload = Shortcode::convertGameSetShortcodesToBackingGame($newPayload);
+
$comment->body = $newPayload;
// If this post is being edited by someone other than
diff --git a/app/Community/Controllers/Api/ShortcodeApiController.php b/app/Community/Controllers/Api/ShortcodeApiController.php
index ec16695843..9f0c60acdb 100644
--- a/app/Community/Controllers/Api/ShortcodeApiController.php
+++ b/app/Community/Controllers/Api/ShortcodeApiController.php
@@ -7,6 +7,7 @@
use App\Community\Actions\FetchDynamicShortcodeContentAction;
use App\Community\Requests\PreviewShortcodeBodyRequest;
use App\Http\Controller;
+use App\Support\Shortcode\Shortcode;
use Illuminate\Http\JsonResponse;
class ShortcodeApiController extends Controller
@@ -15,13 +16,26 @@ public function preview(
PreviewShortcodeBodyRequest $request,
FetchDynamicShortcodeContentAction $action,
): JsonResponse {
+ $body = $request->input('body');
+
+ // Normalize URLs to shortcode format (eg: "https://retroachievements.org/game/123" -> "[game=123]").
+ $body = normalize_shortcodes($body);
+
+ // Convert [game=X?set=Y] shortcodes to their backing game IDs.
+ $body = Shortcode::convertGameSetShortcodesToBackingGame($body);
+
+ // Extract entity IDs from the normalized+converted body.
+ $extractedIds = Shortcode::extractShortcodeIds($body);
+
+ // Fetch the entities and return the final converted body.
$entities = $action->execute(
- usernames: $request->input('usernames'),
- ticketIds: $request->input('ticketIds'),
- achievementIds: $request->input('achievementIds'),
- gameIds: $request->input('gameIds'),
- hubIds: $request->input('hubIds'),
- eventIds: $request->input('eventIds'),
+ convertedBody: $body,
+ achievementIds: $extractedIds['achievementIds'],
+ eventIds: $extractedIds['eventIds'],
+ gameIds: $extractedIds['gameIds'],
+ hubIds: $extractedIds['hubIds'],
+ ticketIds: $extractedIds['ticketIds'],
+ usernames: $extractedIds['usernames'],
);
return response()->json($entities);
diff --git a/app/Community/Data/ShortcodeDynamicEntitiesData.php b/app/Community/Data/ShortcodeDynamicEntitiesData.php
index 1822d7aa79..ad322f073d 100644
--- a/app/Community/Data/ShortcodeDynamicEntitiesData.php
+++ b/app/Community/Data/ShortcodeDynamicEntitiesData.php
@@ -31,6 +31,7 @@ public function __construct(
public array $games = [],
public array $hubs = [],
public array $events = [],
+ public string $convertedBody = '',
) {
}
}
diff --git a/app/Community/Requests/PreviewShortcodeBodyRequest.php b/app/Community/Requests/PreviewShortcodeBodyRequest.php
index 8f943ea7c0..34764666ed 100644
--- a/app/Community/Requests/PreviewShortcodeBodyRequest.php
+++ b/app/Community/Requests/PreviewShortcodeBodyRequest.php
@@ -11,16 +11,7 @@ class PreviewShortcodeBodyRequest extends FormRequest
public function rules(): array
{
return [
- 'usernames' => 'present|array',
- 'usernames.*' => 'string',
- 'ticketIds' => 'present|array',
- 'ticketIds.*' => 'integer',
- 'achievementIds' => 'present|array',
- 'achievementIds.*' => 'integer',
- 'gameIds' => 'present|array',
- 'gameIds.*' => 'integer',
- 'hubIds' => 'present|array',
- 'hubIds.*' => 'integer',
+ 'body' => 'required|string',
];
}
}
diff --git a/app/Helpers/database/forum.php b/app/Helpers/database/forum.php
index a85fe35ff6..9183b69dab 100644
--- a/app/Helpers/database/forum.php
+++ b/app/Helpers/database/forum.php
@@ -163,6 +163,9 @@ function editTopicComment(int $commentId, string $newPayload): void
// Convert [game={legacy_hub_id}] to [hub={game_set_id}].
$newPayload = Shortcode::convertLegacyGameHubShortcodesToHubShortcodes($newPayload);
+ // Convert [game=X?set=Y] to [game=backingGameId].
+ $newPayload = Shortcode::convertGameSetShortcodesToBackingGame($newPayload);
+
$comment = ForumTopicComment::findOrFail($commentId);
$comment->body = $newPayload;
$comment->save();
@@ -190,6 +193,9 @@ function submitTopicComment(
// Convert [game={legacy_hub_id}] to [hub={game_set_id}].
$commentPayload = Shortcode::convertLegacyGameHubShortcodesToHubShortcodes($commentPayload);
+ // Convert [game=X?set=Y] to [game=backingGameId].
+ $commentPayload = Shortcode::convertGameSetShortcodesToBackingGame($commentPayload);
+
// if this exact message was just posted by this user, assume it's an
// accidental double submission and ignore.
$latestPost = $user->forumPosts()->latest('created_at')->first();
diff --git a/app/Helpers/shortcode.php b/app/Helpers/shortcode.php
index 50ef2e8690..04b611c92a 100644
--- a/app/Helpers/shortcode.php
+++ b/app/Helpers/shortcode.php
@@ -96,12 +96,25 @@ function normalize_shortcodes(string $input): string
function normalize_targeted_shortcodes(string $input, string $kind, ?string $tagName = null): string
{
// Find any URL variants of user links and transform them into shortcode tags.
+ // First, handle URLs with a ?set= query param (these are games).
+ if ($kind === 'game') {
+ $findWithSet = [
+ "~https?://(?:[\w\-]+\.)?retroachievements\.org/game2?/([\w]{1,20})(?:-[^\s\"'<>]*)?(?:/)?\\?set=(\d+)~si",
+ "~https?://localhost(?::\d{1,5})?/game2?/([\w]{1,20})(?:-[^\s\"'<>]*)?(?:/)?\\?set=(\d+)~si",
+ ];
+ $replaceWithSet = "[game=$1?set=$2]";
+ $input = preg_replace($findWithSet, $replaceWithSet, $input);
+ }
+
+ // Then, handle URLs without query params.
// Ignore URLs that contain path or query params.
+ // For games, match both /game/ and /game2/ URLs.
+ $pathPattern = $kind === 'game' ? 'game2?' : $kind;
$find = [
- "~\]*retroachievements\.org/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^/>]*\][^]*~si",
- "~\[url[^\]]*retroachievements\.org/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^\]]*\][^\[]*\[/url\]~si",
- "~https?://(?:[\w\-]+\.)?retroachievements\.org/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si",
- "~https?://localhost(?::\d{1,5})?/" . $kind . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si",
+ "~\]*retroachievements\.org/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^/>]*\][^]*~si",
+ "~\[url[^\]]*retroachievements\.org/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))[^\]]*\][^\[]*\[/url\]~si",
+ "~https?://(?:[\w\-]+\.)?retroachievements\.org/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si",
+ "~https?://localhost(?::\d{1,5})?/" . $pathPattern . "/([\w]{1,20})(?:-[^\s\"'<>]*)?(/?(?![\w/?]))~si",
];
$replace = "[" . ($tagName ?? $kind) . "=$1]";
diff --git a/app/Support/Shortcode/Shortcode.php b/app/Support/Shortcode/Shortcode.php
index 46f25b7397..7be81a3aec 100644
--- a/app/Support/Shortcode/Shortcode.php
+++ b/app/Support/Shortcode/Shortcode.php
@@ -11,6 +11,7 @@
use App\Models\System;
use App\Models\Ticket;
use App\Models\User;
+use App\Platform\Actions\ResolveBackingGameForAchievementSetAction;
use App\Platform\Enums\GameSetType;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
@@ -128,6 +129,81 @@ public static function convertUserShortcodesToUseIds(string $input): string
}, $input);
}
+ public static function convertGameSetShortcodesToBackingGame(string $input): string
+ {
+ // Extract all [game=X?set=Y] or [game=X set=Y] patterns.
+ preg_match_all('/\[game=(\d+)(?:\?|\s)set=(\d+)\]/i', $input, $matches, PREG_SET_ORDER);
+ if (empty($matches)) {
+ return $input;
+ }
+
+ // Collect all unique set IDs for bulk processing.
+ $setIds = array_unique(array_column($matches, 2));
+
+ // Now, resolve backing games for all sets.
+ // Build a map of achievement set ID -> backing game ID.
+ $setToBackingGameMap = [];
+ $resolveAction = (new ResolveBackingGameForAchievementSetAction());
+
+ foreach ($setIds as $setId) {
+ $backingGameId = $resolveAction->execute((int) $setId);
+ if ($backingGameId) {
+ $setToBackingGameMap[$setId] = $backingGameId;
+ }
+ }
+
+ // Replace each shortcode with the backing game ID.
+ // If no backing game is found, fall back to the original game ID.
+ return preg_replace_callback(
+ '/\[game=(\d+)(?:\?|\s)set=(\d+)\]/i',
+ function ($match) use ($setToBackingGameMap) {
+ $originalGameId = $match[1];
+ $setId = $match[2];
+
+ $gameId = $setToBackingGameMap[$setId] ?? $originalGameId;
+
+ return "[game={$gameId}]";
+ },
+ $input
+ );
+ }
+
+ public static function extractShortcodeIds(string $input): array
+ {
+ // Extract achievement IDs from [ach=X] shortcodes.
+ preg_match_all('/\[ach=(\d+)\]/i', $input, $achievementMatches);
+ $achievementIds = array_unique(array_map('intval', $achievementMatches[1]));
+
+ // Extract game IDs from [game=X] shortcodes (but not [game=X set=Y]).
+ preg_match_all('/\[game=(\d+)(?!\?|\ set=)\]/i', $input, $gameMatches);
+ $gameIds = array_unique(array_map('intval', $gameMatches[1]));
+
+ // Extract hub IDs from [hub=X] shortcodes.
+ preg_match_all('/\[hub=(\d+)\]/i', $input, $hubMatches);
+ $hubIds = array_unique(array_map('intval', $hubMatches[1]));
+
+ // Extract event IDs from [event=X] shortcodes.
+ preg_match_all('/\[event=(\d+)\]/i', $input, $eventMatches);
+ $eventIds = array_unique(array_map('intval', $eventMatches[1]));
+
+ // Extract ticket IDs from [ticket=X] shortcodes.
+ preg_match_all('/\[ticket=(\d+)\]/i', $input, $ticketMatches);
+ $ticketIds = array_unique(array_map('intval', $ticketMatches[1]));
+
+ // Extract usernames from [user=X] shortcodes.
+ preg_match_all('/\[user=([^\]]+)\]/i', $input, $userMatches);
+ $usernames = array_unique($userMatches[1]);
+
+ return [
+ 'achievementIds' => array_values($achievementIds),
+ 'gameIds' => array_values($gameIds),
+ 'hubIds' => array_values($hubIds),
+ 'eventIds' => array_values($eventIds),
+ 'ticketIds' => array_values($ticketIds),
+ 'usernames' => $usernames,
+ ];
+ }
+
public static function stripAndClamp(
string $input,
int $previewLength = 100,
@@ -476,6 +552,10 @@ private function prefetchUsers(string $input): void
private function parse(string $input, array $options = []): string
{
+ // Resolve game set shortcodes to backing games before render-time.
+ // This allows [game=1?set=9534] to display the correct backing game.
+ $input = self::convertGameSetShortcodesToBackingGame($input);
+
$this->prefetchUsers($input);
// make sure to use attribute delimiter for string values
diff --git a/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts b/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts
index 850e886a8a..2a44ad27a4 100644
--- a/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts
+++ b/resources/js/common/hooks/mutations/useShortcodeBodyPreviewMutation.ts
@@ -2,17 +2,13 @@ import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { route } from 'ziggy-js';
-import type {
- DynamicShortcodeEntities,
- ShortcodeBodyPreviewMutationResponse,
-} from '@/common/models';
+import type { ShortcodeBodyPreviewMutationResponse } from '@/common/models';
export function useShortcodeBodyPreviewMutation() {
return useMutation({
- mutationFn: (payload: DynamicShortcodeEntities) =>
- axios.post(
- route('api.shortcode-body.preview'),
- payload,
- ),
+ mutationFn: (body: string) =>
+ axios.post(route('api.shortcode-body.preview'), {
+ body,
+ }),
});
}
diff --git a/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts b/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts
index b6d86b78cd..44cbc6bfc7 100644
--- a/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts
+++ b/resources/js/common/hooks/useHydrateShortcodeDynamicEntities.test.ts
@@ -31,6 +31,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => {
events: [],
tickets: [],
users: [],
+ convertedBody: '',
};
// ACT
@@ -45,6 +46,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => {
events: [],
tickets: [],
users: [],
+ convertedBody: '',
},
},
},
@@ -65,6 +67,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => {
hubs: [{ id: 3 }],
tickets: [{ id: 4 }],
users: [{ id: 5 }],
+ convertedBody: '',
};
// ACT
@@ -77,6 +80,7 @@ describe('Hook: useHydrateShortcodeDynamicEntities', () => {
events: [],
tickets: [],
users: [],
+ convertedBody: '',
},
},
});
diff --git a/resources/js/common/hooks/useShortcodeBodyPreview.test.ts b/resources/js/common/hooks/useShortcodeBodyPreview.test.ts
index a3169648b3..1d228bbf5d 100644
--- a/resources/js/common/hooks/useShortcodeBodyPreview.test.ts
+++ b/resources/js/common/hooks/useShortcodeBodyPreview.test.ts
@@ -22,13 +22,23 @@ describe('Hook: useShortcodeBodyPreview', () => {
expect(result.current).toBeTruthy();
});
- it('given content with no dynamic entities, sets preview content without making an API call', async () => {
+ it('given content with no dynamic entities, makes an API call with body text', async () => {
// ARRANGE
- const postSpy = vi.spyOn(axios, 'post');
+ const simpleContent = 'Hello world!';
- const { result } = renderHook(() => useShortcodeBodyPreview());
+ const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: simpleContent,
+ },
+ });
- const simpleContent = 'Hello world!';
+ const { result } = renderHook(() => useShortcodeBodyPreview());
// ACT
await act(async () => {
@@ -36,7 +46,7 @@ describe('Hook: useShortcodeBodyPreview', () => {
});
// ASSERT
- expect(postSpy).not.toHaveBeenCalled();
+ expect(postSpy).toHaveBeenCalledWith(['api.shortcode-body.preview'], { body: simpleContent });
await waitFor(() => {
expect(result.current.previewContent).toEqual(simpleContent);
@@ -54,6 +64,7 @@ describe('Hook: useShortcodeBodyPreview', () => {
events: [],
tickets: [],
users: [createUser({ displayName: 'username' })],
+ convertedBody: '[user=username] and [game=123]',
},
};
@@ -68,7 +79,7 @@ describe('Hook: useShortcodeBodyPreview', () => {
// ASSERT
await waitFor(() => {
- expect(result.current.previewContent).toEqual(contentWithEntities);
+ expect(result.current.previewContent).toEqual('[user=username] and [game=123]'); // !! uses converted body
});
});
@@ -88,6 +99,7 @@ describe('Hook: useShortcodeBodyPreview', () => {
],
tickets: [createTicket({ id: 12345 })],
users: [createUser({ displayName: 'username' })],
+ convertedBody: '[user=username] and [game=123]',
},
};
@@ -108,7 +120,7 @@ describe('Hook: useShortcodeBodyPreview', () => {
// ASSERT
await waitFor(() => {
- expect(result.current.previewContent).toEqual(contentWithEntities);
+ expect(result.current.previewContent).toEqual('[user=username] and [game=123]');
});
const {
@@ -134,4 +146,205 @@ describe('Hook: useShortcodeBodyPreview', () => {
expect(persistedTickets).toHaveLength(1);
expect(persistedUsers).toHaveLength(1);
});
+
+ it('given content with a game set shortcode, converts to backing game ID in the preview', async () => {
+ // ARRANGE
+ const contentWithSetId = 'Check out [game=668?set=8659]';
+
+ const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [createGame({ id: 29895, title: 'Sonic the Hedgehog [Subset - Perfect Bonus]' })],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'Check out [game=29895]',
+ },
+ });
+
+ const { result } = renderHook(() => useShortcodeBodyPreview());
+
+ // ACT
+ await act(async () => {
+ await result.current.initiatePreview(contentWithSetId);
+ });
+
+ // ASSERT
+ await waitFor(() => {
+ expect(postSpy).toHaveBeenCalledWith(['api.shortcode-body.preview'], {
+ body: contentWithSetId,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.previewContent).toEqual('Check out [game=29895]');
+ });
+
+ const { persistedGames } = result.current.unsafe_getPersistedValues();
+ expect(persistedGames).toHaveLength(1);
+ expect(persistedGames).toEqual(
+ expect.arrayContaining([expect.objectContaining({ id: 29895 })]),
+ );
+ });
+
+ it('given content with multiple game set shortcodes, converts all to backing game IDs', async () => {
+ // ARRANGE
+ const contentWithSetIds = 'Try [game=668?set=8659] and [game=1?set=9534]';
+
+ vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [
+ createGame({ id: 29895, title: 'Sonic the Hedgehog [Subset - Perfect Bonus]' }),
+ createGame({ id: 28000, title: 'Another Subset Game' }),
+ ],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'Try [game=29895] and [game=28000]',
+ },
+ });
+
+ const { result } = renderHook(() => useShortcodeBodyPreview());
+
+ // ACT
+ await act(async () => {
+ await result.current.initiatePreview(contentWithSetIds);
+ });
+
+ // ASSERT
+ await waitFor(() => {
+ expect(result.current.previewContent).toEqual('Try [game=29895] and [game=28000]');
+ });
+ });
+
+ it('given content with mixed game shortcodes (with and without sets), handles both correctly', async () => {
+ // ARRANGE
+ const contentWithMixedShortcodes = 'Play [game=668] or [game=668?set=8659]';
+
+ const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [
+ createGame({ id: 668, title: 'Sonic the Hedgehog' }),
+ createGame({ id: 29895, title: 'Sonic the Hedgehog [Subset - Perfect Bonus]' }),
+ ],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'Play [game=668] or [game=29895]',
+ },
+ });
+
+ const { result } = renderHook(() => useShortcodeBodyPreview());
+
+ // ACT
+ await act(async () => {
+ await result.current.initiatePreview(contentWithMixedShortcodes);
+ });
+
+ // ASSERT
+ await waitFor(() => {
+ expect(postSpy).toHaveBeenCalledWith(['api.shortcode-body.preview'], {
+ body: contentWithMixedShortcodes,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.previewContent).toEqual('Play [game=668] or [game=29895]');
+ });
+ });
+
+ it('given content with a game set shortcode but no backing game found, falls back to the original shortcode', async () => {
+ // ARRANGE
+ const contentWithInvalidSetId = 'Check out [game=668?set=99999]';
+
+ vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'Check out [game=668?set=99999]',
+ },
+ });
+
+ const { result } = renderHook(() => useShortcodeBodyPreview());
+
+ // ACT
+ await act(async () => {
+ await result.current.initiatePreview(contentWithInvalidSetId);
+ });
+
+ // ASSERT
+ await waitFor(() => {
+ expect(result.current.previewContent).toEqual('Check out [game=668?set=99999]');
+ });
+ });
+
+ it('given content with multiple game set shortcodes but only some have backing games, converts stuff correctly', async () => {
+ // ARRANGE
+ const contentWithMixedValidSets = 'Try [game=1?set=9534] and [game=2?set=8888]';
+
+ vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [createGame({ id: 29895, title: 'Sonic Subset' })],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'Try [game=29895] and [game=2?set=8888]',
+ },
+ });
+
+ const { result } = renderHook(() => useShortcodeBodyPreview());
+
+ // ACT
+ await act(async () => {
+ await result.current.initiatePreview(contentWithMixedValidSets);
+ });
+
+ // ASSERT
+ await waitFor(() => {
+ expect(result.current.previewContent).toEqual('Try [game=29895] and [game=2?set=8888]'); // !!
+ });
+ });
+
+ it('given content with both regular games and set shortcodes but the backend returns no backing games for sets, keeps set shortcodes unchanged', async () => {
+ // ARRANGE
+ const contentWithMixed = 'Play [game=668] or [game=1?set=9534]';
+
+ vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [
+ createGame({ id: 668, title: 'Sonic the Hedgehog' }),
+ // ... no backing game for set ID 9534 ...
+ ],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'Play [game=668] or [game=1?set=9534]',
+ },
+ });
+
+ const { result } = renderHook(() => useShortcodeBodyPreview());
+
+ // ACT
+ await act(async () => {
+ await result.current.initiatePreview(contentWithMixed);
+ });
+
+ // ASSERT
+ await waitFor(() => {
+ expect(result.current.previewContent).toEqual('Play [game=668] or [game=1?set=9534]');
+ });
+ });
});
diff --git a/resources/js/common/hooks/useShortcodeBodyPreview.ts b/resources/js/common/hooks/useShortcodeBodyPreview.ts
index 0598b53160..6beada3bde 100644
--- a/resources/js/common/hooks/useShortcodeBodyPreview.ts
+++ b/resources/js/common/hooks/useShortcodeBodyPreview.ts
@@ -9,7 +9,6 @@ import {
persistedTicketsAtom,
persistedUsersAtom,
} from '@/common/state/shortcode.atoms';
-import { extractDynamicEntitiesFromBody } from '@/common/utils/shortcodes/extractDynamicEntitiesFromBody';
import { preProcessShortcodesInBody } from '@/common/utils/shortcodes/preProcessShortcodesInBody';
import type { ShortcodeBodyPreviewMutationResponse } from '../models';
@@ -44,20 +43,13 @@ export function useShortcodeBodyPreview() {
// Normalize any internal URLs to shortcode format.
const normalizedBody = preProcessShortcodesInBody(body);
- // Then, extract dynamic entities from the normalized content.
- const dynamicEntities = extractDynamicEntitiesFromBody(normalizedBody);
+ // Send the body to the server for entity extraction and fetching.
+ // The server will handle normalizing, converting [game=X?set=Y] to backing game IDs, and extracting entities.
+ const response = await mutation.mutateAsync(normalizedBody);
+ mergeRetrievedEntities(response.data);
- // Do we have any dynamic entities to fetch from the server?
- // If not, we'll skip a round trip to the server to make the preview seem instantaneous.
- const hasDynamicEntities = Object.values(dynamicEntities).some((arr) => arr.length > 0);
-
- // If there are no dynamic entities in the post content, skip the round trip to the server.
- if (hasDynamicEntities) {
- const response = await mutation.mutateAsync(dynamicEntities);
- mergeRetrievedEntities(response.data);
- }
-
- setPreviewContent(normalizedBody);
+ // Use the converted body from the server (eg: "[game=X?set=Y]"" has been converted to "[game=backingGameId]").
+ setPreviewContent(response.data.convertedBody);
};
/**
diff --git a/resources/js/common/models/shortcode/dynamic-shortcode-entities.model.ts b/resources/js/common/models/shortcode/dynamic-shortcode-entities.model.ts
deleted file mode 100644
index f692223e03..0000000000
--- a/resources/js/common/models/shortcode/dynamic-shortcode-entities.model.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface DynamicShortcodeEntities {
- achievementIds: number[];
- gameIds: number[];
- hubIds: number[];
- eventIds: number[];
- ticketIds: number[];
- usernames: string[];
-}
diff --git a/resources/js/common/models/shortcode/index.ts b/resources/js/common/models/shortcode/index.ts
index babd5dc56a..724c01f612 100644
--- a/resources/js/common/models/shortcode/index.ts
+++ b/resources/js/common/models/shortcode/index.ts
@@ -1,4 +1,3 @@
-export * from './dynamic-shortcode-entities.model';
export * from './processed-video.model';
export * from './shortcode.model';
export * from './shortcode-preview-mutation-response.model';
diff --git a/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts b/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts
index 746af989d4..3a10dc771e 100644
--- a/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts
+++ b/resources/js/common/models/shortcode/shortcode-preview-mutation-response.model.ts
@@ -1,8 +1,9 @@
export interface ShortcodeBodyPreviewMutationResponse {
achievements: App.Platform.Data.Achievement[];
+ convertedBody: string;
+ events: App.Platform.Data.Event[];
games: App.Platform.Data.Game[];
hubs: App.Platform.Data.GameSet[];
- events: App.Platform.Data.Event[];
tickets: App.Platform.Data.Ticket[];
users: App.Data.User[];
}
diff --git a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.test.ts b/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.test.ts
deleted file mode 100644
index e6d2e49a4f..0000000000
--- a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { extractDynamicEntitiesFromBody } from './extractDynamicEntitiesFromBody';
-
-describe('Util: extractDynamicEntitiesFromBody', () => {
- it('is defined', () => {
- // ASSERT
- expect(extractDynamicEntitiesFromBody).toBeDefined();
- });
-
- it('given the input contains user shortcodes, extracts and dedupes all usernames', () => {
- // ARRANGE
- const input = 'Hello [user=Jamiras] and [user=Scott] and [user=Jamiras] again';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.usernames).toEqual(['Jamiras', 'Scott']);
- });
-
- it('given the input contains ticket shortcodes, extracts and dedupes all ticket IDs', () => {
- // ARRANGE
- const input = 'Check tickets [ticket=123] and [ticket=456] and [ticket=123].';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.ticketIds).toEqual([123, 456]);
- });
-
- it('given the input contains achievement shortcodes, extracts all achievement IDs', () => {
- // ARRANGE
- const input = 'I earned [ach=9] and [ach=14402]!';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.achievementIds).toEqual([9, 14402]);
- });
-
- it('given the input contains game shortcodes, extracts and dedupes all game IDs', () => {
- // ARRANGE
- const input = 'I like to play [game=1] and [game=14402] and [game=1].';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.gameIds).toEqual([1, 14402]);
- });
-
- it('given the input contains hub shortcodes, extracts and dedupes all hub IDs', () => {
- // ARRANGE
- const input = 'I like to visit [hub=1] and [hub=2] and [hub=1].';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.hubIds).toEqual([1, 2]);
- });
-
- it('given the input contains event shortcodes, extracts and dedupes all event IDs', () => {
- // ARRANGE
- const input = 'I play in [event=1] and [event=2] and [event=1].';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.eventIds).toEqual([1, 2]);
- });
-
- it('given the input contains invalid numeric IDs, ignores them', () => {
- // ARRANGE
- const input = '[ticket=abc] [ach=def] [game=xyz]';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.ticketIds).toEqual([]);
- expect(result.achievementIds).toEqual([]);
- expect(result.gameIds).toEqual([]);
- });
-
- it('given the input contains multiple types of shortcodes, extracts all entities correctly', () => {
- // ARRANGE
- const input = '[user=xelnia] completed [ach=9] in [game=1] and created [ticket=12345].';
-
- // ACT
- const result = extractDynamicEntitiesFromBody(input);
-
- // ASSERT
- expect(result.usernames).toEqual(['xelnia']);
- expect(result.achievementIds).toEqual([9]);
- expect(result.gameIds).toEqual([1]);
- expect(result.ticketIds).toEqual([12345]);
- });
-});
diff --git a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.ts b/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.ts
deleted file mode 100644
index 6712794673..0000000000
--- a/resources/js/common/utils/shortcodes/extractDynamicEntitiesFromBody.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { DynamicShortcodeEntities } from '@/common/models';
-
-const shortcodePatterns = {
- achievement: /\[ach=([^\]]+)\]/g,
- game: /\[game=([^\]]+)\]/g,
- hub: /\[hub=([^\]]+)\]/g,
- event: /\[event=([^\]]+)\]/g,
- ticket: /\[ticket=([^\]]+)\]/g,
- user: /\[user=([^\]]+)\]/g,
-};
-
-export function extractDynamicEntitiesFromBody(input: string): DynamicShortcodeEntities {
- const entities: DynamicShortcodeEntities = {
- achievementIds: [],
- gameIds: [],
- hubIds: [],
- eventIds: [],
- ticketIds: [],
- usernames: [],
- };
-
- // Extract achievement IDs.
- for (const match of input.matchAll(shortcodePatterns.achievement)) {
- const id = parseInt(match[1], 10);
- if (!isNaN(id)) entities.achievementIds.push(id);
- }
-
- // Extract game IDs.
- for (const match of input.matchAll(shortcodePatterns.game)) {
- const id = parseInt(match[1], 10);
- if (!isNaN(id)) entities.gameIds.push(id);
- }
-
- // Extract hub IDs.
- for (const match of input.matchAll(shortcodePatterns.hub)) {
- const id = parseInt(match[1], 10);
- if (!isNaN(id)) entities.hubIds.push(id);
- }
-
- // Extract event IDs.
- for (const match of input.matchAll(shortcodePatterns.event)) {
- const id = parseInt(match[1], 10);
- if (!isNaN(id)) entities.eventIds.push(id);
- }
-
- // Extract ticket IDs.
- for (const match of input.matchAll(shortcodePatterns.ticket)) {
- const id = parseInt(match[1], 10);
- if (!isNaN(id)) entities.ticketIds.push(id);
- }
-
- // Extract usernames.
- for (const match of input.matchAll(shortcodePatterns.user)) {
- entities.usernames.push(match[1]);
- }
-
- return {
- achievementIds: [...new Set(entities.achievementIds)],
- gameIds: [...new Set(entities.gameIds)],
- hubIds: [...new Set(entities.hubIds)],
- eventIds: [...new Set(entities.eventIds)],
- ticketIds: [...new Set(entities.ticketIds)],
- usernames: [...new Set(entities.usernames)],
- };
-}
diff --git a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts
index 1762b0555f..3334524b30 100644
--- a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts
+++ b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.test.ts
@@ -205,4 +205,139 @@ describe('Util: preProcessShortcodesInBody', () => {
// ASSERT
expect(result).toEqual('Check out [user=Scott]');
});
+
+ it('converts game URL with ?set= query parameter to shortcode', () => {
+ // ARRANGE
+ const input = 'Check out https://retroachievements.org/game/668?set=8659';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Check out [game=668?set=8659]');
+ });
+
+ it('converts game URL with ?set= parameter and slug to shortcode', () => {
+ // ARRANGE
+ const input = 'Try https://retroachievements.org/game/668-pokemon-emerald-version?set=8659';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Try [game=668?set=8659]');
+ });
+
+ it('converts localhost game URL with ?set= parameter to shortcode', () => {
+ // ARRANGE
+ const input = 'Dev: http://localhost:64000/game/123?set=456';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Dev: [game=123?set=456]');
+ });
+
+ it('handles mixed game URLs with and without ?set= parameter', () => {
+ // ARRANGE
+ const input =
+ 'Play https://retroachievements.org/game/668 or try https://retroachievements.org/game/668?set=8659';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Play [game=668] or try [game=668?set=8659]');
+ });
+
+ it('prioritizes game URLs with ?set= parameter over regular game URLs', () => {
+ // ARRANGE
+ const input = 'https://retroachievements.org/game/1?set=9534';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ // ... should become [game=1?set=9534], not [game=1] ...
+ expect(result).toEqual('[game=1?set=9534]');
+ });
+
+ it('converts /game2/ URLs to shortcode', () => {
+ // ARRANGE
+ const input = 'Check out https://retroachievements.org/game2/1234';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Check out [game=1234]');
+ });
+
+ it('converts /game2/ URLs with slugs to shortcode', () => {
+ // ARRANGE
+ const input = 'Check out https://retroachievements.org/game2/1234-super-mario-64';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Check out [game=1234]');
+ });
+
+ it('converts /game2/ URLs with ?set= query params to shortcode', () => {
+ // ARRANGE
+ const input = 'Check out https://retroachievements.org/game2/668?set=8659';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Check out [game=668?set=8659]');
+ });
+
+ it('converts /game2/ URLs with ?set= parameters and slugs to shortcode', () => {
+ // ARRANGE
+ const input = 'Try https://retroachievements.org/game2/668-pokemon-emerald-version?set=8659';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Try [game=668?set=8659]');
+ });
+
+ it('converts localhost /game2/ URLs with ?set= parameters to shortcode', () => {
+ // ARRANGE
+ const input = 'Dev: http://localhost:64000/game2/123?set=456';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Dev: [game=123?set=456]');
+ });
+
+ it('handles mixed /game/ and /game2/ URLs', () => {
+ // ARRANGE
+ const input =
+ 'Play https://retroachievements.org/game/668 or try https://retroachievements.org/game2/668?set=8659';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('Play [game=668] or try [game=668?set=8659]');
+ });
+
+ it('converts /game2/ BBCode url tags to shortcode', () => {
+ // ARRANGE
+ const input = '[url=https://retroachievements.org/game2/1234]Cool Game[/url]';
+
+ // ACT
+ const result = preProcessShortcodesInBody(input);
+
+ // ASSERT
+ expect(result).toEqual('[game=1234]');
+ });
});
diff --git a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts
index 103c5515a3..f74a50b4d3 100644
--- a/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts
+++ b/resources/js/common/utils/shortcodes/preProcessShortcodesInBody.ts
@@ -7,31 +7,55 @@ const shortcodeTypes = [
{ type: 'user', shortcode: 'user' },
] as const;
-const createPatterns = (type: string) => [
- // HTML anchor tags.
- new RegExp(
- `]*retroachievements\\.org/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^/>]*\\][^]*`,
- 'gi',
- ),
-
- // BBCode url tags.
- new RegExp(
- `\\[url[^\\]]*retroachievements\\.org/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^\\]]*\\][^\\[]*\\[/url\\]`,
- 'gi',
- ),
-
- // Direct production URLs.
- new RegExp(
- `https?://(?:[\\w-]+\\.)?retroachievements\\.org/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`,
- 'gi',
- ),
-
- // Local development URLs.
- new RegExp(
- `https?://localhost(?::\\d{1,5})?/${type}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`,
- 'gi',
- ),
-];
+const createPatterns = (type: string) => {
+ // For games, match both /game/ and /game2/ URLs.
+ const pathPattern = type === 'game' ? 'game2?' : type;
+
+ const patterns = [
+ // HTML anchor tags.
+ new RegExp(
+ `]*retroachievements\\.org/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^/>]*\\][^]*`,
+ 'gi',
+ ),
+
+ // BBCode url tags.
+ new RegExp(
+ `\\[url[^\\]]*retroachievements\\.org/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))[^\\]]*\\][^\\[]*\\[/url\\]`,
+ 'gi',
+ ),
+
+ // Direct production URLs without query params.
+ new RegExp(
+ `https?://(?:[\\w-]+\\.)?retroachievements\\.org/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`,
+ 'gi',
+ ),
+
+ // Local development URLs without query params.
+ new RegExp(
+ `https?://localhost(?::\\d{1,5})?/${pathPattern}/(\\w{1,20})(?:-[^\\s"'<>]*)?(/?(?![\\w/?]))`,
+ 'gi',
+ ),
+ ];
+
+ // For games specifically, also handle URLs with ?set= query parameter.
+ if (type === 'game') {
+ patterns.unshift(
+ // Production URLs with a ?set= parameter.
+ new RegExp(
+ `https?://(?:[\\w-]+\\.)?retroachievements\\.org/game2?/(\\w{1,20})(?:-[^\\s"'<>]*)?(?:/)?\\?set=(\\d+)`,
+ 'gi',
+ ),
+
+ // Local development URLs with a ?set= parameter.
+ new RegExp(
+ `https?://localhost(?::\\d{1,5})?/game2?/(\\w{1,20})(?:-[^\\s"'<>]*)?(?:/)?\\?set=(\\d+)`,
+ 'gi',
+ ),
+ );
+ }
+
+ return patterns;
+};
export function preProcessShortcodesInBody(body: string): string {
// First, normalize any escaped newlines back to actual newlines.
@@ -42,8 +66,15 @@ export function preProcessShortcodesInBody(body: string): string {
for (const { type, shortcode } of shortcodeTypes) {
const patterns = createPatterns(type);
- for (const regex of patterns) {
- result = result.replaceAll(regex, `[${shortcode}=$1]`);
+ for (let i = 0; i < patterns.length; i++) {
+ const regex = patterns[i];
+
+ // Special handling for game URLs with a ?set= parameter (first two patterns for games).
+ if (type === 'game' && i < 2) {
+ result = result.replaceAll(regex, `[${shortcode}=$1?set=$2]`);
+ } else {
+ result = result.replaceAll(regex, `[${shortcode}=$1]`);
+ }
}
}
diff --git a/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx b/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx
index 4e7eca3f2c..5322bfea35 100644
--- a/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx
+++ b/resources/js/features/messages/components/+create/MessagesCreateRoot.test.tsx
@@ -1,4 +1,5 @@
import userEvent from '@testing-library/user-event';
+import axios from 'axios';
import { createAuthenticatedUser } from '@/common/models';
import { render, screen, waitFor } from '@/test';
@@ -27,6 +28,18 @@ describe('Component: MessagesCreateRoot', () => {
it('given the user previews their message, shows the preview content', async () => {
// ARRANGE
+ vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'hello world',
+ },
+ });
+
render(, {
pageProps: {
auth: { user: createAuthenticatedUser() },
diff --git a/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx b/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx
index 30767e5068..6eb5945076 100644
--- a/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx
+++ b/resources/js/features/messages/components/+show/MessagesShowRoot.test.tsx
@@ -1,5 +1,6 @@
import { router } from '@inertiajs/react';
import userEvent from '@testing-library/user-event';
+import axios from 'axios';
import { route } from 'ziggy-js';
import { createAuthenticatedUser } from '@/common/models';
@@ -106,6 +107,18 @@ describe('Component: MessagesShowRoot', () => {
it('given the user previews a message, shows the preview content', async () => {
// ARRANGE
+ vi.spyOn(axios, 'post').mockResolvedValueOnce({
+ data: {
+ achievements: [],
+ games: [],
+ hubs: [],
+ events: [],
+ tickets: [],
+ users: [],
+ convertedBody: 'hello world',
+ },
+ });
+
const messageThread = createMessageThread();
const paginatedMessages = createPaginatedData([createMessage()]);
@@ -126,7 +139,9 @@ describe('Component: MessagesShowRoot', () => {
await userEvent.click(previewButton);
// ASSERT
- expect(screen.getAllByText(/hello world/i).length).toEqual(2); // textarea and preview div
+ await waitFor(() => {
+ expect(screen.getAllByText(/hello world/i).length).toEqual(2); // textarea and preview div
+ });
});
it('given the user paginates, changes the current route correctly', async () => {
diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts
index 18ebb5fe6e..f5d7d103f9 100644
--- a/resources/js/types/generated.d.ts
+++ b/resources/js/types/generated.d.ts
@@ -162,6 +162,7 @@ declare namespace App.Community.Data {
games: Array;
hubs: Array;
events: Array;
+ convertedBody: string;
};
export type Subscription = {
id: number;
diff --git a/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php b/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php
index 80862923e4..5003e8cfb9 100644
--- a/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php
+++ b/tests/Feature/Community/Controllers/Api/ShortcodeApiControllerTest.php
@@ -17,12 +17,7 @@ public function testItValidatesRequiredFields(): void
$this->withoutMiddleware();
$payload = [
- // !! missing required "usernames" array
- 'ticketIds' => [],
- 'achievementIds' => [],
- 'gameIds' => [],
- 'hubIds' => [],
- 'eventIds' => [],
+ // ... missing required "body" field ...
];
// Act
@@ -30,35 +25,6 @@ public function testItValidatesRequiredFields(): void
// Assert
$response->assertUnprocessable();
- $response->assertJsonValidationErrors(['usernames']);
- }
-
- public function testItReturnsEmptyCollectionsWhenNoIdsProvided(): void
- {
- // Arrange
- $this->withoutMiddleware();
-
- $payload = [
- 'usernames' => [],
- 'ticketIds' => [],
- 'achievementIds' => [],
- 'gameIds' => [],
- 'hubIds' => [],
- 'eventIds' => [],
- ];
-
- // Act
- $response = $this->postJson(route('api.shortcode-body.preview'), $payload);
-
- // Assert
- $response->assertOk();
- $response->assertExactJson([
- 'users' => [],
- 'tickets' => [],
- 'achievements' => [],
- 'games' => [],
- 'hubs' => [],
- 'events' => [],
- ]);
+ $response->assertJsonValidationErrors(['body']);
}
}
diff --git a/tests/Feature/Community/ShortcodeTest.php b/tests/Feature/Community/ShortcodeTest.php
index 29edbc660c..c8cbd03719 100644
--- a/tests/Feature/Community/ShortcodeTest.php
+++ b/tests/Feature/Community/ShortcodeTest.php
@@ -232,6 +232,42 @@ public function testNormalizeGameShortcodes(): void
);
}
+ public function testNormalizeGame2Shortcodes(): void
+ {
+ $rawString = 'https://retroachievements.org/game2/1';
+
+ $normalized = normalize_shortcodes($rawString);
+
+ $this->assertEquals(
+ '[game=1]',
+ $normalized
+ );
+ }
+
+ public function testNormalizeGameShortcodesWithSetParam(): void
+ {
+ $rawString = 'https://retroachievements.org/game/668?set=8659';
+
+ $normalized = normalize_shortcodes($rawString);
+
+ $this->assertEquals(
+ '[game=668?set=8659]',
+ $normalized
+ );
+ }
+
+ public function testNormalizeGame2ShortcodesWithSetParam(): void
+ {
+ $rawString = 'https://retroachievements.org/game2/668?set=8659';
+
+ $normalized = normalize_shortcodes($rawString);
+
+ $this->assertEquals(
+ '[game=668?set=8659]',
+ $normalized
+ );
+ }
+
public function testNormalizeHubShortcodes(): void
{
$rawString = 'https://retroachievements.org/hub/1';