All files / src/lib track-multipart.ts

82.5% Statements 66/80
57.14% Branches 24/42
100% Functions 3/3
82.5% Lines 66/80

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 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 1131x                                           2x 2x 2x     2x 2x   2x 2x 2x     2x 2x     5x 5x 5x 5x   5x 5x 52x 5x 5x 52x 47x 47x 52x   5x       5x 5x 5x     5x       5x 5x 5x 5x 5x 5x   5x 1x 1x 5x     5x 2x 2x   5x 5x 5x 5x 5x 5x   5x       2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x  
import path from "node:path";
import type { FastifyRequest } from "fastify";
import { AppError } from "@ontrack/backend-common";
import { parseTrackRating } from "./track-rating.js";
 
export type ParsedTrackMultipart = {
  fileName: string;
  buffer: Buffer;
  name: string;
  trackTypeCode: "ROAD" | "MTB";
  routeLayoutCode: "CIRCULAR" | "LINEAR";
  locality: string;
  trackName: string;
  distanceKm: number;
  elevationM: number;
  surfaceQuality: number;
  difficulty: number;
  scenery: number;
  safety: number;
  description: string;
};
 
function parseNonNegativeInt(raw: string, field: string): number {
  const n = Number.parseInt(raw, 10);
  if (!Number.isFinite(n) || n < 0) {
    throw new AppError(400, "INVALID_FIELD", `Invalid integer field: ${field}`);
  }
  return n;
}
 
function parseDistance(raw: string): number {
  const n = Number.parseFloat(raw);
  if (!Number.isFinite(n) || n < 0) {
    throw new AppError(400, "INVALID_FIELD", "Invalid distanceKm");
  }
  return n;
}
 
/** Поле файла в multipart: `file` (один GPX). Текстовые поля — как в JSON GET /tracks. */
export async function parseTrackMultipart(request: FastifyRequest): Promise<ParsedTrackMultipart> {
  const textFields: Record<string, string> = {};
  let fileBuffer: Buffer | null = null;
  let rawFilename = "";
 
  const parts = request.parts();
  for await (const part of parts) {
    if (part.type === "file") {
      fileBuffer = await part.toBuffer();
      rawFilename = part.filename ?? "";
    } else {
      textFields[part.fieldname] = String(part.value ?? "");
    }
  }
 
  if (!fileBuffer || fileBuffer.length === 0) {
    throw new AppError(400, "MISSING_FILE", "Multipart field `file` with GPX content is required");
  }
 
  const base = path.basename(rawFilename || "upload.gpx");
  let fileName = base.trim().length > 0 ? base : `upload-${Date.now()}.gpx`;
  if (!/\.gpx$/i.test(fileName)) {
    fileName = `${fileName}.gpx`;
  }
  if (fileName.includes("/") || fileName.includes("\\")) {
    throw new AppError(400, "INVALID_FILE_NAME", "Invalid GPX file name");
  }
 
  const name = (textFields.name ?? "").trim();
  const trackName = (textFields.trackName ?? "").trim();
  const locality = (textFields.locality ?? "").trim();
  const description = (textFields.description ?? "").trim();
  const ttRaw = (textFields.trackTypeCode ?? "").trim().toUpperCase();
  const rlRaw = (textFields.routeLayoutCode ?? "").trim().toUpperCase();
 
  if (!name || !trackName) {
    throw new AppError(400, "MISSING_FIELD", "name and trackName are required");
  }
  if (ttRaw !== "ROAD" && ttRaw !== "MTB") {
    throw new AppError(400, "INVALID_TRACK_TYPE", "trackTypeCode must be ROAD or MTB");
  }
  if (rlRaw !== "CIRCULAR" && rlRaw !== "LINEAR") {
    throw new AppError(400, "INVALID_ROUTE_LAYOUT", "routeLayoutCode must be CIRCULAR or LINEAR");
  }
 
  const dm = (textFields.distanceKm ?? "").trim();
  const el = (textFields.elevationM ?? "").trim();
  const sq = (textFields.surfaceQuality ?? "").trim();
  const df = (textFields.difficulty ?? "").trim();
  const sc = (textFields.scenery ?? "").trim();
  const sf = (textFields.safety ?? "").trim();
 
  if (!dm || !el || !sq || !df || !sc || !sf) {
    throw new AppError(400, "MISSING_FIELD", "Numeric metric fields are required");
  }
 
  return {
    fileName,
    buffer: fileBuffer,
    name,
    trackTypeCode: ttRaw as "ROAD" | "MTB",
    routeLayoutCode: rlRaw as "CIRCULAR" | "LINEAR",
    locality,
    trackName,
    distanceKm: parseDistance(dm),
    elevationM: parseNonNegativeInt(el, "elevationM"),
    surfaceQuality: parseTrackRating(sq, "surfaceQuality"),
    difficulty: parseTrackRating(df, "difficulty"),
    scenery: parseTrackRating(sc, "scenery"),
    safety: parseTrackRating(sf, "safety"),
    description,
  };
}