fix: restore portfolio loader and safe consent revocation

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-04 21:40:35 -03:00
1 parent bff3e4d735
commit a4e5e5c048
4 files changed
+315 -13

No files matched your search

+27
View File
@@ -223,4 +223,31 @@ describe("GoogleAnalytics consent", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
}); });
it("reports when revocation cannot be persisted", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(
screen.getByRole("alert", {
name: "Falha ao salvar a revogação",
}),
).toHaveTextContent(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
expect(analyticsWindow[GA_DISABLE_KEY]).not.toBe(true);
expect(
screen.getByRole("button", { name: "Revogar analytics" }),
).toBeVisible();
});
}); });
+17 -1
View File
@@ -98,6 +98,7 @@ const buttonClassName =
function GoogleAnalyticsConsent() { function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>(); const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false); const [preferencesOpen, setPreferencesOpen] = useState(false);
const [revokeError, setRevokeError] = useState<string | null>(null);
const configuredRef = useRef(false); const configuredRef = useRef(false);
const preferencesButtonRef = useRef<HTMLButtonElement>(null); const preferencesButtonRef = useRef<HTMLButtonElement>(null);
@@ -150,7 +151,13 @@ function GoogleAnalyticsConsent() {
} }
function revokeAnalytics() { function revokeAnalytics() {
persistConsent("denied"); if (!persistConsent("denied")) {
setRevokeError(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
return;
}
setRevokeError(null);
window[GA_DISABLE_KEY] = true; window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent); window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies(); expireAnalyticsCookies();
@@ -250,6 +257,15 @@ function GoogleAnalyticsConsent() {
Analytics está {consent === "granted" ? "ativo" : "desativado"}. Analytics está {consent === "granted" ? "ativo" : "desativado"}.
Dados de publicidade permanecem negados. Dados de publicidade permanecem negados.
</p> </p>
{revokeError && (
<p
role="alert"
aria-label="Falha ao salvar a revogação"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
>
{revokeError}
</p>
)}
<div className="mt-sp-4 flex flex-wrap gap-sp-2"> <div className="mt-sp-4 flex flex-wrap gap-sp-2">
{consent === "granted" ? ( {consent === "granted" ? (
<button <button
+216
View File
@@ -0,0 +1,216 @@
jest.mock("node:fs/promises", () => ({
readFile: jest.fn(),
}));
jest.mock("./project-health", () => ({
...jest.requireActual("./project-health"),
createProjectHealthSnapshot: jest.fn(),
}));
import { readFile } from "node:fs/promises";
import { loadFeaturedProjects, portfolio } from "./portfolio-data";
import { createProjectHealthSnapshot } from "./project-health";
const featuredIds = portfolio.projects
.filter(({ featured }) => featured !== false)
.map(({ id }) => id);
describe("loadFeaturedProjects", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(readFile).mockResolvedValue(
JSON.stringify({
checkedAt: "2026-07-11T05:31:16.931Z",
projects: {},
}) as never,
);
jest
.mocked(createProjectHealthSnapshot)
.mockRejectedValue(
new Error("remote refresh should not run during render"),
);
});
it("returns every featured project without network in production", async () => {
const nodeEnv = jest.replaceProperty(process.env, "NODE_ENV", "production");
jest.mocked(readFile).mockRejectedValueOnce(new Error("missing snapshot"));
try {
const result = await loadFeaturedProjects();
expect(result.map(({ id }) => id)).toEqual(featuredIds);
expect(
result.find(({ id }) => id === "sev-modaintima")?.demoAvailability,
).toBe("unknown");
expect(createProjectHealthSnapshot).not.toHaveBeenCalled();
} finally {
nodeEnv.restore();
}
});
it("does not refresh an old snapshot in production", async () => {
const nodeEnv = jest.replaceProperty(process.env, "NODE_ENV", "production");
try {
const result = await loadFeaturedProjects();
expect(result.map(({ id }) => id)).toEqual(featuredIds);
expect(createProjectHealthSnapshot).not.toHaveBeenCalled();
} finally {
nodeEnv.restore();
}
});
it("refreshes an old snapshot in development", async () => {
const nodeEnv = jest.replaceProperty(
process.env,
"NODE_ENV",
"development",
);
const checkedAt = new Date().toISOString();
jest.mocked(createProjectHealthSnapshot).mockResolvedValue({
checkedAt,
projects: {
"sev-modaintima": {
status: 200,
contentType: "text/html; charset=utf-8",
checkedAt,
},
},
});
try {
const result = await loadFeaturedProjects();
expect(result.map(({ id }) => id)).toEqual(featuredIds);
expect(
result.find(({ id }) => id === "sev-modaintima")?.demoAvailability,
).toBe("available");
expect(createProjectHealthSnapshot).toHaveBeenCalledTimes(1);
expect(createProjectHealthSnapshot).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: "sev-modaintima",
demoUrl: "https://sevmodaintima.com.br",
}),
]),
);
} finally {
nodeEnv.restore();
}
});
it("does not refresh a fresh snapshot in development", async () => {
const nodeEnv = jest.replaceProperty(
process.env,
"NODE_ENV",
"development",
);
const checkedAt = new Date().toISOString();
jest.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({
checkedAt,
projects: {
"sev-modaintima": {
status: 200,
contentType: "text/html",
checkedAt,
},
},
}) as never,
);
try {
const result = await loadFeaturedProjects();
expect(
result.find(({ id }) => id === "sev-modaintima")?.demoAvailability,
).toBe("available");
expect(createProjectHealthSnapshot).not.toHaveBeenCalled();
} finally {
nodeEnv.restore();
}
});
it("refreshes a missing snapshot in development", async () => {
const nodeEnv = jest.replaceProperty(
process.env,
"NODE_ENV",
"development",
);
const checkedAt = new Date().toISOString();
jest.mocked(readFile).mockRejectedValueOnce(new Error("missing snapshot"));
jest.mocked(createProjectHealthSnapshot).mockResolvedValue({
checkedAt,
projects: {
"sev-modaintima": {
status: 200,
contentType: "text/html",
checkedAt,
},
},
});
try {
const result = await loadFeaturedProjects();
expect(result.map(({ id }) => id)).toEqual(featuredIds);
expect(createProjectHealthSnapshot).toHaveBeenCalledTimes(1);
} finally {
nodeEnv.restore();
}
});
it("preserves prior observations when refreshed checks fail", async () => {
const nodeEnv = jest.replaceProperty(
process.env,
"NODE_ENV",
"development",
);
const refreshedAt = new Date().toISOString();
jest.mocked(readFile).mockResolvedValueOnce(
JSON.stringify({
checkedAt: "2020-01-01T00:00:00.000Z",
projects: {
"sev-modaintima": {
status: 200,
contentType: "text/html",
checkedAt: "2020-01-01T00:00:00.000Z",
},
},
}) as never,
);
jest.mocked(createProjectHealthSnapshot).mockResolvedValue({
checkedAt: refreshedAt,
projects: {
"sev-modaintima": {
status: null,
contentType: null,
checkedAt: refreshedAt,
error: "network unavailable",
},
"twins-lab": {
status: null,
contentType: null,
checkedAt: refreshedAt,
error: "network unavailable",
},
},
});
try {
const result = await loadFeaturedProjects();
const previous = result.find(({ id }) => id === "sev-modaintima");
const unknown = result.find(({ id }) => id === "twins-lab");
expect(result.map(({ id }) => id)).toEqual(featuredIds);
expect(previous?.demoAvailability).toBe("available");
expect(previous?.demoCheckedAt).toBe("2020-01-01T00:00:00.000Z");
expect(unknown?.demoAvailability).toBe("unknown");
expect(unknown?.demoCheckedAt).toBeNull();
expect(createProjectHealthSnapshot).toHaveBeenCalledTimes(1);
} finally {
nodeEnv.restore();
}
});
});
+55 -12
View File
@@ -3,7 +3,9 @@ import { resolve } from "node:path";
import portfolioData from "@/public/portfolio-data.json"; import portfolioData from "@/public/portfolio-data.json";
import { import {
createProjectHealthSnapshot, createProjectHealthSnapshot,
filterLiveProjects, type DemoAvailability,
getDemoAvailability,
PROJECT_HEALTH_MAX_AGE_MS,
type ProjectHealthSnapshot, type ProjectHealthSnapshot,
} from "./project-health"; } from "./project-health";
@@ -13,16 +15,37 @@ export type PortfolioExperience = PortfolioData["experiences"][number];
export type PortfolioSkillCategory = export type PortfolioSkillCategory =
PortfolioData["skills"]["technical"][number]; PortfolioData["skills"]["technical"][number];
export type PortfolioProjectView = PortfolioProject & {
demoAvailability: DemoAvailability;
demoCheckedAt: string | null;
};
export const portfolio = portfolioData; export const portfolio = portfolioData;
export function getLiveFeaturedProjects( export function getFeaturedProjects(
snapshot: ProjectHealthSnapshot | null,
): PortfolioProjectView[] {
return portfolio.projects
.filter(({ featured }) => featured !== false)
.map((project) => {
const check = snapshot?.projects[project.id];
return {
...project,
demoAvailability: getDemoAvailability(project.demoUrl, check),
demoCheckedAt: check?.checkedAt ?? null,
};
});
}
function isSnapshotFresh(
snapshot: ProjectHealthSnapshot | null, snapshot: ProjectHealthSnapshot | null,
now = new Date(), now = new Date(),
) { ) {
return filterLiveProjects<PortfolioProject>( if (!snapshot) return false;
portfolio.projects, const checkedAt = Date.parse(snapshot.checkedAt);
snapshot, return (
now, Number.isFinite(checkedAt) &&
now.getTime() - checkedAt <= PROJECT_HEALTH_MAX_AGE_MS
); );
} }
@@ -35,16 +58,36 @@ export async function loadProjectHealthSnapshot(): Promise<ProjectHealthSnapshot
} }
} }
export async function loadLiveFeaturedProjects() { export async function loadFeaturedProjects(): Promise<PortfolioProjectView[]> {
const fromSnapshot = getLiveFeaturedProjects(await loadProjectHealthSnapshot()); const snapshot = await loadProjectHealthSnapshot();
if (fromSnapshot.length > 0) return fromSnapshot; if (process.env.NODE_ENV !== "development" || isSnapshotFresh(snapshot)) {
return getFeaturedProjects(snapshot);
}
const projectDemos = portfolio.projects.reduce< const projectDemos = portfolio.projects.reduce<
Array<{ id: string; demoUrl: string }> Array<{ id: string; demoUrl: string }>
>((demos, project) => { >((demos, project) => {
if (project.demoUrl) demos.push({ id: project.id, demoUrl: project.demoUrl }); if (project.demoUrl)
demos.push({ id: project.id, demoUrl: project.demoUrl });
return demos; return demos;
}, []); }, []);
const freshSnapshot = await createProjectHealthSnapshot(projectDemos);
return getLiveFeaturedProjects(freshSnapshot); try {
const refreshed = await createProjectHealthSnapshot(projectDemos);
const projects = { ...refreshed.projects };
for (const [id, check] of Object.entries(projects)) {
if (!check.error && check.status !== null) continue;
const previous = snapshot?.projects[id];
if (previous && !previous.error && previous.status !== null) {
projects[id] = previous;
} else {
delete projects[id];
}
}
return getFeaturedProjects({ ...refreshed, projects });
} catch {
return getFeaturedProjects(snapshot);
}
} }