|
1 | 1 | /** |
2 | | - * Tests for normalizeSqliteValue and normalizeSqliteBindings |
| 2 | + * Tests for normalizeSqliteValue, normalizeSqliteBindings, and unescapeText |
3 | 3 | * (WL-0MLRSV1XF14KM6WT) |
4 | 4 | */ |
5 | 5 |
|
6 | 6 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
7 | | -import { normalizeSqliteValue, normalizeSqliteBindings } from '../src/persistent-store.js'; |
| 7 | +import { normalizeSqliteValue, normalizeSqliteBindings, unescapeText } from '../src/persistent-store.js'; |
8 | 8 | import { WorklogDatabase } from '../src/database.js'; |
9 | 9 | import { createTempDir, cleanupTempDir, createTempJsonlPath, createTempDbPath } from './test-utils.js'; |
10 | 10 |
|
@@ -287,3 +287,155 @@ describe('SQLite binding round-trip', () => { |
287 | 287 | expect(outbound[0].toId).toBe(b.id); |
288 | 288 | }); |
289 | 289 | }); |
| 290 | + |
| 291 | +// --------------------------------------------------------------------------- |
| 292 | +// Unit tests for unescapeText |
| 293 | +// --------------------------------------------------------------------------- |
| 294 | + |
| 295 | +describe('unescapeText', () => { |
| 296 | + it('returns an empty string unchanged', () => { |
| 297 | + expect(unescapeText('')).toBe(''); |
| 298 | + }); |
| 299 | + |
| 300 | + it('passes through plain text with no escape sequences', () => { |
| 301 | + expect(unescapeText('Hello World')).toBe('Hello World'); |
| 302 | + }); |
| 303 | + |
| 304 | + it('converts \\n to a real newline', () => { |
| 305 | + expect(unescapeText('Line\\nBreak')).toBe('Line\nBreak'); |
| 306 | + }); |
| 307 | + |
| 308 | + it('converts \\t to a real tab', () => { |
| 309 | + expect(unescapeText('Col\\tValue')).toBe('Col\tValue'); |
| 310 | + }); |
| 311 | + |
| 312 | + it('converts \\r to a real carriage return', () => { |
| 313 | + expect(unescapeText('Foo\\rBar')).toBe('Foo\rBar'); |
| 314 | + }); |
| 315 | + |
| 316 | + it('converts \\\\ to a single backslash', () => { |
| 317 | + expect(unescapeText('path\\\\file')).toBe('path\\file'); |
| 318 | + }); |
| 319 | + |
| 320 | + it('handles multiple escape sequences in a single string', () => { |
| 321 | + expect(unescapeText('a\\nb\\tc\\\\d')).toBe('a\nb\tc\\d'); |
| 322 | + }); |
| 323 | + |
| 324 | + it('does not double-decode when a backslash precedes a backslash-n', () => { |
| 325 | + // Input: 4 chars: \ \ n -> backslash + n (not a newline) |
| 326 | + expect(unescapeText('\\\\n')).toBe('\\n'); |
| 327 | + }); |
| 328 | + |
| 329 | + it('preserves double quotes unchanged', () => { |
| 330 | + expect(unescapeText('say "hello"')).toBe('say "hello"'); |
| 331 | + }); |
| 332 | + |
| 333 | + it('preserves backticks unchanged', () => { |
| 334 | + expect(unescapeText('use `code`')).toBe('use `code`'); |
| 335 | + }); |
| 336 | + |
| 337 | + it('preserves unrecognised backslash sequences unchanged', () => { |
| 338 | + // \x is not a recognised sequence; the backslash is kept as-is |
| 339 | + expect(unescapeText('foo\\xbar')).toBe('foo\\xbar'); |
| 340 | + }); |
| 341 | +}); |
| 342 | + |
| 343 | +// --------------------------------------------------------------------------- |
| 344 | +// Integration round-trip tests: unescaping applied on DB write |
| 345 | +// --------------------------------------------------------------------------- |
| 346 | + |
| 347 | +describe('unescapeText round-trip via DB', () => { |
| 348 | + let tempDir: string; |
| 349 | + let dbPath: string; |
| 350 | + let jsonlPath: string; |
| 351 | + let db: WorklogDatabase; |
| 352 | + |
| 353 | + beforeEach(() => { |
| 354 | + tempDir = createTempDir(); |
| 355 | + dbPath = createTempDbPath(tempDir); |
| 356 | + jsonlPath = createTempJsonlPath(tempDir); |
| 357 | + db = new WorklogDatabase('UT', dbPath, jsonlPath, true, true); |
| 358 | + }); |
| 359 | + |
| 360 | + afterEach(() => { |
| 361 | + db.close(); |
| 362 | + cleanupTempDir(tempDir); |
| 363 | + }); |
| 364 | + |
| 365 | + it('stores description with real newline when input contains \\n escape artifact', () => { |
| 366 | + const created = db.create({ |
| 367 | + title: 'Escape test', |
| 368 | + description: 'Line\\nBreak', |
| 369 | + }); |
| 370 | + |
| 371 | + const loaded = db.get(created.id); |
| 372 | + expect(loaded).toBeDefined(); |
| 373 | + // Stored text must contain a real newline, not the two-char sequence \n |
| 374 | + expect(loaded!.description).toBe('Line\nBreak'); |
| 375 | + expect(loaded!.description).not.toContain('\\n'); |
| 376 | + }); |
| 377 | + |
| 378 | + it('stores title with real newline when input contains \\n escape artifact', () => { |
| 379 | + const created = db.create({ |
| 380 | + title: 'Title\\nWith Escape', |
| 381 | + }); |
| 382 | + |
| 383 | + const loaded = db.get(created.id); |
| 384 | + expect(loaded).toBeDefined(); |
| 385 | + expect(loaded!.title).toBe('Title\nWith Escape'); |
| 386 | + expect(loaded!.title).not.toContain('\\n'); |
| 387 | + }); |
| 388 | + |
| 389 | + it('stores comment body with real newline when input contains \\n escape artifact', () => { |
| 390 | + const item = db.create({ title: 'Escape comment test' }); |
| 391 | + |
| 392 | + db.createComment({ |
| 393 | + workItemId: item.id, |
| 394 | + author: 'tester', |
| 395 | + comment: 'First\\nSecond', |
| 396 | + references: [], |
| 397 | + }); |
| 398 | + |
| 399 | + const comments = db.getCommentsForWorkItem(item.id); |
| 400 | + expect(comments).toHaveLength(1); |
| 401 | + expect(comments[0].comment).toBe('First\nSecond'); |
| 402 | + expect(comments[0].comment).not.toContain('\\n'); |
| 403 | + }); |
| 404 | + |
| 405 | + it('unescapes audit text field but leaves audit JSON structure intact', () => { |
| 406 | + const created = db.create({ |
| 407 | + title: 'Audit escape test', |
| 408 | + audit: { |
| 409 | + time: '2026-01-01T00:00:00.000Z', |
| 410 | + author: 'tester', |
| 411 | + text: 'Ready to close: Yes\\nExtra detail', |
| 412 | + status: 'Complete', |
| 413 | + }, |
| 414 | + }); |
| 415 | + |
| 416 | + const loaded = db.get(created.id); |
| 417 | + expect(loaded).toBeDefined(); |
| 418 | + expect(loaded!.audit).toBeDefined(); |
| 419 | + // audit.text should have a real newline |
| 420 | + expect(loaded!.audit!.text).toBe('Ready to close: Yes\nExtra detail'); |
| 421 | + expect(loaded!.audit!.text).not.toContain('\\n'); |
| 422 | + // Structured audit fields must remain intact |
| 423 | + expect(loaded!.audit!.author).toBe('tester'); |
| 424 | + expect(loaded!.audit!.status).toBe('Complete'); |
| 425 | + }); |
| 426 | + |
| 427 | + it('does not alter tags (JSON field) when description contains escape artifacts', () => { |
| 428 | + const created = db.create({ |
| 429 | + title: 'Tags intact', |
| 430 | + description: 'Desc\\nValue', |
| 431 | + tags: ['tag\\none', 'normal'], |
| 432 | + }); |
| 433 | + |
| 434 | + const loaded = db.get(created.id); |
| 435 | + expect(loaded).toBeDefined(); |
| 436 | + // Description should be unescaped |
| 437 | + expect(loaded!.description).toBe('Desc\nValue'); |
| 438 | + // Tags are JSON-structured; the raw tag values are preserved as-is |
| 439 | + expect(loaded!.tags).toEqual(['tag\\none', 'normal']); |
| 440 | + }); |
| 441 | +}); |
0 commit comments