httpapi-sync.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import { afterEach, describe, expect, mock } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Context, Effect, Layer } from "effect"
  4. import { Flag } from "@kirincode-ai/core/flag/flag"
  5. import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
  6. import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
  7. import { Session } from "@/session/session"
  8. import { resetDatabase } from "../fixture/db"
  9. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
  12. const originalWorkspaces = Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES
  13. const context = Context.empty() as Context.Context<unknown>
  14. const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer))
  15. afterEach(async () => {
  16. mock.restore()
  17. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  18. await disposeAllInstances()
  19. await resetDatabase()
  20. })
  21. describe("sync HttpApi", () => {
  22. it.instance(
  23. "serves sync routes",
  24. () =>
  25. Effect.gen(function* () {
  26. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  27. const tmp = yield* TestInstance
  28. const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
  29. const session = yield* Session.use.create({ title: "sync" })
  30. const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers })
  31. expect(started.status).toBe(200)
  32. expect(yield* started.json).toBe(true)
  33. const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, {
  34. method: "POST",
  35. headers,
  36. body: JSON.stringify({}),
  37. })
  38. expect(history.status).toBe(200)
  39. const rows = (yield* history.json) as Array<{
  40. id: string
  41. aggregate_id: string
  42. seq: number
  43. type: string
  44. data: Record<string, unknown>
  45. }>
  46. expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
  47. const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, {
  48. method: "POST",
  49. headers,
  50. body: JSON.stringify({
  51. directory: tmp.directory,
  52. events: rows
  53. .filter((row) => row.aggregate_id === session.id)
  54. .map((row) => ({
  55. id: row.id,
  56. aggregateID: row.aggregate_id,
  57. seq: row.seq,
  58. type: row.type,
  59. data: row.data,
  60. })),
  61. }),
  62. })
  63. expect(replayed.status).toBe(200)
  64. expect(yield* replayed.json).toEqual({ sessionID: session.id })
  65. }),
  66. { git: true, config: { formatter: false, lsp: false } },
  67. )
  68. it.instance(
  69. "validates seq values",
  70. () =>
  71. Effect.gen(function* () {
  72. const tmp = yield* TestInstance
  73. const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
  74. const cases = [
  75. {
  76. path: SyncPaths.history,
  77. body: { aggregate: -1 },
  78. },
  79. {
  80. path: SyncPaths.history,
  81. body: { aggregate: 1.5 },
  82. },
  83. {
  84. path: SyncPaths.replay,
  85. body: {
  86. directory: tmp.directory,
  87. events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
  88. },
  89. },
  90. {
  91. path: SyncPaths.replay,
  92. body: {
  93. directory: tmp.directory,
  94. events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
  95. },
  96. },
  97. {
  98. path: SyncPaths.replay,
  99. body: {
  100. directory: tmp.directory,
  101. events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
  102. },
  103. },
  104. ]
  105. for (const item of cases) {
  106. const response = yield* requestInDirectory(item.path, tmp.directory, {
  107. method: "POST",
  108. headers,
  109. body: JSON.stringify(item.body),
  110. })
  111. expect(response.status).toBe(400)
  112. }
  113. }),
  114. { git: true, config: { formatter: false, lsp: false } },
  115. )
  116. it.instance.skip(
  117. "returns structured validation errors",
  118. () =>
  119. Effect.gen(function* () {
  120. const tmp = yield* TestInstance
  121. const response = yield* Effect.promise(() =>
  122. HttpApiApp.webHandler().handler(
  123. new Request(`http://localhost${SyncPaths.history}`, {
  124. method: "POST",
  125. headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
  126. body: JSON.stringify({ aggregate: -1 }),
  127. }),
  128. context,
  129. ),
  130. )
  131. expect(response.status).toBe(400)
  132. expect(response.headers.get("content-type") ?? "").toContain("application/json")
  133. const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
  134. expect(body.success).toBe(false)
  135. expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
  136. }),
  137. { git: true, config: { formatter: false, lsp: false } },
  138. )
  139. })