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 { AccountEffect } from "../../src/account/effect" 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) => AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) const json = (req: Parameters[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) => AccountEffect.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 = [] 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* AccountEffect.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* AccountEffect.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* AccountEffect.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* AccountEffect.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") } }), )