Blame · Line-by-line history
code-suggestions.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 7c0203e | 1 | /** |
| 2 | * Block J22 — Code review suggestion blocks. Pure extract + apply tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import { | |
| 7 | detectLineEnding, | |
| 8 | splitLines, | |
| 9 | extractSuggestions, | |
| 10 | applySuggestionToContent, | |
| 11 | applyNthSuggestion, | |
| 12 | __internal, | |
| 13 | } from "../lib/code-suggestions"; | |
| 14 | ||
| 15 | describe("code-suggestions — detectLineEnding", () => { | |
| 16 | it("returns CRLF for CRLF content", () => { | |
| 17 | expect(detectLineEnding("a\r\nb\r\n")).toBe("\r\n"); | |
| 18 | }); | |
| 19 | it("returns LF for LF content", () => { | |
| 20 | expect(detectLineEnding("a\nb\n")).toBe("\n"); | |
| 21 | }); | |
| 22 | it("returns LF for empty", () => { | |
| 23 | expect(detectLineEnding("")).toBe("\n"); | |
| 24 | }); | |
| 25 | it("returns CRLF if ANY CRLF present", () => { | |
| 26 | expect(detectLineEnding("a\nb\r\nc")).toBe("\r\n"); | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | describe("code-suggestions — extractSuggestions", () => { | |
| 31 | it("returns [] for empty or non-string input", () => { | |
| 32 | expect(extractSuggestions("")).toEqual([]); | |
| 33 | expect(extractSuggestions(null as unknown as string)).toEqual([]); | |
| 34 | expect(extractSuggestions(undefined as unknown as string)).toEqual([]); | |
| 35 | }); | |
| 36 | ||
| 37 | it("extracts a single single-line suggestion", () => { | |
| 38 | const body = "LGTM but\n\n```suggestion\nconst x = 1;\n```\n\nthoughts?"; | |
| 39 | const out = extractSuggestions(body); | |
| 40 | expect(out).toHaveLength(1); | |
| 41 | expect(out[0].content).toBe("const x = 1;"); | |
| 42 | expect(out[0].index).toBe(0); | |
| 43 | }); | |
| 44 | ||
| 45 | it("extracts multi-line suggestions preserving internal newlines", () => { | |
| 46 | const body = "```suggestion\nline1\nline2\nline3\n```\n"; | |
| 47 | const out = extractSuggestions(body); | |
| 48 | expect(out).toHaveLength(1); | |
| 49 | expect(out[0].content).toBe("line1\nline2\nline3"); | |
| 50 | }); | |
| 51 | ||
| 52 | it("captures empty suggestions (intent: delete the line)", () => { | |
| 53 | const body = "```suggestion\n```\n"; | |
| 54 | const out = extractSuggestions(body); | |
| 55 | expect(out).toHaveLength(1); | |
| 56 | expect(out[0].content).toBe(""); | |
| 57 | }); | |
| 58 | ||
| 59 | it("skips fences whose info-string isn't `suggestion`", () => { | |
| 60 | const body = "```js\nconst x = 1;\n```\n"; | |
| 61 | expect(extractSuggestions(body)).toEqual([]); | |
| 62 | }); | |
| 63 | ||
| 64 | it("extracts multiple suggestions in order", () => { | |
| 65 | const body = | |
| 66 | "First:\n```suggestion\nfoo\n```\n\nSecond:\n```suggestion\nbar\nbaz\n```\n"; | |
| 67 | const out = extractSuggestions(body); | |
| 68 | expect(out).toHaveLength(2); | |
| 69 | expect(out[0].content).toBe("foo"); | |
| 70 | expect(out[1].content).toBe("bar\nbaz"); | |
| 71 | expect(out[0].index).toBe(0); | |
| 72 | expect(out[1].index).toBe(1); | |
| 73 | }); | |
| 74 | ||
| 75 | it("skips unterminated fences", () => { | |
| 76 | const body = "```suggestion\nconst x = 1;"; | |
| 77 | expect(extractSuggestions(body)).toEqual([]); | |
| 78 | }); | |
| 79 | ||
| 80 | it("tolerates 4+ backtick fences (for suggestions that contain ```)", () => { | |
| 81 | const body = | |
| 82 | "````suggestion\nconst s = `template ${with} backticks`;\n````\n"; | |
| 83 | const out = extractSuggestions(body); | |
| 84 | expect(out).toHaveLength(1); | |
| 85 | expect(out[0].content).toBe( | |
| 86 | "const s = `template ${with} backticks`;" | |
| 87 | ); | |
| 88 | }); | |
| 89 | ||
| 90 | it("ignores trailing info after `suggestion`", () => { | |
| 91 | const body = "```suggestion block\nfoo\n```\n"; | |
| 92 | expect(extractSuggestions(body)).toHaveLength(1); | |
| 93 | }); | |
| 94 | ||
| 95 | it("is case-sensitive on the language token (GitHub parity)", () => { | |
| 96 | // GitHub's renderer is case-sensitive. This keeps our parser | |
| 97 | // predictable — bump to case-insensitive when GitHub relaxes. | |
| 98 | const body = "```Suggestion\nfoo\n```\n"; | |
| 99 | expect(extractSuggestions(body)).toEqual([]); | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | describe("code-suggestions — applySuggestionToContent", () => { | |
| 104 | const content = "line one\nline two\nline three\n"; | |
| 105 | ||
| 106 | it("replaces a single line (1-indexed)", () => { | |
| 107 | const res = applySuggestionToContent({ | |
| 108 | content, | |
| 109 | startLine: 2, | |
| 110 | endLine: 2, | |
| 111 | suggestion: "TWO!", | |
| 112 | }); | |
| 113 | expect(res.ok).toBe(true); | |
| 114 | expect(res.content).toBe("line one\nTWO!\nline three\n"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("replaces with multiple lines", () => { | |
| 118 | const res = applySuggestionToContent({ | |
| 119 | content, | |
| 120 | startLine: 2, | |
| 121 | endLine: 2, | |
| 122 | suggestion: "a\nb\nc", | |
| 123 | }); | |
| 124 | expect(res.ok).toBe(true); | |
| 125 | expect(res.content).toBe("line one\na\nb\nc\nline three\n"); | |
| 126 | }); | |
| 127 | ||
| 128 | it("replaces a multi-line range", () => { | |
| 129 | const res = applySuggestionToContent({ | |
| 130 | content, | |
| 131 | startLine: 1, | |
| 132 | endLine: 2, | |
| 133 | suggestion: "merged", | |
| 134 | }); | |
| 135 | expect(res.ok).toBe(true); | |
| 136 | expect(res.content).toBe("merged\nline three\n"); | |
| 137 | }); | |
| 138 | ||
| 139 | it("preserves CRLF line endings", () => { | |
| 140 | const crlf = "a\r\nb\r\nc\r\n"; | |
| 141 | const res = applySuggestionToContent({ | |
| 142 | content: crlf, | |
| 143 | startLine: 2, | |
| 144 | endLine: 2, | |
| 145 | suggestion: "B", | |
| 146 | }); | |
| 147 | expect(res.ok).toBe(true); | |
| 148 | expect(res.content).toBe("a\r\nB\r\nc\r\n"); | |
| 149 | }); | |
| 150 | ||
| 151 | it("preserves no-trailing-newline files", () => { | |
| 152 | const res = applySuggestionToContent({ | |
| 153 | content: "a\nb\nc", | |
| 154 | startLine: 3, | |
| 155 | endLine: 3, | |
| 156 | suggestion: "C", | |
| 157 | }); | |
| 158 | expect(res.ok).toBe(true); | |
| 159 | expect(res.content).toBe("a\nb\nC"); | |
| 160 | }); | |
| 161 | ||
| 162 | it("strips trailing newline from the suggestion", () => { | |
| 163 | const res = applySuggestionToContent({ | |
| 164 | content: "a\nb\n", | |
| 165 | startLine: 1, | |
| 166 | endLine: 1, | |
| 167 | suggestion: "A\n", | |
| 168 | }); | |
| 169 | expect(res.ok).toBe(true); | |
| 170 | expect(res.content).toBe("A\nb\n"); | |
| 171 | }); | |
| 172 | ||
| 173 | it("rejects bad ranges", () => { | |
| 174 | const r1 = applySuggestionToContent({ | |
| 175 | content, | |
| 176 | startLine: 0, | |
| 177 | endLine: 1, | |
| 178 | suggestion: "x", | |
| 179 | }); | |
| 180 | expect(r1).toEqual({ ok: false, reason: "bad_range" }); | |
| 181 | const r2 = applySuggestionToContent({ | |
| 182 | content, | |
| 183 | startLine: 2, | |
| 184 | endLine: 1, | |
| 185 | suggestion: "x", | |
| 186 | }); | |
| 187 | expect(r2).toEqual({ ok: false, reason: "bad_range" }); | |
| 188 | }); | |
| 189 | ||
| 190 | it("rejects out-of-bounds line numbers", () => { | |
| 191 | const r = applySuggestionToContent({ | |
| 192 | content, | |
| 193 | startLine: 5, | |
| 194 | endLine: 5, | |
| 195 | suggestion: "x", | |
| 196 | }); | |
| 197 | expect(r).toEqual({ ok: false, reason: "line_out_of_bounds" }); | |
| 198 | }); | |
| 199 | ||
| 200 | it("returns no_change when the suggestion matches existing content", () => { | |
| 201 | const r = applySuggestionToContent({ | |
| 202 | content, | |
| 203 | startLine: 1, | |
| 204 | endLine: 1, | |
| 205 | suggestion: "line one", | |
| 206 | }); | |
| 207 | expect(r).toEqual({ ok: false, reason: "no_change" }); | |
| 208 | }); | |
| 209 | ||
| 210 | it("empty suggestion deletes the line", () => { | |
| 211 | const r = applySuggestionToContent({ | |
| 212 | content, | |
| 213 | startLine: 2, | |
| 214 | endLine: 2, | |
| 215 | suggestion: "", | |
| 216 | }); | |
| 217 | expect(r.ok).toBe(true); | |
| 218 | expect(r.content).toBe("line one\n\nline three\n"); | |
| 219 | }); | |
| 220 | ||
| 221 | it("rejects non-string content", () => { | |
| 222 | const r = applySuggestionToContent({ | |
| 223 | content: undefined as unknown as string, | |
| 224 | startLine: 1, | |
| 225 | endLine: 1, | |
| 226 | suggestion: "x", | |
| 227 | }); | |
| 228 | expect(r.ok).toBe(false); | |
| 229 | }); | |
| 230 | }); | |
| 231 | ||
| 232 | describe("code-suggestions — applyNthSuggestion", () => { | |
| 233 | const body = | |
| 234 | "First:\n```suggestion\nalpha\n```\n\nSecond:\n```suggestion\nbeta\n```\n"; | |
| 235 | ||
| 236 | it("applies the 0th block by default", () => { | |
| 237 | const r = applyNthSuggestion(body, 0, { | |
| 238 | content: "x\n", | |
| 239 | startLine: 1, | |
| 240 | endLine: 1, | |
| 241 | }); | |
| 242 | expect(r.ok).toBe(true); | |
| 243 | if (r.ok) expect(r.content).toBe("alpha\n"); | |
| 244 | }); | |
| 245 | ||
| 246 | it("applies the Nth block", () => { | |
| 247 | const r = applyNthSuggestion(body, 1, { | |
| 248 | content: "x\n", | |
| 249 | startLine: 1, | |
| 250 | endLine: 1, | |
| 251 | }); | |
| 252 | expect(r.ok).toBe(true); | |
| 253 | if (r.ok) expect(r.content).toBe("beta\n"); | |
| 254 | }); | |
| 255 | ||
| 256 | it("returns not_found for an out-of-range index", () => { | |
| 257 | const r = applyNthSuggestion(body, 99, { | |
| 258 | content: "x\n", | |
| 259 | startLine: 1, | |
| 260 | endLine: 1, | |
| 261 | }); | |
| 262 | expect(r).toEqual({ ok: false, reason: "not_found" }); | |
| 263 | }); | |
| 264 | ||
| 265 | it("returns not_found for a negative index", () => { | |
| 266 | const r = applyNthSuggestion(body, -1, { | |
| 267 | content: "x\n", | |
| 268 | startLine: 1, | |
| 269 | endLine: 1, | |
| 270 | }); | |
| 271 | expect(r).toEqual({ ok: false, reason: "not_found" }); | |
| 272 | }); | |
| 273 | }); | |
| 274 | ||
| 275 | describe("code-suggestions — __internal parity", () => { | |
| 276 | it("re-exports helpers", () => { | |
| 277 | expect(__internal.extractSuggestions).toBe(extractSuggestions); | |
| 278 | expect(__internal.applySuggestionToContent).toBe(applySuggestionToContent); | |
| 279 | expect(__internal.detectLineEnding).toBe(detectLineEnding); | |
| 280 | expect(__internal.splitLines).toBe(splitLines); | |
| 281 | expect(__internal.applyNthSuggestion).toBe(applyNthSuggestion); | |
| 282 | }); | |
| 283 | }); | |
| 284 | ||
| 285 | describe("code-suggestions — splitLines", () => { | |
| 286 | it("handles LF + CRLF correctly", () => { | |
| 287 | expect(splitLines("a\nb\nc")).toEqual(["a", "b", "c"]); | |
| 288 | expect(splitLines("a\r\nb\r\nc")).toEqual(["a", "b", "c"]); | |
| 289 | }); | |
| 290 | }); | |
| 291 | ||
| 292 | describe("code-suggestions — routes", () => { | |
| 293 | // Import the app lazily so the pure tests above don't boot Hono. | |
| 294 | it("POST apply-suggestion 401s for unauthenticated users", async () => { | |
| 295 | const { default: app } = await import("../app"); | |
| 296 | const res = await app.request( | |
| 297 | "/alice/repo/pulls/1/comments/00000000-0000-0000-0000-000000000000/apply-suggestion", | |
| 298 | { method: "POST" } | |
| 299 | ); | |
| 300 | // requireAuth redirects web requests to /login. | |
| 301 | expect([302, 401]).toContain(res.status); | |
| 302 | }); | |
| 303 | ||
| 304 | it("invalid PR number returns 4xx", async () => { | |
| 305 | const { default: app } = await import("../app"); | |
| 306 | const res = await app.request( | |
| 307 | "/alice/repo/pulls/notanum/comments/00000000-0000-0000-0000-000000000000/apply-suggestion", | |
| 308 | { method: "POST" } | |
| 309 | ); | |
| 310 | // Unauth first; if somehow auth'd, would be 400. Both are acceptable. | |
| 311 | expect([302, 400, 401, 404]).toContain(res.status); | |
| 312 | }); | |
| 313 | }); |