repo.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import { expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Effect, Layer, Option } from "effect"
  4. import { sql } from "drizzle-orm"
  5. import { AccountRepo } from "../../src/account/repo"
  6. import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
  7. import { Database } from "@kirincode-ai/core/database/database"
  8. import { testEffect } from "../lib/effect"
  9. const truncate = Layer.effectDiscard(
  10. Effect.gen(function* () {
  11. const { db } = yield* Database.Service
  12. yield* db.run(sql`DELETE FROM account_state`)
  13. yield* db.run(sql`DELETE FROM account`)
  14. }),
  15. )
  16. const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] })
  17. const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode])))
  18. it.live("list returns empty when no accounts exist", () =>
  19. Effect.gen(function* () {
  20. const accounts = yield* AccountRepo.use.list()
  21. expect(accounts).toEqual([])
  22. }),
  23. )
  24. it.live("active returns none when no accounts exist", () =>
  25. Effect.gen(function* () {
  26. const active = yield* AccountRepo.use.active()
  27. expect(Option.isNone(active)).toBe(true)
  28. }),
  29. )
  30. it.live("persistAccount inserts and getRow retrieves", () =>
  31. Effect.gen(function* () {
  32. const id = AccountID.make("user-1")
  33. yield* AccountRepo.Service.use((r) =>
  34. r.persistAccount({
  35. id,
  36. email: "test@example.com",
  37. url: "https://control.example.com",
  38. accessToken: AccessToken.make("at_123"),
  39. refreshToken: RefreshToken.make("rt_456"),
  40. expiry: Date.now() + 3600_000,
  41. orgID: Option.some(OrgID.make("org-1")),
  42. }),
  43. )
  44. const row = yield* AccountRepo.use.getRow(id)
  45. expect(Option.isSome(row)).toBe(true)
  46. const value = Option.getOrThrow(row)
  47. expect(value.id).toBe(AccountID.make("user-1"))
  48. expect(value.email).toBe("test@example.com")
  49. const active = yield* AccountRepo.use.active()
  50. expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
  51. }),
  52. )
  53. it.live("persistAccount normalizes trailing slashes in stored server URLs", () =>
  54. Effect.gen(function* () {
  55. const id = AccountID.make("user-1")
  56. yield* AccountRepo.Service.use((r) =>
  57. r.persistAccount({
  58. id,
  59. email: "test@example.com",
  60. url: "https://control.example.com/",
  61. accessToken: AccessToken.make("at_123"),
  62. refreshToken: RefreshToken.make("rt_456"),
  63. expiry: Date.now() + 3600_000,
  64. orgID: Option.none(),
  65. }),
  66. )
  67. const row = yield* AccountRepo.use.getRow(id)
  68. const active = yield* AccountRepo.use.active()
  69. const list = yield* AccountRepo.use.list()
  70. expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
  71. expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
  72. expect(list[0]?.url).toBe("https://control.example.com")
  73. }),
  74. )
  75. it.live("persistAccount sets the active account and org", () =>
  76. Effect.gen(function* () {
  77. const id1 = AccountID.make("user-1")
  78. const id2 = AccountID.make("user-2")
  79. yield* AccountRepo.Service.use((r) =>
  80. r.persistAccount({
  81. id: id1,
  82. email: "first@example.com",
  83. url: "https://control.example.com",
  84. accessToken: AccessToken.make("at_1"),
  85. refreshToken: RefreshToken.make("rt_1"),
  86. expiry: Date.now() + 3600_000,
  87. orgID: Option.some(OrgID.make("org-1")),
  88. }),
  89. )
  90. yield* AccountRepo.Service.use((r) =>
  91. r.persistAccount({
  92. id: id2,
  93. email: "second@example.com",
  94. url: "https://control.example.com",
  95. accessToken: AccessToken.make("at_2"),
  96. refreshToken: RefreshToken.make("rt_2"),
  97. expiry: Date.now() + 3600_000,
  98. orgID: Option.some(OrgID.make("org-2")),
  99. }),
  100. )
  101. // Last persisted account is active with its org
  102. const active = yield* AccountRepo.use.active()
  103. expect(Option.isSome(active)).toBe(true)
  104. expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
  105. expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
  106. }),
  107. )
  108. it.live("list returns all accounts", () =>
  109. Effect.gen(function* () {
  110. const id1 = AccountID.make("user-1")
  111. const id2 = AccountID.make("user-2")
  112. yield* AccountRepo.Service.use((r) =>
  113. r.persistAccount({
  114. id: id1,
  115. email: "a@example.com",
  116. url: "https://control.example.com",
  117. accessToken: AccessToken.make("at_1"),
  118. refreshToken: RefreshToken.make("rt_1"),
  119. expiry: Date.now() + 3600_000,
  120. orgID: Option.none(),
  121. }),
  122. )
  123. yield* AccountRepo.Service.use((r) =>
  124. r.persistAccount({
  125. id: id2,
  126. email: "b@example.com",
  127. url: "https://control.example.com",
  128. accessToken: AccessToken.make("at_2"),
  129. refreshToken: RefreshToken.make("rt_2"),
  130. expiry: Date.now() + 3600_000,
  131. orgID: Option.some(OrgID.make("org-1")),
  132. }),
  133. )
  134. const accounts = yield* AccountRepo.use.list()
  135. expect(accounts.length).toBe(2)
  136. expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
  137. }),
  138. )
  139. it.live("remove deletes an account", () =>
  140. Effect.gen(function* () {
  141. const id = AccountID.make("user-1")
  142. yield* AccountRepo.Service.use((r) =>
  143. r.persistAccount({
  144. id,
  145. email: "test@example.com",
  146. url: "https://control.example.com",
  147. accessToken: AccessToken.make("at_1"),
  148. refreshToken: RefreshToken.make("rt_1"),
  149. expiry: Date.now() + 3600_000,
  150. orgID: Option.none(),
  151. }),
  152. )
  153. yield* AccountRepo.use.remove(id)
  154. const row = yield* AccountRepo.use.getRow(id)
  155. expect(Option.isNone(row)).toBe(true)
  156. }),
  157. )
  158. it.live("use stores the selected org and marks the account active", () =>
  159. Effect.gen(function* () {
  160. const id1 = AccountID.make("user-1")
  161. const id2 = AccountID.make("user-2")
  162. yield* AccountRepo.Service.use((r) =>
  163. r.persistAccount({
  164. id: id1,
  165. email: "first@example.com",
  166. url: "https://control.example.com",
  167. accessToken: AccessToken.make("at_1"),
  168. refreshToken: RefreshToken.make("rt_1"),
  169. expiry: Date.now() + 3600_000,
  170. orgID: Option.none(),
  171. }),
  172. )
  173. yield* AccountRepo.Service.use((r) =>
  174. r.persistAccount({
  175. id: id2,
  176. email: "second@example.com",
  177. url: "https://control.example.com",
  178. accessToken: AccessToken.make("at_2"),
  179. refreshToken: RefreshToken.make("rt_2"),
  180. expiry: Date.now() + 3600_000,
  181. orgID: Option.none(),
  182. }),
  183. )
  184. yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
  185. const active1 = yield* AccountRepo.use.active()
  186. expect(Option.getOrThrow(active1).id).toBe(id1)
  187. expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
  188. yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
  189. const active2 = yield* AccountRepo.use.active()
  190. expect(Option.getOrThrow(active2).active_org_id).toBeNull()
  191. }),
  192. )
  193. it.live("persistToken updates token fields", () =>
  194. Effect.gen(function* () {
  195. const id = AccountID.make("user-1")
  196. yield* AccountRepo.Service.use((r) =>
  197. r.persistAccount({
  198. id,
  199. email: "test@example.com",
  200. url: "https://control.example.com",
  201. accessToken: AccessToken.make("old_token"),
  202. refreshToken: RefreshToken.make("old_refresh"),
  203. expiry: 1000,
  204. orgID: Option.none(),
  205. }),
  206. )
  207. const expiry = Date.now() + 7200_000
  208. yield* AccountRepo.Service.use((r) =>
  209. r.persistToken({
  210. accountID: id,
  211. accessToken: AccessToken.make("new_token"),
  212. refreshToken: RefreshToken.make("new_refresh"),
  213. expiry: Option.some(expiry),
  214. }),
  215. )
  216. const row = yield* AccountRepo.use.getRow(id)
  217. const value = Option.getOrThrow(row)
  218. expect(value.access_token).toBe(AccessToken.make("new_token"))
  219. expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
  220. expect(value.token_expiry).toBe(expiry)
  221. }),
  222. )
  223. it.live("persistToken with no expiry sets token_expiry to null", () =>
  224. Effect.gen(function* () {
  225. const id = AccountID.make("user-1")
  226. yield* AccountRepo.Service.use((r) =>
  227. r.persistAccount({
  228. id,
  229. email: "test@example.com",
  230. url: "https://control.example.com",
  231. accessToken: AccessToken.make("old_token"),
  232. refreshToken: RefreshToken.make("old_refresh"),
  233. expiry: 1000,
  234. orgID: Option.none(),
  235. }),
  236. )
  237. yield* AccountRepo.Service.use((r) =>
  238. r.persistToken({
  239. accountID: id,
  240. accessToken: AccessToken.make("new_token"),
  241. refreshToken: RefreshToken.make("new_refresh"),
  242. expiry: Option.none(),
  243. }),
  244. )
  245. const row = yield* AccountRepo.use.getRow(id)
  246. expect(Option.getOrThrow(row).token_expiry).toBeNull()
  247. }),
  248. )
  249. it.live("persistAccount upserts on conflict", () =>
  250. Effect.gen(function* () {
  251. const id = AccountID.make("user-1")
  252. yield* AccountRepo.Service.use((r) =>
  253. r.persistAccount({
  254. id,
  255. email: "test@example.com",
  256. url: "https://control.example.com",
  257. accessToken: AccessToken.make("at_v1"),
  258. refreshToken: RefreshToken.make("rt_v1"),
  259. expiry: 1000,
  260. orgID: Option.some(OrgID.make("org-1")),
  261. }),
  262. )
  263. yield* AccountRepo.Service.use((r) =>
  264. r.persistAccount({
  265. id,
  266. email: "test@example.com",
  267. url: "https://control.example.com",
  268. accessToken: AccessToken.make("at_v2"),
  269. refreshToken: RefreshToken.make("rt_v2"),
  270. expiry: 2000,
  271. orgID: Option.some(OrgID.make("org-2")),
  272. }),
  273. )
  274. const accounts = yield* AccountRepo.use.list()
  275. expect(accounts.length).toBe(1)
  276. const row = yield* AccountRepo.use.getRow(id)
  277. const value = Option.getOrThrow(row)
  278. expect(value.access_token).toBe(AccessToken.make("at_v2"))
  279. const active = yield* AccountRepo.use.active()
  280. expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
  281. }),
  282. )
  283. it.live("remove clears active state when deleting the active account", () =>
  284. Effect.gen(function* () {
  285. const id = AccountID.make("user-1")
  286. yield* AccountRepo.Service.use((r) =>
  287. r.persistAccount({
  288. id,
  289. email: "test@example.com",
  290. url: "https://control.example.com",
  291. accessToken: AccessToken.make("at_1"),
  292. refreshToken: RefreshToken.make("rt_1"),
  293. expiry: Date.now() + 3600_000,
  294. orgID: Option.some(OrgID.make("org-1")),
  295. }),
  296. )
  297. yield* AccountRepo.use.remove(id)
  298. const active = yield* AccountRepo.use.active()
  299. expect(Option.isNone(active)).toBe(true)
  300. }),
  301. )
  302. it.live("getRow returns none for nonexistent account", () =>
  303. Effect.gen(function* () {
  304. const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope")))
  305. expect(Option.isNone(row)).toBe(true)
  306. }),
  307. )