All files / src/handlers rooms.ts

100% Statements 20/20
100% Branches 16/16
100% Functions 1/1
100% Lines 18/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86                                                        3x                               17x 17x 14x 7x 7x 7x 6x           3x               4x   7x 1x 1x   6x 1x 1x             5x 4x          
/**
 * Phase 5 room HTTP handlers (body classification + decode logic). The Hono
 * glue in `src/routes/rooms.ts` wraps these with DO dispatch and response
 * shaping; the pure functions here are 100% coverage-gated.
 *
 * Body content types accepted by PUT `/_/:room` and POST `/_` (per §6.1):
 *   - `application/json`                           → JSON `{snapshot: string}`
 *   - `text/x-socialcalc`                          → raw SocialCalc save text
 *   - `text/plain`                                 → treat like `text/x-socialcalc`
 *                                                   (legacy fallback when no
 *                                                   content-type matches — see
 *                                                   src/main.ls:329-330)
 *   - `text/csv`                                   → CSV converted via csvToSave
 *   - `text/x-ethercalc-csv-double-encoded`        → same, after UTF-8→Latin-1→UTF-8
 *   - `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
 *   - `application/vnd.oasis.opendocument.spreadsheet`
 *                                                   → deferred to Phase 8
 *                                                   (501 `xlsx import lands
 *                                                   in Phase 8`)
 */
import { csvToSocialCalc, decodeDoubleEncoded } from '../lib/csv.ts';
 
/** The three types of payload this layer understands. */
export type DecodedBody =
  | { readonly kind: 'save'; readonly snapshot: string }
  | { readonly kind: 'xlsx-deferred' }
  | { readonly kind: 'empty' };
 
const XLSX_MIMES: readonly string[] = [
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.oasis.opendocument.spreadsheet',
];
 
/**
 * Classify a raw request body into a DecodedBody. `contentType` should be
 * the raw header value (or empty string); `bytes` is the read body. JSON
 * bodies are parsed here and asserted to contain a `snapshot` string — a
 * malformed payload produces `{kind: 'empty'}`, matching the legacy
 * `cb snapshot if snapshot` guard in src/main.ls:348-349.
 */
export function classifyRequestBody(
  contentType: string,
  bytes: Uint8Array,
): DecodedBody {
  const ct = contentType.split(';')[0]!.trim().toLowerCase();
  if (XLSX_MIMES.includes(ct)) return { kind: 'xlsx-deferred' };
  if (ct === 'application/json') {
    try {
      const text = new TextDecoder('utf-8').decode(bytes);
      const parsed = JSON.parse(text) as unknown;
      if (
        parsed &&
        typeof parsed === 'object' &&
        'snapshot' in (parsed as Record<string, unknown>) &&
        typeof (parsed as Record<string, unknown>).snapshot === 'string'
      ) {
        return {
          kind: 'save',
          snapshot: (parsed as { snapshot: string }).snapshot,
        };
      }
    } catch {
      /* fall through to empty */
    }
    return { kind: 'empty' };
  }
  if (ct === 'text/x-ethercalc-csv-double-encoded') {
    const csv = decodeDoubleEncoded(bytes);
    return { kind: 'save', snapshot: csvToSocialCalc(csv) };
  }
  if (ct === 'text/csv') {
    const csv = new TextDecoder('utf-8').decode(bytes);
    return { kind: 'save', snapshot: csvToSocialCalc(csv) };
  }
  // `text/x-socialcalc`, `text/plain`, and anything else — treat as raw
  // SocialCalc save text. The legacy server also falls back to
  // `ConvertOtherFormatToSave(csv, 'csv')` for unknown bodies via `J`'s
  // to_socialcalc path, but for Phase 5 we mirror only the well-known
  // content-types. Future phases can add more fallback paths.
  if (bytes.byteLength === 0) return { kind: 'empty' };
  return {
    kind: 'save',
    snapshot: new TextDecoder('utf-8').decode(bytes),
  };
}