ktg-plugin-marketplace/plugins/linkedin-studio/scripts/analytics/tests/csv-parser.test.ts
Kjell Tore Guttormsen 55c94ee964 feat(linkedin-studio): S16 — optional manual saves in analytics + close deferred onboarding Write MAJOR
Lifts the original v4.0.0 Non-Goal: an optional, manually-entered `saves`
metric through the analytics layer, built location-agnostic (option c) so
UI-brief §9b/M0 relocates the data dir in one place later.

- types: PostMetrics.saves? + Weekly/Monthly summary.totalSaves? (optional);
  new RankableMetric type for the always-numeric index-access whitelist
- parser: dedicated parseOptionalCount() — blank/non-numeric/negative -> undefined
  ("unknown != 0"), genuine 0 kept; saves NOT folded into engagementRate
- reports: totalSaves set only when >=1 post carries saves (backward-compat)
- cli: saves surfaced in import summary + weekly/monthly totals + per-post
- S16-pre: onboarding.md allowed-tools gains Write (closes S15-deferred MAJOR)
- docs (three-doc rule): plugin README boundary + analytics README + root README
  + plugin CLAUDE.md + CHANGELOG; dwell stays explicitly unmeasurable

Independent /trekreview: brief-conformance 0 findings; code-correctness 2 MAJOR
(own lockstep misses) FIXED in-session (parseOptionalCount + edge tests). Gate:
tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98. Within-v4.1.0 refinement
(no surface/count/version change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:23:12 +02:00

193 lines
7.5 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseLinkedInCSV } from "../src/parsers/csv-parser.js";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixturesDir = join(__dirname, "fixtures");
describe("CSV Parser", () => {
it("should parse standard CSV export", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
assert.equal(batch.postCount, 8, "Should have 8 posts");
assert.equal(batch.posts.length, 8, "Posts array should have 8 items");
assert.equal(batch.exportFilename, "sample-export.csv");
assert.ok(batch.batchId, "Should have a batchId");
assert.ok(batch.importedAt, "Should have importedAt timestamp");
// Check first post
const firstPost = batch.posts[0];
assert.ok(firstPost.id, "Post should have an ID");
assert.ok(
firstPost.title.includes("uncomfortable truth"),
"Title should match"
);
assert.equal(firstPost.publishedDate, "2026-01-28");
assert.equal(firstPost.metrics.impressions, 4523);
assert.equal(firstPost.metrics.reactions, 87);
assert.equal(firstPost.metrics.comments, 23);
assert.equal(firstPost.metrics.shares, 12);
assert.equal(firstPost.metrics.clicks, 156);
assert.ok(firstPost.metrics.engagementRate > 0, "Should have engagement rate");
});
it("should handle European format", () => {
const filePath = join(fixturesDir, "european-export.csv");
const batch = parseLinkedInCSV(filePath, "european-export.csv");
assert.equal(batch.postCount, 2, "Should have 2 posts");
// Check that European number format is parsed correctly
const firstPost = batch.posts[0];
assert.equal(firstPost.metrics.impressions, 4523, "Should parse 4.523 as 4523");
assert.equal(firstPost.publishedDate, "2026-01-28", "Should normalize date from DD.MM.YYYY");
const secondPost = batch.posts[1];
assert.equal(secondPost.metrics.impressions, 2891, "Should parse 2.891 as 2891");
assert.equal(secondPost.publishedDate, "2026-01-26", "Should normalize date from DD.MM.YYYY");
});
it("should handle empty CSV", () => {
const filePath = join(fixturesDir, "empty-export.csv");
const batch = parseLinkedInCSV(filePath, "empty-export.csv");
assert.equal(batch.postCount, 0, "Should have 0 posts");
assert.equal(batch.posts.length, 0, "Posts array should be empty");
assert.equal(batch.dateRange.from, "", "Date range from should be empty");
assert.equal(batch.dateRange.to, "", "Date range to should be empty");
});
it("should handle BOM", () => {
const filePath = join(fixturesDir, "bom-export.csv");
const batch = parseLinkedInCSV(filePath, "bom-export.csv");
assert.equal(batch.postCount, 8, "Should parse BOM file correctly");
assert.ok(
batch.posts[0].title.includes("uncomfortable truth"),
"Should parse first post correctly despite BOM"
);
});
it("should calculate engagement rate", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
const firstPost = batch.posts[0];
// (87+23+12+156)/4523 * 100 = 6.14...
const expectedRate = ((87 + 23 + 12 + 156) / 4523) * 100;
assert.ok(
Math.abs(firstPost.metrics.engagementRate - expectedRate) < 0.01,
`Engagement rate should be ~${expectedRate}, got ${firstPost.metrics.engagementRate}`
);
});
it("should generate deterministic post IDs", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch1 = parseLinkedInCSV(filePath, "sample-export.csv");
const batch2 = parseLinkedInCSV(filePath, "sample-export.csv");
// Same post should have same ID
assert.equal(
batch1.posts[0].id,
batch2.posts[0].id,
"Same post should generate same ID"
);
// Different posts should have different IDs
assert.notEqual(
batch1.posts[0].id,
batch1.posts[1].id,
"Different posts should have different IDs"
);
});
it("should normalize dates to YYYY-MM-DD", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
// All dates should be in YYYY-MM-DD format
batch.posts.forEach((post) => {
assert.match(
post.publishedDate,
/^\d{4}-\d{2}-\d{2}$/,
`Date ${post.publishedDate} should be in YYYY-MM-DD format`
);
});
// Check date range
assert.equal(batch.dateRange.from, "2026-01-13", "Date range from should be earliest date");
assert.equal(batch.dateRange.to, "2026-01-28", "Date range to should be latest date");
});
});
describe("Saves (manual-entry, optional)", () => {
it("should parse a Saves column when the user augments the CSV with it", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
assert.equal(batch.postCount, 2, "Should have 2 posts");
// Row 1 carries a saves count read from native LinkedIn analytics.
assert.equal(batch.posts[0].metrics.saves, 42, "Should parse the Saves cell value");
});
it("should leave saves undefined when the Saves cell is blank (unknown != zero)", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
// Row 2's Saves cell is empty — saves is unknown, NOT zero.
assert.equal(
batch.posts[1].metrics.saves,
undefined,
"Blank Saves cell must stay undefined, never coerced to 0"
);
});
it("should leave saves undefined for a standard export with no Saves column (backward-compat)", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
for (const post of batch.posts) {
assert.equal(
post.metrics.saves,
undefined,
"Existing CSV exports without a Saves column must round-trip unchanged"
);
}
});
it("should NOT fold saves into engagementRate (kept comparable to historical data)", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
// Row 1: (100+30+15+200)/5000 * 100 = 6.9 — saves (42) must NOT be in the numerator.
const expectedRate = ((100 + 30 + 15 + 200) / 5000) * 100;
assert.ok(
Math.abs(batch.posts[0].metrics.engagementRate - expectedRate) < 0.01,
`engagementRate should exclude saves (~${expectedRate}), got ${batch.posts[0].metrics.engagementRate}`
);
});
it("should treat an explicit '0' Saves cell as a genuine zero (not undefined)", () => {
const filePath = join(fixturesDir, "saves-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-edge-export.csv");
// A literal 0 in the Saves column is a real reading — zero saves, not unknown.
assert.equal(batch.posts[0].metrics.saves, 0, "Explicit '0' must stay 0, not collapse to undefined");
});
it("should leave saves undefined for a non-numeric Saves cell (unknown, never coerced to 0)", () => {
const filePath = join(fixturesDir, "saves-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-edge-export.csv");
// "n/a" is not a count — saves stays unknown, NOT silently flattened to 0.
assert.equal(
batch.posts[1].metrics.saves,
undefined,
"Non-numeric Saves cell must stay undefined — never coerced to 0"
);
});
});