Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`release/1.1.x` maintenance branch + `:1.1-dev` Docker tag rule** (#331) - mirrors `artifact-keeper#890`; pushes to `release/1.1.x` now publish `ghcr.io/artifact-keeper/artifact-keeper-web:1.1-dev` so the v1.1.x release-gate can test a true v1.1.x web/backend pair.

### Changed
- **Type-safe API layer — extend #206 hardening to replication** (#359 batch 4) - replaced all 11 `as never` casts in `src/lib/api/replication.ts` with adapter functions, `assertData` guards, and `narrowEnum` for the `PeerStatus` union. Dropped three dead fields from `PeerInstance` (`api_key`/`sync_filter`/`updated_at`) and one from `PeerConnection` (`source_peer_id`) — all four were declared on the local types but never populated by the SDK and never read by any consumer (verified via grep). The peers list and connections table render unchanged.
- **Type-safe API layer — extend #206 hardening to telemetry** (#359 batch 3) - replaced all 9 `as never` casts in `src/lib/api/telemetry.ts` with adapter functions, `assertData` guards, and explicit body forwarding. CrashReport's optional+nullable fields (`stack_trace`, `os_info`, `uptime_seconds`, `submitted_at`, `submission_error`) now normalize undefined → null. Pages that consume this API are unchanged.
- **Type-safe API layer — extend #206 hardening to webhooks + analytics** (#359 batch 2) - replaced all 9 `as never` casts in `src/lib/api/webhooks.ts` and all 11 in `src/lib/api/analytics.ts` with adapter functions, `assertData` guards, and `narrowEnum` for the `WebhookEvent` string-to-union narrowing. Webhook events that the web doesn't model yet now fall back to `artifact_uploaded` with a console warning instead of crashing render code expecting a known event. Pages that consume these APIs are unchanged.
- **Type-safe API layer — extend #206 hardening to monitoring + lifecycle** (#359 batch 1) - replaced all `as never` casts in `src/lib/api/monitoring.ts` and `src/lib/api/lifecycle.ts` with adapter functions and `assertData` guards. Adapters normalize the SDK's `?: string | null` (optional + nullable) shape to the local types' `: string | null` (required + nullable) shape so callers see a stable contract. Two `as unknown as` casts remain in `lifecycle.ts` and are commented inline: the SDK incorrectly types `createLifecyclePolicy` / `updateLifecyclePolicy` bodies as the security-policy request shape rather than the lifecycle request shape — to be removed when the generator is rebuilt against the corrected OpenAPI spec. Pages that consume these APIs are unchanged.
Expand Down
211 changes: 186 additions & 25 deletions src/lib/api/__tests__/replication.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type {
IdentityResponse as SdkIdentityResponse,
PeerInstanceResponse as SdkPeerInstanceResponse,
PeerResponse as SdkPeerResponse,
} from "@artifact-keeper/sdk";

vi.mock("@/lib/sdk-client", () => ({}));

Expand Down Expand Up @@ -28,14 +33,59 @@ vi.mock("@artifact-keeper/sdk", () => ({
listPeerConnections: (...args: unknown[]) => mockListPeerConnections(...args),
}));

const SDK_IDENTITY: SdkIdentityResponse = {
peer_id: "p1",
name: "us-east",
endpoint_url: "https://us.example.com",
};

const SDK_PEER: SdkPeerInstanceResponse = {
id: "p1",
name: "us-east",
endpoint_url: "https://us.example.com",
status: "online",
region: "us-east-1",
cache_size_bytes: 1_000_000,
cache_usage_percent: 50,
cache_used_bytes: 500_000,
is_local: false,
last_heartbeat_at: "2026-05-01T00:00:00Z",
last_sync_at: "2026-05-01T00:00:00Z",
created_at: "2026-04-01T00:00:00Z",
};

const SDK_CONNECTION: SdkPeerResponse = {
id: "c1",
target_peer_id: "p2",
status: "active",
latency_ms: 12,
bandwidth_estimate_bps: 1_000_000,
shared_artifacts_count: 100,
shared_chunks_count: 200,
bytes_transferred_total: 1_000_000_000,
transfer_success_count: 50,
transfer_failure_count: 1,
last_probed_at: "2026-05-01T00:00:00Z",
last_transfer_at: "2026-05-01T00:00:00Z",
};

describe("peersApi", () => {
beforeEach(() => vi.clearAllMocks());

it("getIdentity returns identity", async () => {
const data = { peer_id: "p1", name: "us-east" };
mockGetIdentity.mockResolvedValue({ data, error: undefined });
mockGetIdentity.mockResolvedValue({ data: SDK_IDENTITY, error: undefined });
const { peersApi } = await import("../replication");
expect(await peersApi.getIdentity()).toEqual(data);
expect(await peersApi.getIdentity()).toEqual({
peer_id: "p1",
name: "us-east",
endpoint_url: "https://us.example.com",
});
});

it("getIdentity throws Empty response body when SDK returns no data (#359)", async () => {
mockGetIdentity.mockResolvedValue({ data: undefined, error: undefined });
const { peersApi } = await import("../replication");
await expect(peersApi.getIdentity()).rejects.toThrow(/Empty response body/);
});

it("getIdentity throws on error", async () => {
Expand All @@ -45,10 +95,53 @@ describe("peersApi", () => {
});

it("list returns peers", async () => {
const data = { items: [{ id: "p1" }], total: 1 };
mockListPeers.mockResolvedValue({ data, error: undefined });
mockListPeers.mockResolvedValue({
data: { items: [SDK_PEER], total: 1 },
error: undefined,
});
const { peersApi } = await import("../replication");
const out = await peersApi.list();
expect(out.total).toBe(1);
expect(out.items[0].id).toBe("p1");
expect(out.items[0].status).toBe("online");
});

it("list normalizes optional+nullable fields to null (#359)", async () => {
mockListPeers.mockResolvedValue({
data: {
items: [
{
...SDK_PEER,
region: undefined,
last_heartbeat_at: undefined,
last_sync_at: undefined,
},
],
total: 1,
},
error: undefined,
});
const { peersApi } = await import("../replication");
expect(await peersApi.list()).toEqual(data);
const out = await peersApi.list();
expect(out.items[0].region).toBeNull();
expect(out.items[0].last_heartbeat_at).toBeNull();
expect(out.items[0].last_sync_at).toBeNull();
});

it("list narrows unknown peer status to fallback (#359)", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mockListPeers.mockResolvedValue({
data: {
items: [{ ...SDK_PEER, status: "exotic" }],
total: 1,
},
error: undefined,
});
const { peersApi } = await import("../replication");
const out = await peersApi.list();
expect(out.items[0].status).toBe("offline");
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});

it("list throws on error", async () => {
Expand All @@ -58,10 +151,10 @@ describe("peersApi", () => {
});

it("get returns a single peer", async () => {
const peer = { id: "p1" };
mockGetPeer.mockResolvedValue({ data: peer, error: undefined });
mockGetPeer.mockResolvedValue({ data: SDK_PEER, error: undefined });
const { peersApi } = await import("../replication");
expect(await peersApi.get("p1")).toEqual(peer);
const out = await peersApi.get("p1");
expect(out.id).toBe("p1");
});

it("get throws on error", async () => {
Expand All @@ -71,18 +164,42 @@ describe("peersApi", () => {
});

it("register returns new peer", async () => {
const peer = { id: "p2" };
mockRegisterPeer.mockResolvedValue({ data: peer, error: undefined });
mockRegisterPeer.mockResolvedValue({ data: SDK_PEER, error: undefined });
const { peersApi } = await import("../replication");
expect(
await peersApi.register({ name: "eu-west", endpoint_url: "https://eu.example.com", api_key: "key" })
).toEqual(peer);
const out = await peersApi.register({
name: "us-east",
endpoint_url: "https://us.example.com",
api_key: "secret",
});
expect(out.id).toBe("p1");
});

it("register forwards local body fields to SDK (#359)", async () => {
mockRegisterPeer.mockResolvedValue({ data: SDK_PEER, error: undefined });
const { peersApi } = await import("../replication");
await peersApi.register({
name: "eu-west",
endpoint_url: "https://eu.example.com",
region: "eu-west-1",
api_key: "secret",
});
expect(mockRegisterPeer).toHaveBeenCalledWith({
body: {
name: "eu-west",
endpoint_url: "https://eu.example.com",
region: "eu-west-1",
api_key: "secret",
sync_filter: {},
},
});
});

it("register throws on error", async () => {
mockRegisterPeer.mockResolvedValue({ data: undefined, error: "fail" });
const { peersApi } = await import("../replication");
await expect(peersApi.register({ name: "x", endpoint_url: "x", api_key: "x" })).rejects.toBe("fail");
await expect(
peersApi.register({ name: "x", endpoint_url: "x", api_key: "x" }),
).rejects.toBe("fail");
});

it("unregister calls SDK", async () => {
Expand All @@ -108,7 +225,9 @@ describe("peersApi", () => {
it("heartbeat throws on error", async () => {
mockHeartbeat.mockResolvedValue({ error: "fail" });
const { peersApi } = await import("../replication");
await expect(peersApi.heartbeat("p1", { cache_used_bytes: 0 })).rejects.toBe("fail");
await expect(
peersApi.heartbeat("p1", { cache_used_bytes: 0 }),
).rejects.toBe("fail");
});

it("triggerSync calls SDK", async () => {
Expand Down Expand Up @@ -137,17 +256,32 @@ describe("peersApi", () => {
await expect(peersApi.getRepositories("p1")).rejects.toBe("fail");
});

it("assignRepository calls SDK", async () => {
it("assignRepository calls SDK with adapted body", async () => {
mockAssignRepo.mockResolvedValue({ error: undefined });
const { peersApi } = await import("../replication");
await peersApi.assignRepository("p1", { repository_id: "r1" });
expect(mockAssignRepo).toHaveBeenCalled();
await peersApi.assignRepository("p1", {
repository_id: "r1",
sync_enabled: true,
replication_mode: "push",
replication_schedule: "0 * * * *",
});
expect(mockAssignRepo).toHaveBeenCalledWith({
path: { id: "p1" },
body: {
repository_id: "r1",
sync_enabled: true,
replication_mode: "push",
replication_schedule: "0 * * * *",
},
});
});

it("assignRepository throws on error", async () => {
mockAssignRepo.mockResolvedValue({ error: "fail" });
const { peersApi } = await import("../replication");
await expect(peersApi.assignRepository("p1", { repository_id: "r1" })).rejects.toBe("fail");
await expect(
peersApi.assignRepository("p1", { repository_id: "r1" }),
).rejects.toBe("fail");
});

it("unassignRepository calls SDK", async () => {
Expand All @@ -160,18 +294,45 @@ describe("peersApi", () => {
it("unassignRepository throws on error", async () => {
mockUnassignRepo.mockResolvedValue({ error: "fail" });
const { peersApi } = await import("../replication");
await expect(peersApi.unassignRepository("p1", "r1")).rejects.toBe("fail");
await expect(
peersApi.unassignRepository("p1", "r1"),
).rejects.toBe("fail");
});

it("getConnections returns connections", async () => {
const data = [{ id: "c1" }];
mockListPeerConnections.mockResolvedValue({ data, error: undefined });
mockListPeerConnections.mockResolvedValue({
data: [SDK_CONNECTION],
error: undefined,
});
const { peersApi } = await import("../replication");
const out = await peersApi.getConnections("p1");
expect(out[0].id).toBe("c1");
expect(out[0].target_peer_id).toBe("p2");
expect(out[0].latency_ms).toBe(12);
});

it("getConnections coerces missing latency/bandwidth to 0 (#359)", async () => {
mockListPeerConnections.mockResolvedValue({
data: [
{
...SDK_CONNECTION,
latency_ms: undefined,
bandwidth_estimate_bps: undefined,
},
],
error: undefined,
});
const { peersApi } = await import("../replication");
expect(await peersApi.getConnections("p1")).toEqual(data);
const out = await peersApi.getConnections("p1");
expect(out[0].latency_ms).toBe(0);
expect(out[0].bandwidth_estimate_bps).toBe(0);
});

it("getConnections throws on error", async () => {
mockListPeerConnections.mockResolvedValue({ data: undefined, error: "fail" });
mockListPeerConnections.mockResolvedValue({
data: undefined,
error: "fail",
});
const { peersApi } = await import("../replication");
await expect(peersApi.getConnections("p1")).rejects.toBe("fail");
});
Expand Down
Loading
Loading