mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-01 06:42:26 +00:00
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
283 lines
8.3 KiB
TypeScript
283 lines
8.3 KiB
TypeScript
import { expect } from "bun:test"
|
|
import { Duration, Effect, Layer, Option, Schema } from "effect"
|
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
|
|
import { AccountRepo } from "../../src/account/repo"
|
|
import { Account } from "../../src/account"
|
|
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
|
import { Database } from "../../src/storage/db"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const truncate = Layer.effectDiscard(
|
|
Effect.sync(() => {
|
|
const db = Database.Client()
|
|
db.run(/*sql*/ `DELETE FROM account_state`)
|
|
db.run(/*sql*/ `DELETE FROM account`)
|
|
}),
|
|
)
|
|
|
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
|
|
|
const live = (client: HttpClient.HttpClient) =>
|
|
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
|
|
|
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
|
HttpClientResponse.fromWeb(
|
|
req,
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
)
|
|
|
|
const encodeOrg = Schema.encodeSync(Org)
|
|
|
|
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
|
|
|
|
const login = () =>
|
|
new Login({
|
|
code: DeviceCode.make("device-code"),
|
|
user: UserCode.make("user-code"),
|
|
url: "https://one.example.com/verify",
|
|
server: "https://one.example.com",
|
|
expiry: Duration.seconds(600),
|
|
interval: Duration.seconds(5),
|
|
})
|
|
|
|
const deviceTokenClient = (body: unknown, status = 400) =>
|
|
HttpClient.make((req) =>
|
|
Effect.succeed(
|
|
req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
|
|
),
|
|
)
|
|
|
|
const poll = (body: unknown, status = 400) =>
|
|
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
|
|
|
it.effect("orgsByAccount groups orgs per account", () =>
|
|
Effect.gen(function* () {
|
|
yield* AccountRepo.use((r) =>
|
|
r.persistAccount({
|
|
id: AccountID.make("user-1"),
|
|
email: "one@example.com",
|
|
url: "https://one.example.com",
|
|
accessToken: AccessToken.make("at_1"),
|
|
refreshToken: RefreshToken.make("rt_1"),
|
|
expiry: Date.now() + 60_000,
|
|
orgID: Option.none(),
|
|
}),
|
|
)
|
|
|
|
yield* AccountRepo.use((r) =>
|
|
r.persistAccount({
|
|
id: AccountID.make("user-2"),
|
|
email: "two@example.com",
|
|
url: "https://two.example.com",
|
|
accessToken: AccessToken.make("at_2"),
|
|
refreshToken: RefreshToken.make("rt_2"),
|
|
expiry: Date.now() + 60_000,
|
|
orgID: Option.none(),
|
|
}),
|
|
)
|
|
|
|
const seen: Array<string> = []
|
|
const client = HttpClient.make((req) =>
|
|
Effect.gen(function* () {
|
|
seen.push(`${req.method} ${req.url}`)
|
|
|
|
if (req.url === "https://one.example.com/api/orgs") {
|
|
return json(req, [org("org-1", "One")])
|
|
}
|
|
|
|
if (req.url === "https://two.example.com/api/orgs") {
|
|
return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
|
|
}
|
|
|
|
return json(req, [], 404)
|
|
}),
|
|
)
|
|
|
|
const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
|
|
|
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
|
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
|
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
|
|
])
|
|
expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"])
|
|
}),
|
|
)
|
|
|
|
it.effect("token refresh persists the new token", () =>
|
|
Effect.gen(function* () {
|
|
const id = AccountID.make("user-1")
|
|
|
|
yield* AccountRepo.use((r) =>
|
|
r.persistAccount({
|
|
id,
|
|
email: "user@example.com",
|
|
url: "https://one.example.com",
|
|
accessToken: AccessToken.make("at_old"),
|
|
refreshToken: RefreshToken.make("rt_old"),
|
|
expiry: Date.now() - 1_000,
|
|
orgID: Option.none(),
|
|
}),
|
|
)
|
|
|
|
const client = HttpClient.make((req) =>
|
|
Effect.succeed(
|
|
req.url === "https://one.example.com/auth/device/token"
|
|
? json(req, {
|
|
access_token: "at_new",
|
|
refresh_token: "rt_new",
|
|
expires_in: 60,
|
|
})
|
|
: json(req, {}, 404),
|
|
),
|
|
)
|
|
|
|
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
|
|
|
expect(Option.getOrThrow(token)).toBeDefined()
|
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
|
|
|
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
|
const value = Option.getOrThrow(row)
|
|
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
|
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
|
expect(value.token_expiry).toBeGreaterThan(Date.now())
|
|
}),
|
|
)
|
|
|
|
it.effect("config sends the selected org header", () =>
|
|
Effect.gen(function* () {
|
|
const id = AccountID.make("user-1")
|
|
|
|
yield* AccountRepo.use((r) =>
|
|
r.persistAccount({
|
|
id,
|
|
email: "user@example.com",
|
|
url: "https://one.example.com",
|
|
accessToken: AccessToken.make("at_1"),
|
|
refreshToken: RefreshToken.make("rt_1"),
|
|
expiry: Date.now() + 60_000,
|
|
orgID: Option.none(),
|
|
}),
|
|
)
|
|
|
|
const seen: { auth?: string; org?: string } = {}
|
|
const client = HttpClient.make((req) =>
|
|
Effect.gen(function* () {
|
|
seen.auth = req.headers.authorization
|
|
seen.org = req.headers["x-org-id"]
|
|
|
|
if (req.url === "https://one.example.com/api/config") {
|
|
return json(req, { config: { theme: "light", seats: 5 } })
|
|
}
|
|
|
|
return json(req, {}, 404)
|
|
}),
|
|
)
|
|
|
|
const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
|
|
|
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
|
expect(seen).toEqual({
|
|
auth: "Bearer at_1",
|
|
org: "org-9",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("poll stores the account and first org on success", () =>
|
|
Effect.gen(function* () {
|
|
const client = HttpClient.make((req) =>
|
|
Effect.succeed(
|
|
req.url === "https://one.example.com/auth/device/token"
|
|
? json(req, {
|
|
access_token: "at_1",
|
|
refresh_token: "rt_1",
|
|
token_type: "Bearer",
|
|
expires_in: 60,
|
|
})
|
|
: req.url === "https://one.example.com/api/user"
|
|
? json(req, { id: "user-1", email: "user@example.com" })
|
|
: req.url === "https://one.example.com/api/orgs"
|
|
? json(req, [org("org-1", "One")])
|
|
: json(req, {}, 404),
|
|
),
|
|
)
|
|
|
|
const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
|
|
|
|
expect(res._tag).toBe("PollSuccess")
|
|
if (res._tag === "PollSuccess") {
|
|
expect(res.email).toBe("user@example.com")
|
|
}
|
|
|
|
const active = yield* AccountRepo.use((r) => r.active())
|
|
expect(Option.getOrThrow(active)).toEqual(
|
|
expect.objectContaining({
|
|
id: "user-1",
|
|
email: "user@example.com",
|
|
active_org_id: "org-1",
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
for (const [name, body, expectedTag] of [
|
|
[
|
|
"pending",
|
|
{
|
|
error: "authorization_pending",
|
|
error_description: "The authorization request is still pending",
|
|
},
|
|
"PollPending",
|
|
],
|
|
[
|
|
"slow",
|
|
{
|
|
error: "slow_down",
|
|
error_description: "Polling too frequently, please slow down",
|
|
},
|
|
"PollSlow",
|
|
],
|
|
[
|
|
"denied",
|
|
{
|
|
error: "access_denied",
|
|
error_description: "The authorization request was denied",
|
|
},
|
|
"PollDenied",
|
|
],
|
|
[
|
|
"expired",
|
|
{
|
|
error: "expired_token",
|
|
error_description: "The device code has expired",
|
|
},
|
|
"PollExpired",
|
|
],
|
|
] as const) {
|
|
it.effect(`poll returns ${name} for ${body.error}`, () =>
|
|
Effect.gen(function* () {
|
|
const result = yield* poll(body)
|
|
expect(result._tag).toBe(expectedTag)
|
|
}),
|
|
)
|
|
}
|
|
|
|
it.effect("poll returns poll error for other OAuth errors", () =>
|
|
Effect.gen(function* () {
|
|
const result = yield* poll({
|
|
error: "server_error",
|
|
error_description: "An unexpected error occurred",
|
|
})
|
|
|
|
expect(result._tag).toBe("PollError")
|
|
if (result._tag === "PollError") {
|
|
expect(String(result.cause)).toContain("server_error")
|
|
}
|
|
}),
|
|
)
|