mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
fix(dashboard): conceal locked hidden badge details (#11605)
Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
1
changelog.d/fixes/11605-profile-hidden-badge-details.md
Normal file
1
changelog.d/fixes/11605-profile-hidden-badge-details.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** Prevent locked hidden badges from revealing their icon or opening private badge details before they are earned ([#11605](https://github.com/diegosouzapw/OmniRoute/pull/11605)) — thanks @pacocartones
|
||||
@@ -249,21 +249,23 @@ export default function ProfilePage() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{allBadges.map((badge) => {
|
||||
const isEarned = earnedIds.has(badge.id);
|
||||
const isHiddenAndLocked = Boolean(badge.hidden) && !isEarned;
|
||||
const earnedInfo = earnedBadges.find((b) => b.badgeId === badge.id);
|
||||
const rarityColor = RARITY_COLORS[badge.rarity] || RARITY_COLORS.common;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.id}
|
||||
onClick={() => setSelectedBadge(badge)}
|
||||
onClick={() => !isHiddenAndLocked && setSelectedBadge(badge)}
|
||||
disabled={isHiddenAndLocked}
|
||||
className={`relative p-4 rounded-xl border transition-all text-left ${
|
||||
isEarned
|
||||
? `${rarityColor} bg-surface hover:shadow-md`
|
||||
: "border-border/50 bg-surface/50 opacity-50 grayscale hover:opacity-70"
|
||||
: "border-border/50 bg-surface/50 opacity-50 grayscale enabled:hover:opacity-70 disabled:cursor-default"
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl mb-2">
|
||||
<BadgeIcon icon={badge.icon} earned={isEarned} />
|
||||
<BadgeIcon icon={isHiddenAndLocked ? null : badge.icon} earned={isEarned} />
|
||||
</div>
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{badge.hidden && !isEarned ? "???" : translateBadge(badge, "name")}
|
||||
|
||||
114
tests/unit/ui/profile-hidden-badge-confidentiality.test.tsx
Normal file
114
tests/unit/ui/profile-hidden-badge-confidentiality.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const translate = (key: string) => key;
|
||||
vi.mock("next-intl", () => ({
|
||||
useLocale: () => "en",
|
||||
useTranslations: () => Object.assign(translate, { has: () => false }),
|
||||
}));
|
||||
|
||||
const { default: ProfilePage } = await import("@/app/(dashboard)/dashboard/profile/page");
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLDivElement }> = [];
|
||||
|
||||
async function renderProfile(earned = false) {
|
||||
const hiddenBadge = {
|
||||
id: "secret-badge",
|
||||
name: "Secret Badge Name",
|
||||
description: "Secret badge description",
|
||||
icon: "rocket",
|
||||
category: "secret-category",
|
||||
rarity: "legendary",
|
||||
criteria: '{"type":"secret-condition"}',
|
||||
hidden: 1,
|
||||
createdAt: "2026-08-26T00:00:00.000Z",
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/level")) {
|
||||
return { ok: true, json: async () => ({ level: { totalXp: 0, currentLevel: 1 } }) };
|
||||
}
|
||||
if (url.endsWith("/earned")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
badges: earned
|
||||
? [{ badgeId: hiddenBadge.id, unlockedAt: "2026-08-26T00:00:00.000Z" }]
|
||||
: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({ badges: [hiddenBadge] }) };
|
||||
})
|
||||
);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push({ root, container });
|
||||
act(() => root.render(<ProfilePage />));
|
||||
|
||||
for (let i = 0; i < 40 && !container.querySelector("button"); i++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, container } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Profile hidden badge confidentiality", () => {
|
||||
it("does not disclose locked hidden badge metadata when activated", async () => {
|
||||
const container = await renderProfile();
|
||||
const badgeButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("???")
|
||||
);
|
||||
expect(badgeButton).toBeDefined();
|
||||
expect(container.textContent).not.toContain("rocket_launch");
|
||||
|
||||
await act(async () => {
|
||||
badgeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Secret Badge Name");
|
||||
expect(container.textContent).not.toContain("Secret badge description");
|
||||
expect(container.textContent).not.toContain("secret-category");
|
||||
expect(container.textContent).not.toContain("legendary");
|
||||
expect(container.textContent).not.toContain("secret-condition");
|
||||
});
|
||||
|
||||
it("reveals an earned hidden badge normally", async () => {
|
||||
const container = await renderProfile(true);
|
||||
const badgeButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Secret Badge Name")
|
||||
);
|
||||
expect(badgeButton).toBeDefined();
|
||||
expect((badgeButton as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
badgeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("secret-category");
|
||||
expect(container.textContent).toContain("rocket_launch");
|
||||
expect(container.textContent).toContain("Secret Badge Name");
|
||||
expect(container.textContent).toContain("Secret badge description");
|
||||
expect(container.textContent).toContain("legendary");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user