share-next.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { beforeEach, describe, expect } from "bun:test"
  2. import { Effect, Exit, Layer, Option } from "effect"
  3. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  4. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  5. import { httpClient } from "@kirincode-ai/core/effect/app-node-platform"
  6. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  7. import { SessionProjector } from "@kirincode-ai/core/session/projector"
  8. import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
  9. import { AccountRepo } from "../../src/account/repo"
  10. import { EventV2Bridge } from "../../src/event-v2-bridge"
  11. import { Session } from "@/session/session"
  12. import type { SessionID } from "../../src/session/schema"
  13. import { ShareNext } from "@/share/share-next"
  14. import { SessionShareTable } from "@kirincode-ai/core/share/sql"
  15. import { Database } from "@kirincode-ai/core/database/database"
  16. import { eq } from "drizzle-orm"
  17. import { provideTmpdirInstance } from "../fixture/fixture"
  18. import { resetDatabase } from "../fixture/db"
  19. import { pollWithTimeout, testEffect } from "../lib/effect"
  20. const env = LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node]))
  21. const it = testEffect(env)
  22. const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
  23. HttpClientResponse.fromWeb(
  24. req,
  25. new Response(JSON.stringify(body), {
  26. status,
  27. headers: { "content-type": "application/json" },
  28. }),
  29. )
  30. const none = HttpClient.make(() => Effect.die("unexpected http call"))
  31. function requestLayer(client: HttpClient.HttpClient) {
  32. const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const
  33. return LayerNode.compile(LayerNode.group([ShareNext.node, AccountRepo.node]), [replacement])
  34. }
  35. function integrationLayer(client: HttpClient.HttpClient) {
  36. const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const
  37. return LayerNode.compile(
  38. LayerNode.group([
  39. ShareNext.node,
  40. EventV2Bridge.node,
  41. Session.node,
  42. SessionProjector.node,
  43. AccountRepo.node,
  44. Database.node,
  45. ]),
  46. [replacement],
  47. )
  48. }
  49. const share = (id: SessionID) =>
  50. Effect.gen(function* () {
  51. const { db } = yield* Database.Service
  52. return yield* db
  53. .select()
  54. .from(SessionShareTable)
  55. .where(eq(SessionShareTable.session_id, id))
  56. .get()
  57. .pipe(Effect.orDie)
  58. })
  59. const seed = (url: string, org?: string) =>
  60. AccountRepo.Service.use((repo) =>
  61. repo.persistAccount({
  62. id: AccountID.make("account-1"),
  63. email: "user@example.com",
  64. url,
  65. accessToken: AccessToken.make("st_test_token"),
  66. refreshToken: RefreshToken.make("rt_test_token"),
  67. expiry: Date.now() + 10 * 60_000,
  68. orgID: org ? Option.some(OrgID.make(org)) : Option.none(),
  69. }),
  70. )
  71. beforeEach(async () => {
  72. await resetDatabase()
  73. })
  74. describe("ShareNext", () => {
  75. it.live("request uses legacy share API without active org account", () =>
  76. provideTmpdirInstance(
  77. () =>
  78. ShareNext.Service.use((svc) =>
  79. Effect.gen(function* () {
  80. const req = yield* svc.request()
  81. expect(req.api.create).toBe("/api/share")
  82. expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync")
  83. expect(req.api.remove("shr_123")).toBe("/api/share/shr_123")
  84. expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data")
  85. expect(req.baseUrl).toBe("https://legacy-share.example.com")
  86. expect(req.headers).toEqual({})
  87. }),
  88. ).pipe(Effect.provide(requestLayer(none))),
  89. { config: { enterprise: { url: "https://legacy-share.example.com" } } },
  90. ),
  91. )
  92. it.live("request uses default URL when no enterprise config", () =>
  93. provideTmpdirInstance(() =>
  94. ShareNext.Service.use((svc) =>
  95. Effect.gen(function* () {
  96. const req = yield* svc.request()
  97. expect(req.baseUrl).toBe("https://opncd.ai")
  98. expect(req.api.create).toBe("/api/share")
  99. expect(req.headers).toEqual({})
  100. }),
  101. ).pipe(Effect.provide(requestLayer(none))),
  102. ),
  103. )
  104. it.live("request uses org share API with auth headers when account is active", () =>
  105. provideTmpdirInstance(() =>
  106. Effect.gen(function* () {
  107. yield* seed("https://control.example.com", "org-1")
  108. const req = yield* ShareNext.use.request()
  109. expect(req.api.create).toBe("/api/shares")
  110. expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
  111. expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123")
  112. expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data")
  113. expect(req.baseUrl).toBe("https://control.example.com")
  114. expect(req.headers).toEqual({
  115. authorization: "Bearer st_test_token",
  116. "x-org-id": "org-1",
  117. })
  118. }).pipe(Effect.provide(requestLayer(none))),
  119. ),
  120. )
  121. it.live("create posts share, persists it, and returns the result", () =>
  122. provideTmpdirInstance(
  123. () => {
  124. const createRequests: HttpClientRequest.HttpClientRequest[] = []
  125. const client = HttpClient.make((req) => {
  126. if (req.url.endsWith("/api/share")) {
  127. createRequests.push(req)
  128. return Effect.succeed(
  129. json(req, {
  130. id: "shr_abc",
  131. url: "https://legacy-share.example.com/share/abc",
  132. secret: "sec_123",
  133. }),
  134. )
  135. }
  136. return Effect.succeed(json(req, { ok: true }))
  137. })
  138. return Effect.gen(function* () {
  139. const session = yield* (yield* Session.Service).create({ title: "test" })
  140. const result = yield* (yield* ShareNext.Service).create(session.id)
  141. expect(result.id).toBe("shr_abc")
  142. expect(result.url).toBe("https://legacy-share.example.com/share/abc")
  143. expect(result.secret).toBe("sec_123")
  144. const row = yield* share(session.id)
  145. expect(row?.id).toBe("shr_abc")
  146. expect(row?.url).toBe("https://legacy-share.example.com/share/abc")
  147. expect(row?.secret).toBe("sec_123")
  148. expect(createRequests).toHaveLength(1)
  149. expect(createRequests[0].method).toBe("POST")
  150. expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share")
  151. }).pipe(Effect.provide(integrationLayer(client)))
  152. },
  153. { config: { enterprise: { url: "https://legacy-share.example.com" } } },
  154. ),
  155. )
  156. it.live("remove deletes the persisted share and calls the delete endpoint", () =>
  157. provideTmpdirInstance(
  158. () => {
  159. const seen: HttpClientRequest.HttpClientRequest[] = []
  160. const client = HttpClient.make((req) => {
  161. seen.push(req)
  162. if (req.method === "POST") {
  163. return Effect.succeed(
  164. json(req, {
  165. id: "shr_abc",
  166. url: "https://legacy-share.example.com/share/abc",
  167. secret: "sec_123",
  168. }),
  169. )
  170. }
  171. return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 })))
  172. })
  173. return Effect.gen(function* () {
  174. const session = yield* (yield* Session.Service).create({ title: "test" })
  175. const service = yield* ShareNext.Service
  176. yield* service.create(session.id)
  177. yield* service.remove(session.id)
  178. expect(yield* share(session.id)).toBeUndefined()
  179. expect(seen.map((req) => [req.method, req.url])).toEqual([
  180. ["POST", "https://legacy-share.example.com/api/share"],
  181. ["DELETE", "https://legacy-share.example.com/api/share/shr_abc"],
  182. ])
  183. }).pipe(Effect.provide(integrationLayer(client)))
  184. },
  185. { config: { enterprise: { url: "https://legacy-share.example.com" } } },
  186. ),
  187. )
  188. it.live("create fails on a non-ok response and does not persist a share", () =>
  189. provideTmpdirInstance(() => {
  190. const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500)))
  191. return Effect.gen(function* () {
  192. const session = yield* (yield* Session.Service).create({ title: "test" })
  193. const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id)))
  194. expect(Exit.isFailure(exit)).toBe(true)
  195. expect(yield* share(session.id)).toBeUndefined()
  196. }).pipe(Effect.provide(integrationLayer(client)))
  197. }),
  198. )
  199. it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () =>
  200. provideTmpdirInstance(
  201. () => {
  202. const seen: Array<{ url: string; body: string }> = []
  203. const client = HttpClient.make((req) => {
  204. if (req.url.endsWith("/sync") && req.body._tag === "Uint8Array") {
  205. seen.push({ url: req.url, body: new TextDecoder().decode(req.body.body) })
  206. }
  207. return Effect.succeed(json(req, { ok: true }))
  208. })
  209. return Effect.gen(function* () {
  210. const events = yield* EventV2Bridge.Service
  211. const share = yield* ShareNext.Service
  212. const session = yield* Session.Service
  213. const info = yield* session.create({ title: "first" })
  214. yield* share.init()
  215. yield* Effect.sleep(50)
  216. const { db } = yield* Database.Service
  217. yield* db
  218. .insert(SessionShareTable)
  219. .values({
  220. session_id: info.id,
  221. id: "shr_abc",
  222. url: "https://legacy-share.example.com/share/abc",
  223. secret: "sec_123",
  224. })
  225. .run()
  226. .pipe(Effect.orDie)
  227. yield* events.publish(Session.Event.Diff, {
  228. sessionID: info.id,
  229. diff: [
  230. {
  231. file: "a.ts",
  232. patch:
  233. "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n",
  234. additions: 1,
  235. deletions: 1,
  236. status: "modified",
  237. },
  238. ],
  239. })
  240. yield* events.publish(Session.Event.Diff, {
  241. sessionID: info.id,
  242. diff: [
  243. {
  244. file: "b.ts",
  245. patch:
  246. "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
  247. additions: 2,
  248. deletions: 0,
  249. status: "modified",
  250. },
  251. ],
  252. })
  253. yield* pollWithTimeout(
  254. Effect.sync(() => (seen.length === 1 ? true : undefined)),
  255. "timed out waiting for share sync",
  256. "5 seconds",
  257. )
  258. expect(seen).toHaveLength(1)
  259. expect(seen[0].url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync")
  260. const body = JSON.parse(seen[0].body) as {
  261. secret: string
  262. data: Array<{
  263. type: string
  264. data: Array<{
  265. file: string
  266. patch: string
  267. additions: number
  268. deletions: number
  269. status?: string
  270. }>
  271. }>
  272. }
  273. expect(body.secret).toBe("sec_123")
  274. expect(body.data).toHaveLength(1)
  275. expect(body.data[0].type).toBe("session_diff")
  276. expect(body.data[0].data).toEqual([
  277. {
  278. file: "b.ts",
  279. patch:
  280. "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
  281. additions: 2,
  282. deletions: 0,
  283. status: "modified",
  284. },
  285. ])
  286. }).pipe(Effect.provide(integrationLayer(client)))
  287. },
  288. { config: { enterprise: { url: "https://legacy-share.example.com" } } },
  289. ),
  290. )
  291. })