Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions src/app/(app)/repositories/_components/repo-settings-tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ vi.mock("sonner", () => ({

// Mock repositories API
const mockUpdate = vi.fn();
const mockGetCacheTtl = vi.fn();
const mockSetCacheTtl = vi.fn();
vi.mock("@/lib/api/repositories", () => ({
repositoriesApi: {
update: (...args: unknown[]) => mockUpdate(...args),
getCacheTtl: (...args: unknown[]) => mockGetCacheTtl(...args),
setCacheTtl: (...args: unknown[]) => mockSetCacheTtl(...args),
},
}));

Expand Down Expand Up @@ -751,3 +755,295 @@ describe("RepoSettingsTab - Quota unit switching", () => {
expect(screen.getByText("You have unsaved changes")).toBeTruthy();
});
});

// ---------------------------------------------------------------------------
// Proxy Cache section (#448) -- shown only for Remote (proxy) repos. Pin
// the visibility gate, the editing flow that plugs into the existing
// hasChanges / Save / Discard workflow, and the inline-validation that
// mirrors the backend's validate_cache_ttl range (1..=2_592_000).
// ---------------------------------------------------------------------------

const remoteRepo: Repository = {
...baseRepo,
id: "repo-2",
key: "pypi-remote",
name: "PyPI Remote",
repo_type: "remote",
upstream_url: "https://pypi.org",
};

describe("RepoSettingsTab - Proxy Cache section (#448)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListPolicies.mockResolvedValue([]);
// Default to a stable success response so the Proxy Cache section
// can render its initial value -- per-test overrides via
// .mockResolvedValueOnce / .mockRejectedValueOnce.
mockGetCacheTtl.mockResolvedValue({
repository_key: "pypi-remote",
cache_ttl_seconds: 86400,
});
});

it("hides the Proxy Cache section for Local repos", () => {
render(<RepoSettingsTab repository={baseRepo} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText("Proxy Cache")).toBeNull();
expect(screen.queryByLabelText("Cache TTL (seconds)")).toBeNull();
// No GET should be issued for non-Remote repos -- the useQuery is
// gated on `enabled: isRemote` to avoid wasted round-trips.
expect(mockGetCacheTtl).not.toHaveBeenCalled();
});

it("hides the Proxy Cache section for Virtual / Staging repos", () => {
const virtualRepo: Repository = { ...baseRepo, repo_type: "virtual" };
render(<RepoSettingsTab repository={virtualRepo} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText("Proxy Cache")).toBeNull();
expect(mockGetCacheTtl).not.toHaveBeenCalled();
});

it("renders the Proxy Cache section with the TTL fetched from the backend on Remote repos", async () => {
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
expect(screen.getByText("Proxy Cache")).toBeTruthy();
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});
expect(mockGetCacheTtl).toHaveBeenCalledWith("pypi-remote");
// The human-readable hint should display alongside the input -- 86400s
// is the backend's default fallback (artifact-keeper#917) and the
// helper picks the largest unit that gives an integer magnitude, so
// "1 day" rather than "24 hours".
expect(screen.getByText(/1 day/)).toBeTruthy();
});

it("triggers the unsaved-changes bar when the TTL is edited", async () => {
const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "3600");

expect(screen.getByText("You have unsaved changes")).toBeTruthy();
});

it("calls setCacheTtl on save and shows a success toast", async () => {
mockSetCacheTtl.mockResolvedValue({
repository_key: "pypi-remote",
cache_ttl_seconds: 3600,
});
const { toast } = await import("sonner");

const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "3600");

await user.click(screen.getByRole("button", { name: /save changes/i }));

await waitFor(() => {
expect(mockSetCacheTtl).toHaveBeenCalledWith("pypi-remote", 3600);
});
expect(toast.success).toHaveBeenCalledWith("Cache TTL saved");
// The general-fields update mutation must NOT fire when only the
// TTL changed -- the two endpoints are independent and we don't want
// to send an empty PATCH that the audit log would record as a no-op
// edit.
expect(mockUpdate).not.toHaveBeenCalled();
});

it("shows the inline error and disables Save for out-of-range TTL", async () => {
const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
// Above the backend max of 2,592,000 (30 days).
await user.type(input, "9999999");

expect(
screen.getByText(
/Must be a whole number between 1 and 2,592,000/i
)
).toBeTruthy();
expect(input).toHaveProperty("ariaInvalid", "true");
const save = screen.getByRole("button", { name: /save changes/i });
expect(save).toHaveProperty("disabled", true);
});

it("programmatically associates the validation error with the input (#448 a11y)", async () => {
const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "9999999");

// The error must be exposed as a live alert and linked from the input
// via aria-describedby so screen-reader users hear the explanation, not
// just "invalid". Mirrors the reference pattern in #459.
const error = screen.getByRole("alert");
expect(error.id).toBe("settings-cache-ttl-error");
expect(input.getAttribute("aria-describedby")).toBe(
"settings-cache-ttl-error"
);

// When the value becomes valid again, the association is removed.
await user.clear(input);
await user.type(input, "3600");
expect(screen.queryByRole("alert")).toBeNull();
expect(input.getAttribute("aria-describedby")).toBeNull();
});

it("disables Discard while a cache-TTL save is in flight (#448 review)", async () => {
// A never-resolving setCacheTtl keeps the mutation pending so we can
// assert the Discard button is guarded the same way Save is, preventing
// a Discard from racing an in-flight save.
let resolveSet: (() => void) | undefined;
mockSetCacheTtl.mockReturnValue(
new Promise<void>((resolve) => {
resolveSet = resolve;
})
);

const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "3600");
await user.click(screen.getByRole("button", { name: /save changes/i }));

await waitFor(() => {
expect(
screen.getByRole("button", { name: /discard/i })
).toHaveProperty("disabled", true);
});

// Let the in-flight mutation settle so the test doesn't leak a pending promise.
resolveSet?.();
});

it("zero is rejected as out-of-range (backend min is 1)", async () => {
const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "0");

expect(
screen.getByText(
/Must be a whole number between 1 and 2,592,000/i
)
).toBeTruthy();
});

it("Discard reverts the TTL override back to the fetched value", async () => {
const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "3600");
expect(screen.getByText("You have unsaved changes")).toBeTruthy();

await user.click(screen.getByRole("button", { name: /discard/i }));

expect(screen.queryByText("You have unsaved changes")).toBeNull();
expect(input).toHaveProperty("value", "86400");
});

it("shows an error toast when setCacheTtl fails (e.g. 503 from a misconfigured proxy)", async () => {
mockSetCacheTtl.mockRejectedValue(
new Error("proxy service not configured")
);
const { toast } = await import("sonner");

const user = userEvent.setup();
render(<RepoSettingsTab repository={remoteRepo} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByLabelText("Cache TTL (seconds)")).toHaveProperty(
"value",
"86400"
);
});

const input = screen.getByLabelText("Cache TTL (seconds)");
await user.clear(input);
await user.type(input, "3600");
await user.click(screen.getByRole("button", { name: /save changes/i }));

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to save cache TTL");
});
});
});
Loading
Loading