share-next.test.ts 12 KB

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