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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ describe("extension account client", () => {
const fetchImpl = async (_url: unknown, init?: { headers?: { Authorization?: string } }) => {
const header = init?.headers?.Authorization ?? "";
if (header === "Bearer token-a") {
return new Response(JSON.stringify({ accountId: "account-a", autoSubmit: false }), { status: 200 });
return new Response(
JSON.stringify({
accountId: "account-a",
scopes: ["profile:read", "contacts:read"],
autoSubmit: false,
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({ error: "unauthenticated" }), { status: 401 });
};
Expand All @@ -21,7 +28,11 @@ describe("extension account client", () => {
token: "token-a",
fetchImpl: fetchImpl as typeof fetch,
});
expect(active).toEqual({ ok: true, accountId: "account-a" });
expect(active).toEqual({
ok: true,
accountId: "account-a",
scopes: ["profile:read", "contacts:read"],
});
const signedOut = await readExtensionAccount({
origin: "http://127.0.0.1:3010",
token: "token-a-revoked",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function extensionAccountProfileKey(accountId: string): string {
}

export type ExtensionAccountResult =
| { ok: true; accountId: string }
| { ok: true; accountId: string; scopes: string[] }
| { ok: false; reason: "signed_out" | "misconfigured" };

export async function readExtensionAccount(input: {
Expand All @@ -33,9 +33,12 @@ export async function readExtensionAccount(input: {
});
if (response.status === 401) return { ok: false, reason: "signed_out" };
if (!response.ok) return { ok: false, reason: "misconfigured" };
const body = (await response.json()) as { accountId?: string };
const body = (await response.json()) as { accountId?: string; scopes?: unknown };
if (!body.accountId) return { ok: false, reason: "signed_out" };
return { ok: true, accountId: body.accountId };
const scopes = Array.isArray(body.scopes)
? body.scopes.filter((item): item is string => typeof item === "string")
: [];
return { ok: true, accountId: body.accountId, scopes };
}

export function selectExtensionProfileKey(input: {
Expand Down
7 changes: 6 additions & 1 deletion apps/applyflow-extension/src/account/extension-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ export async function extensionApiFetch<T>(input: {
body: input.body !== undefined ? JSON.stringify(input.body) : undefined,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
// Chrome uses DOMException; Node/undici may use Error with name AbortError.
const name =
error && typeof error === "object" && "name" in error
? String((error as { name?: unknown }).name ?? "")
: "";
if (name === "AbortError") {
return { ok: false, status: 0, error: "aborted" };
}
return { ok: false, status: 0, error: "network" };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import type { Contact, ContactInteraction } from "@devflow/applyflow-core";

import { listCloudContacts, saveCloudContact, saveCloudInteraction } from "./extension-cloud-sync.js";

const CONTACT: Contact = {
id: "extcontact_a",
name: "Ada Recruiter",
type: "recruiter",
status: "not_contacted",
createdAt: "2026-10-20T12:00:00.000Z",
updatedAt: "2026-10-20T12:00:00.000Z",
};

describe("extension cloud contacts sync", () => {
it("reconciles identical payload after a lost response (network after commit)", async () => {
const fetchImpl = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
if ((init?.method ?? "GET") === "PUT") {
throw new TypeError("network");
}
return {
ok: true,
status: 200,
json: async () => ({
contacts: [CONTACT],
interactions: [],
versions: { [CONTACT.id]: 1 },
}),
};
});

const lost = await saveCloudContact({
origin: "http://127.0.0.1:3010",
token: "t".repeat(64),
contact: CONTACT,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(lost.ok).toBe(true);
if (lost.ok) {
expect(lost.reused).toBe(true);
expect(lost.version).toBe(1);
}
});

it("does not reconcile abort into success even if server already has the contact", async () => {
const controller = new AbortController();
controller.abort();
const fetchImpl = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
if (init?.signal?.aborted) {
const error = new Error("aborted");
error.name = "AbortError";
throw error;
}
return {
ok: true,
status: 200,
json: async () => ({
contacts: [CONTACT],
interactions: [],
versions: { [CONTACT.id]: 1 },
}),
};
});

const result = await saveCloudContact({
origin: "http://127.0.0.1:3010",
token: "t".repeat(64),
contact: CONTACT,
signal: controller.signal,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("aborted");
expect(result.status).toBe(0);
}
});

it("returns version_conflict when listed contact differs after network loss", async () => {
const diverged = { ...CONTACT, name: "Other Name" };
const fetchImpl = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
if ((init?.method ?? "GET") === "PUT") {
throw new TypeError("network");
}
return {
ok: true,
status: 200,
json: async () => ({
contacts: [diverged],
interactions: [],
versions: { [CONTACT.id]: 2 },
}),
};
});

const result = await saveCloudContact({
origin: "http://127.0.0.1:3010",
token: "t".repeat(64),
contact: CONTACT,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("version_conflict");
expect(result.status).toBe(409);
}
});

it("lists contacts and registers an interaction", async () => {
const interaction: ContactInteraction = {
id: "extinteraction_a",
contactId: CONTACT.id,
type: "note",
occurredAt: "2026-10-20T12:05:00.000Z",
content: "manual",
};
const fetchImpl = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
contacts: [CONTACT],
interactions: [],
versions: { [CONTACT.id]: 1 },
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ interaction }),
});

const listed = await listCloudContacts({
origin: "http://127.0.0.1:3010",
token: "t".repeat(64),
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(listed.ok).toBe(true);

const saved = await saveCloudInteraction({
origin: "http://127.0.0.1:3010",
token: "t".repeat(64),
interaction,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(saved.ok).toBe(true);
});
});
Loading
Loading