fix: restore portfolio loader and safe consent revocation
This commit is contained in:
1 parent
bff3e4d735
commit
a4e5e5c048
4 files changed
+315
-13
No files matched your search
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user