feat(linkedin-thought-leadership): v1.0.0 — initial open-source import
Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and AI-assisted content creation. Updated for the January 2026 360Brew algorithm change. 16 agents, 25 commands, 6 skills, 9 hooks, 24 reference docs. Personal data sanitized: voice samples generalized to template, high-engagement posts cleared, region-specific references replaced with placeholders. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7194a37129
commit
39f8b275a6
143 changed files with 32662 additions and 0 deletions
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { detectAlerts, detectWeeklyAlerts } from "../src/utils/alerts.js";
|
||||
import type { PostAnalytics } from "../src/models/types.js";
|
||||
|
||||
/**
|
||||
* Helper function to create PostAnalytics with default values.
|
||||
*/
|
||||
function makePost(overrides: Partial<PostAnalytics> = {}): PostAnalytics {
|
||||
return {
|
||||
id: `post-${Math.random().toString(36).substr(2, 9)}`,
|
||||
title: "Test Post",
|
||||
publishedDate: "2026-01-15",
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 100,
|
||||
engagementRate: 5.0,
|
||||
},
|
||||
importedAt: new Date().toISOString(),
|
||||
exportSource: "LinkedIn",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("alerts", () => {
|
||||
describe("detectAlerts", () => {
|
||||
test("should find spike posts", () => {
|
||||
// Create posts with one outlier high value
|
||||
// Need value that's > 2.0 standard deviations from mean (not >=)
|
||||
// Using more base values to create a scenario where outlier exceeds threshold
|
||||
const posts = [
|
||||
makePost({ id: "1", title: "Normal Post 1", metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", title: "Normal Post 2", metrics: { impressions: 1200, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", title: "Normal Post 3", metrics: { impressions: 1100, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "4", title: "Normal Post 4", metrics: { impressions: 900, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "5", title: "Normal Post 5", metrics: { impressions: 1050, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "6", title: "Viral Post", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
assert.ok(alerts.length > 0, "Should detect at least one alert");
|
||||
const spikeAlert = alerts.find(a => a.type === "spike");
|
||||
assert.ok(spikeAlert, "Should have a spike alert");
|
||||
assert.equal(spikeAlert.severity, "info");
|
||||
assert.equal(spikeAlert.postId, "6");
|
||||
assert.ok(spikeAlert.message.includes("Viral Post"));
|
||||
});
|
||||
|
||||
test("should find drop posts", () => {
|
||||
// Create posts with one outlier low value
|
||||
const posts = [
|
||||
makePost({ id: "1", title: "Normal Post 1", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", title: "Normal Post 2", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", title: "Normal Post 3", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "4", title: "Low Reach Post", metrics: { impressions: 100, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
assert.equal(alerts.length, 1);
|
||||
assert.equal(alerts[0].type, "drop");
|
||||
assert.equal(alerts[0].severity, "warning");
|
||||
assert.equal(alerts[0].postId, "4");
|
||||
assert.ok(alerts[0].message.includes("Low Reach Post"));
|
||||
});
|
||||
|
||||
test("should return empty for uniform data", () => {
|
||||
const posts = [
|
||||
makePost({ id: "1", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
assert.equal(alerts.length, 0);
|
||||
});
|
||||
|
||||
test("should handle empty posts array", () => {
|
||||
const alerts = detectAlerts([]);
|
||||
assert.equal(alerts.length, 0);
|
||||
});
|
||||
|
||||
test("should sort alerts by severity", () => {
|
||||
// Create scenario with multiple alerts of different severities
|
||||
// For this, we'd need to manually create alerts with different severities
|
||||
// Since detectAlerts only produces "info" spikes and "warning" drops,
|
||||
// let's just verify the sorting works with what we have
|
||||
const posts = [
|
||||
makePost({ id: "1", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", metrics: { impressions: 100, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }), // Drop
|
||||
makePost({ id: "4", metrics: { impressions: 50000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }), // Spike
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
// Should have drop (warning) first, then spike (info)
|
||||
if (alerts.length > 1) {
|
||||
assert.equal(alerts[0].severity, "warning");
|
||||
assert.equal(alerts[1].severity, "info");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectWeeklyAlerts", () => {
|
||||
test("should detect critical drop in impressions", () => {
|
||||
const current = { impressions: 1000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 3000, engagementRate: 5.0 }; // -66.7% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const impressionAlerts = alerts.filter((a) => a.metric === "impressions");
|
||||
assert.ok(impressionAlerts.length > 0);
|
||||
assert.equal(impressionAlerts[0].severity, "critical");
|
||||
assert.equal(impressionAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect warning drop in impressions", () => {
|
||||
const current = { impressions: 6000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // -40% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const impressionAlerts = alerts.filter((a) => a.metric === "impressions");
|
||||
assert.ok(impressionAlerts.length > 0);
|
||||
assert.equal(impressionAlerts[0].severity, "warning");
|
||||
assert.equal(impressionAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect spike in impressions", () => {
|
||||
const current = { impressions: 25000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // +150% increase
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const impressionAlerts = alerts.filter((a) => a.metric === "impressions");
|
||||
assert.ok(impressionAlerts.length > 0);
|
||||
assert.equal(impressionAlerts[0].severity, "info");
|
||||
assert.equal(impressionAlerts[0].type, "spike");
|
||||
});
|
||||
|
||||
test("should detect critical drop in engagement rate", () => {
|
||||
const current = { impressions: 10000, engagementRate: 2.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 6.0 }; // -66.7% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const engagementAlerts = alerts.filter((a) => a.metric === "engagementRate");
|
||||
assert.ok(engagementAlerts.length > 0);
|
||||
assert.equal(engagementAlerts[0].severity, "critical");
|
||||
assert.equal(engagementAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect warning drop in engagement rate", () => {
|
||||
const current = { impressions: 10000, engagementRate: 3.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // -40% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const engagementAlerts = alerts.filter((a) => a.metric === "engagementRate");
|
||||
assert.ok(engagementAlerts.length > 0);
|
||||
assert.equal(engagementAlerts[0].severity, "warning");
|
||||
assert.equal(engagementAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect spike in engagement rate", () => {
|
||||
const current = { impressions: 10000, engagementRate: 12.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // +140% increase
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const engagementAlerts = alerts.filter((a) => a.metric === "engagementRate");
|
||||
assert.ok(engagementAlerts.length > 0);
|
||||
assert.equal(engagementAlerts[0].severity, "info");
|
||||
assert.equal(engagementAlerts[0].type, "spike");
|
||||
});
|
||||
|
||||
test("should return empty for stable metrics", () => {
|
||||
const current = { impressions: 10000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 10200, engagementRate: 5.1 }; // Small changes
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
assert.equal(alerts.length, 0);
|
||||
});
|
||||
|
||||
test("should handle multiple alerts and sort by severity", () => {
|
||||
const current = { impressions: 1000, engagementRate: 2.0 };
|
||||
const previous = { impressions: 3000, engagementRate: 6.0 }; // Both critical drops
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
assert.ok(alerts.length >= 2);
|
||||
// All should be critical
|
||||
alerts.forEach((alert) => {
|
||||
assert.equal(alert.severity, "critical");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/bom-export.csv
vendored
Normal file
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/bom-export.csv
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks"
|
||||
"The uncomfortable truth about AI governance in public sector. Most organizations are focused on...",2026-01-28,4523,87,23,12,156
|
||||
"3 frameworks I use daily for evaluating AI tools before recommending them to government...",2026-01-26,2891,54,18,8,94
|
||||
"Why 80% of AI projects fail in public sector (and what the 20% do differently)...",2026-01-24,8712,192,45,31,287
|
||||
"Just spent 3 hours debugging a Copilot Studio flow. The issue? A single missing...",2026-01-22,1543,32,41,5,67
|
||||
"Hot take: The best AI strategy for 2026 isn't about AI at all. It's about...",2026-01-20,6234,143,67,28,198
|
||||
"I asked 50 government employees about their biggest AI challenge. The #1 answer surprised...",2026-01-17,5891,128,89,19,234
|
||||
"Unpopular opinion: Low-code/no-code platforms are actually harder than they look. Here's why...",2026-01-15,3456,76,34,11,123
|
||||
"The meeting that changed how I think about AI adoption in large organizations...",2026-01-13,2198,48,22,7,89
|
||||
|
1
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/empty-export.csv
vendored
Normal file
1
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/empty-export.csv
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks"
|
||||
|
3
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/european-export.csv
vendored
Normal file
3
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/european-export.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"Content";"Date";"Impressions";"Reactions";"Comments";"Shares";"Clicks"
|
||||
"The uncomfortable truth about AI governance...";"28.01.2026";"4.523";"87";"23";"12";"156"
|
||||
"3 frameworks I use daily...";"26.01.2026";"2.891";"54";"18";"8";"94"
|
||||
|
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/sample-export.csv
vendored
Normal file
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/sample-export.csv
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks"
|
||||
"The uncomfortable truth about AI governance in public sector. Most organizations are focused on...",2026-01-28,4523,87,23,12,156
|
||||
"3 frameworks I use daily for evaluating AI tools before recommending them to government...",2026-01-26,2891,54,18,8,94
|
||||
"Why 80% of AI projects fail in public sector (and what the 20% do differently)...",2026-01-24,8712,192,45,31,287
|
||||
"Just spent 3 hours debugging a Copilot Studio flow. The issue? A single missing...",2026-01-22,1543,32,41,5,67
|
||||
"Hot take: The best AI strategy for 2026 isn't about AI at all. It's about...",2026-01-20,6234,143,67,28,198
|
||||
"I asked 50 government employees about their biggest AI challenge. The #1 answer surprised...",2026-01-17,5891,128,89,19,234
|
||||
"Unpopular opinion: Low-code/no-code platforms are actually harder than they look. Here's why...",2026-01-15,3456,76,34,11,123
|
||||
"The meeting that changed how I think about AI adoption in large organizations...",2026-01-13,2198,48,22,7,89
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mean,
|
||||
standardDeviation,
|
||||
trendDirection,
|
||||
percentChange,
|
||||
deviationsFromMean,
|
||||
} from "../src/utils/stats.js";
|
||||
|
||||
describe("stats", () => {
|
||||
describe("mean", () => {
|
||||
test("should return mean of values", () => {
|
||||
const result = mean([10, 20, 30]);
|
||||
assert.equal(result, 20);
|
||||
});
|
||||
|
||||
test("should return 0 for empty array", () => {
|
||||
const result = mean([]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should handle single value", () => {
|
||||
const result = mean([42]);
|
||||
assert.equal(result, 42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("standardDeviation", () => {
|
||||
test("should calculate correctly for known values", () => {
|
||||
// For [2, 4, 4, 4, 5, 5, 7, 9]:
|
||||
// Mean = 5
|
||||
// Variance = ((2-5)^2 + (4-5)^2 + (4-5)^2 + (4-5)^2 + (5-5)^2 + (5-5)^2 + (7-5)^2 + (9-5)^2) / 8
|
||||
// Variance = (9 + 1 + 1 + 1 + 0 + 0 + 4 + 16) / 8 = 32 / 8 = 4
|
||||
// StdDev = 2
|
||||
const result = standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]);
|
||||
assert.equal(result, 2);
|
||||
});
|
||||
|
||||
test("should return 0 for single value", () => {
|
||||
const result = standardDeviation([5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should return 0 for empty array", () => {
|
||||
const result = standardDeviation([]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should handle uniform values", () => {
|
||||
const result = standardDeviation([5, 5, 5, 5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trendDirection", () => {
|
||||
test("should detect up trend", () => {
|
||||
const result = trendDirection(110, 100);
|
||||
assert.equal(result, "up");
|
||||
});
|
||||
|
||||
test("should detect down trend", () => {
|
||||
const result = trendDirection(90, 100);
|
||||
assert.equal(result, "down");
|
||||
});
|
||||
|
||||
test("should detect stable trend", () => {
|
||||
const result = trendDirection(103, 100);
|
||||
assert.equal(result, "stable");
|
||||
});
|
||||
|
||||
test("should use custom threshold", () => {
|
||||
const result = trendDirection(103, 100, 10);
|
||||
assert.equal(result, "stable");
|
||||
});
|
||||
|
||||
test("should detect up with custom threshold", () => {
|
||||
const result = trendDirection(112, 100, 10);
|
||||
assert.equal(result, "up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentChange", () => {
|
||||
test("should calculate positive change correctly", () => {
|
||||
const result = percentChange(110, 100);
|
||||
assert.equal(result, 10);
|
||||
});
|
||||
|
||||
test("should calculate negative change correctly", () => {
|
||||
const result = percentChange(90, 100);
|
||||
assert.equal(result, -10);
|
||||
});
|
||||
|
||||
test("should handle zero previous value", () => {
|
||||
const result = percentChange(100, 0);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should handle zero current value", () => {
|
||||
const result = percentChange(0, 100);
|
||||
assert.equal(result, -100);
|
||||
});
|
||||
|
||||
test("should handle no change", () => {
|
||||
const result = percentChange(100, 100);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviationsFromMean", () => {
|
||||
test("should calculate correctly for value above mean", () => {
|
||||
// Mean of [10, 20, 30] = 20
|
||||
// StdDev = sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2) / 3) = sqrt((100 + 0 + 100) / 3) = sqrt(66.67) ≈ 8.165
|
||||
// Deviations for 30 = (30 - 20) / 8.165 ≈ 1.225
|
||||
const result = deviationsFromMean(30, [10, 20, 30]);
|
||||
assert.ok(Math.abs(result - 1.225) < 0.01);
|
||||
});
|
||||
|
||||
test("should calculate correctly for value below mean", () => {
|
||||
const result = deviationsFromMean(10, [10, 20, 30]);
|
||||
assert.ok(Math.abs(result + 1.225) < 0.01); // Negative deviation
|
||||
});
|
||||
|
||||
test("should return 0 for uniform data", () => {
|
||||
const result = deviationsFromMean(5, [5, 5, 5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should return 0 for single value", () => {
|
||||
const result = deviationsFromMean(5, [5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should calculate for value at mean", () => {
|
||||
const result = deviationsFromMean(20, [10, 20, 30]);
|
||||
assert.ok(Math.abs(result) < 0.01);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
ensureDirectories,
|
||||
saveBatch,
|
||||
loadAllBatches,
|
||||
loadAllPosts,
|
||||
listExports,
|
||||
saveWeeklyReport,
|
||||
loadWeeklyReport,
|
||||
loadAllWeeklyReports,
|
||||
} from "../src/utils/storage.js";
|
||||
import type { AnalyticsBatch, PostAnalytics, WeeklyReport } from "../src/models/types.js";
|
||||
|
||||
// Helper function to create test post data
|
||||
function createTestPost(overrides?: Partial<PostAnalytics>): PostAnalytics {
|
||||
return {
|
||||
id: overrides?.id || "test-post-1",
|
||||
title: overrides?.title || "Test post content",
|
||||
publishedDate: overrides?.publishedDate || "2026-01-15",
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
...(overrides?.metrics || {}),
|
||||
},
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportSource: overrides?.exportSource || "test-export.csv",
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create test batch data
|
||||
function createTestBatch(overrides?: Partial<AnalyticsBatch>): AnalyticsBatch {
|
||||
const posts = overrides?.posts || [createTestPost()];
|
||||
return {
|
||||
batchId: overrides?.batchId || "12345678-1234-1234-1234-123456789abc",
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportFilename: overrides?.exportFilename || "test-export.csv",
|
||||
dateRange: overrides?.dateRange || { from: "2026-01-15", to: "2026-01-20" },
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create test weekly report
|
||||
function createTestWeeklyReport(overrides?: Partial<WeeklyReport>): WeeklyReport {
|
||||
return {
|
||||
week: overrides?.week || "2026-W03",
|
||||
generatedAt: overrides?.generatedAt || "2026-01-20T10:00:00Z",
|
||||
summary: {
|
||||
totalPosts: 5,
|
||||
totalImpressions: 5000,
|
||||
totalReactions: 250,
|
||||
totalComments: 50,
|
||||
totalShares: 25,
|
||||
totalClicks: 100,
|
||||
avgEngagementRate: 8.5,
|
||||
avgImpressionsPerPost: 1000,
|
||||
...(overrides?.summary || {}),
|
||||
},
|
||||
topPerformers: overrides?.topPerformers || [],
|
||||
underperformers: overrides?.underperformers || [],
|
||||
trends: {
|
||||
impressionsTrend: "up",
|
||||
engagementTrend: "stable",
|
||||
comparedTo: "2026-W02",
|
||||
percentChange: {
|
||||
impressions: 10,
|
||||
engagement: 2,
|
||||
},
|
||||
...(overrides?.trends || {}),
|
||||
},
|
||||
alerts: overrides?.alerts || [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage", () => {
|
||||
let tempDir: string;
|
||||
|
||||
// Create temp directory before each test
|
||||
function setupTempDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "analytics-test-"));
|
||||
}
|
||||
|
||||
// Clean up temp directory after each test
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("ensureDirectories", () => {
|
||||
test("should create directories", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "exports")));
|
||||
assert.ok(existsSync(join(tempDir, "posts")));
|
||||
assert.ok(existsSync(join(tempDir, "weekly-reports")));
|
||||
});
|
||||
|
||||
test("should not fail if directories already exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
// Call again - should not throw
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "exports")));
|
||||
assert.ok(existsSync(join(tempDir, "posts")));
|
||||
assert.ok(existsSync(join(tempDir, "weekly-reports")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveBatch", () => {
|
||||
test("should write JSON file", () => {
|
||||
tempDir = setupTempDir();
|
||||
const batch = createTestBatch();
|
||||
|
||||
const filename = saveBatch(tempDir, batch);
|
||||
|
||||
assert.ok(filename.startsWith("2026-01-15-"));
|
||||
assert.ok(filename.endsWith(".json"));
|
||||
|
||||
const filepath = join(tempDir, "posts", filename);
|
||||
assert.ok(existsSync(filepath));
|
||||
|
||||
// Verify content
|
||||
const loadedBatches = loadAllBatches(tempDir);
|
||||
assert.equal(loadedBatches.length, 1);
|
||||
assert.equal(loadedBatches[0].batchId, batch.batchId);
|
||||
});
|
||||
|
||||
test("should create directories if they don't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
const batch = createTestBatch();
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "posts")));
|
||||
});
|
||||
|
||||
test("should use short batch ID in filename", () => {
|
||||
tempDir = setupTempDir();
|
||||
const batch = createTestBatch({
|
||||
batchId: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
dateRange: { from: "2026-01-15", to: "2026-01-20" },
|
||||
});
|
||||
|
||||
const filename = saveBatch(tempDir, batch);
|
||||
|
||||
assert.ok(filename.startsWith("2026-01-15-abcdef12"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllBatches", () => {
|
||||
test("should load saved batches", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const batch1 = createTestBatch({
|
||||
batchId: "batch1",
|
||||
importedAt: "2026-01-20T10:00:00Z",
|
||||
});
|
||||
const batch2 = createTestBatch({
|
||||
batchId: "batch2",
|
||||
importedAt: "2026-01-21T10:00:00Z",
|
||||
dateRange: { from: "2026-01-21", to: "2026-01-21" },
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch1);
|
||||
saveBatch(tempDir, batch2);
|
||||
|
||||
const batches = loadAllBatches(tempDir);
|
||||
|
||||
assert.equal(batches.length, 2);
|
||||
assert.equal(batches[0].batchId, "batch1");
|
||||
assert.equal(batches[1].batchId, "batch2");
|
||||
});
|
||||
|
||||
test("should return empty array if posts directory doesn't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const batches = loadAllBatches(tempDir);
|
||||
|
||||
assert.deepEqual(batches, []);
|
||||
});
|
||||
|
||||
test("should sort by importedAt timestamp", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const batch1 = createTestBatch({
|
||||
batchId: "batch1",
|
||||
importedAt: "2026-01-22T10:00:00Z",
|
||||
dateRange: { from: "2026-01-22", to: "2026-01-22" },
|
||||
});
|
||||
const batch2 = createTestBatch({
|
||||
batchId: "batch2",
|
||||
importedAt: "2026-01-20T10:00:00Z",
|
||||
});
|
||||
const batch3 = createTestBatch({
|
||||
batchId: "batch3",
|
||||
importedAt: "2026-01-21T10:00:00Z",
|
||||
dateRange: { from: "2026-01-21", to: "2026-01-21" },
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch1);
|
||||
saveBatch(tempDir, batch2);
|
||||
saveBatch(tempDir, batch3);
|
||||
|
||||
const batches = loadAllBatches(tempDir);
|
||||
|
||||
assert.equal(batches.length, 3);
|
||||
assert.equal(batches[0].batchId, "batch2"); // Earliest
|
||||
assert.equal(batches[1].batchId, "batch3");
|
||||
assert.equal(batches[2].batchId, "batch1"); // Latest
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllPosts", () => {
|
||||
test("should deduplicate by post ID", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const post1 = createTestPost({
|
||||
id: "post1",
|
||||
title: "Old version",
|
||||
publishedDate: "2026-01-15",
|
||||
});
|
||||
const post1Updated = createTestPost({
|
||||
id: "post1",
|
||||
title: "New version",
|
||||
publishedDate: "2026-01-15",
|
||||
});
|
||||
const post2 = createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-16",
|
||||
});
|
||||
|
||||
const batch1 = createTestBatch({
|
||||
batchId: "batch1",
|
||||
importedAt: "2026-01-20T10:00:00Z",
|
||||
posts: [post1, post2],
|
||||
});
|
||||
const batch2 = createTestBatch({
|
||||
batchId: "batch2",
|
||||
importedAt: "2026-01-21T10:00:00Z",
|
||||
dateRange: { from: "2026-01-21", to: "2026-01-21" },
|
||||
posts: [post1Updated], // Later import of post1
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch1);
|
||||
saveBatch(tempDir, batch2);
|
||||
|
||||
const posts = loadAllPosts(tempDir);
|
||||
|
||||
assert.equal(posts.length, 2);
|
||||
// Should have the updated version of post1
|
||||
const foundPost1 = posts.find(p => p.id === "post1");
|
||||
assert.equal(foundPost1?.title, "New version");
|
||||
});
|
||||
|
||||
test("should sort by publishedDate descending", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const post1 = createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-15",
|
||||
});
|
||||
const post2 = createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-17",
|
||||
});
|
||||
const post3 = createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2026-01-16",
|
||||
});
|
||||
|
||||
const batch = createTestBatch({
|
||||
posts: [post1, post2, post3],
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const posts = loadAllPosts(tempDir);
|
||||
|
||||
assert.equal(posts.length, 3);
|
||||
assert.equal(posts[0].id, "post2"); // 2026-01-17
|
||||
assert.equal(posts[1].id, "post3"); // 2026-01-16
|
||||
assert.equal(posts[2].id, "post1"); // 2026-01-15
|
||||
});
|
||||
|
||||
test("should return empty array if no batches exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts = loadAllPosts(tempDir);
|
||||
|
||||
assert.deepEqual(posts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listExports", () => {
|
||||
test("should list CSV files", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
const exportsDir = join(tempDir, "exports");
|
||||
writeFileSync(join(exportsDir, "export1.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "export2.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "other.txt"), "data"); // Non-CSV
|
||||
|
||||
const exports = listExports(tempDir);
|
||||
|
||||
assert.equal(exports.length, 2);
|
||||
assert.ok(exports.includes("export1.csv"));
|
||||
assert.ok(exports.includes("export2.csv"));
|
||||
});
|
||||
|
||||
test("should return empty for missing directory", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const exports = listExports(tempDir);
|
||||
|
||||
assert.deepEqual(exports, []);
|
||||
});
|
||||
|
||||
test("should return sorted list", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
const exportsDir = join(tempDir, "exports");
|
||||
writeFileSync(join(exportsDir, "c.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "a.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "b.csv"), "data");
|
||||
|
||||
const exports = listExports(tempDir);
|
||||
|
||||
assert.deepEqual(exports, ["a.csv", "b.csv", "c.csv"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveWeeklyReport", () => {
|
||||
test("should write report JSON", () => {
|
||||
tempDir = setupTempDir();
|
||||
const report = createTestWeeklyReport({ week: "2026-W03" });
|
||||
|
||||
const filename = saveWeeklyReport(tempDir, report);
|
||||
|
||||
assert.equal(filename, "2026-W03.json");
|
||||
|
||||
const filepath = join(tempDir, "weekly-reports", filename);
|
||||
assert.ok(existsSync(filepath));
|
||||
|
||||
// Verify content
|
||||
const loaded = loadWeeklyReport(tempDir, "2026-W03");
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.week, "2026-W03");
|
||||
assert.equal(loaded.summary.totalPosts, 5);
|
||||
});
|
||||
|
||||
test("should create directories if they don't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
const report = createTestWeeklyReport();
|
||||
|
||||
saveWeeklyReport(tempDir, report);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "weekly-reports")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWeeklyReport", () => {
|
||||
test("should return null for missing report", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report = loadWeeklyReport(tempDir, "2026-W99");
|
||||
|
||||
assert.equal(report, null);
|
||||
});
|
||||
|
||||
test("should load existing report", () => {
|
||||
tempDir = setupTempDir();
|
||||
const report = createTestWeeklyReport({ week: "2026-W03" });
|
||||
|
||||
saveWeeklyReport(tempDir, report);
|
||||
|
||||
const loaded = loadWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.week, "2026-W03");
|
||||
assert.equal(loaded.summary.totalPosts, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllWeeklyReports", () => {
|
||||
test("should load all reports sorted", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report1 = createTestWeeklyReport({ week: "2026-W03" });
|
||||
const report2 = createTestWeeklyReport({ week: "2026-W01" });
|
||||
const report3 = createTestWeeklyReport({ week: "2026-W05" });
|
||||
|
||||
saveWeeklyReport(tempDir, report1);
|
||||
saveWeeklyReport(tempDir, report2);
|
||||
saveWeeklyReport(tempDir, report3);
|
||||
|
||||
const reports = loadAllWeeklyReports(tempDir);
|
||||
|
||||
assert.equal(reports.length, 3);
|
||||
assert.equal(reports[0].week, "2026-W05"); // Newest first
|
||||
assert.equal(reports[1].week, "2026-W03");
|
||||
assert.equal(reports[2].week, "2026-W01");
|
||||
});
|
||||
|
||||
test("should return empty array if directory doesn't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const reports = loadAllWeeklyReports(tempDir);
|
||||
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
|
||||
test("should ignore non-JSON files", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
const reportsDir = join(tempDir, "weekly-reports");
|
||||
const report = createTestWeeklyReport({ week: "2026-W03" });
|
||||
saveWeeklyReport(tempDir, report);
|
||||
writeFileSync(join(reportsDir, "readme.txt"), "data");
|
||||
|
||||
const reports = loadAllWeeklyReports(tempDir);
|
||||
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0].week, "2026-W03");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,570 @@
|
|||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
getISOWeek,
|
||||
getCurrentISOWeek,
|
||||
getPostsForWeek,
|
||||
generateWeeklyReport,
|
||||
} from "../src/reports/weekly.js";
|
||||
import { saveBatch, saveWeeklyReport } from "../src/utils/storage.js";
|
||||
import type { PostAnalytics, AnalyticsBatch } from "../src/models/types.js";
|
||||
|
||||
// Helper function to create test post data
|
||||
function createTestPost(overrides?: Partial<PostAnalytics>): PostAnalytics {
|
||||
return {
|
||||
id: overrides?.id || "test-post-1",
|
||||
title: overrides?.title || "Test post content",
|
||||
publishedDate: overrides?.publishedDate || "2026-01-15",
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
...(overrides?.metrics || {}),
|
||||
},
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportSource: overrides?.exportSource || "test-export.csv",
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create test batch data
|
||||
function createTestBatch(overrides?: Partial<AnalyticsBatch>): AnalyticsBatch {
|
||||
const posts = overrides?.posts || [createTestPost()];
|
||||
return {
|
||||
batchId: overrides?.batchId || "12345678-1234-1234-1234-123456789abc",
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportFilename: overrides?.exportFilename || "test-export.csv",
|
||||
dateRange: overrides?.dateRange || { from: "2026-01-15", to: "2026-01-20" },
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("weekly", () => {
|
||||
let tempDir: string;
|
||||
|
||||
// Create temp directory before each test
|
||||
function setupTempDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "analytics-test-"));
|
||||
}
|
||||
|
||||
// Clean up temp directory after each test
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("getISOWeek", () => {
|
||||
test("should return correct ISO week for 2026-01-01", () => {
|
||||
// 2026-01-01 is Thursday, should be week 1 of 2026
|
||||
const result = getISOWeek(new Date("2026-01-01"));
|
||||
assert.equal(result, "2026-W01");
|
||||
});
|
||||
|
||||
test("should return correct ISO week for 2025-12-29", () => {
|
||||
// 2025-12-29 is Monday, first day of ISO week 1, 2026
|
||||
const result = getISOWeek(new Date("2025-12-29"));
|
||||
assert.equal(result, "2026-W01");
|
||||
});
|
||||
|
||||
test("should return correct ISO week for 2025-12-28", () => {
|
||||
// 2025-12-28 is Sunday, last day of 2025 week 52
|
||||
const result = getISOWeek(new Date("2025-12-28"));
|
||||
assert.equal(result, "2025-W52");
|
||||
});
|
||||
|
||||
test("should handle year boundaries - early January", () => {
|
||||
// 2025-01-01 is Wednesday, should be in 2025-W01
|
||||
const result = getISOWeek(new Date("2025-01-01"));
|
||||
assert.equal(result, "2025-W01");
|
||||
});
|
||||
|
||||
test("should handle year boundaries - late December", () => {
|
||||
// 2024-12-30 is Monday, should be in 2025-W01
|
||||
const result = getISOWeek(new Date("2024-12-30"));
|
||||
assert.equal(result, "2025-W01");
|
||||
});
|
||||
|
||||
test("should handle mid-year dates", () => {
|
||||
// 2026-06-15 is Monday
|
||||
const result = getISOWeek(new Date("2026-06-15"));
|
||||
assert.equal(result, "2026-W25");
|
||||
});
|
||||
|
||||
test("should handle leap year", () => {
|
||||
// 2024 is a leap year, Feb 29 should be in week 9
|
||||
const result = getISOWeek(new Date("2024-02-29"));
|
||||
assert.equal(result, "2024-W09");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentISOWeek", () => {
|
||||
test("should return a valid ISO week format", () => {
|
||||
const result = getCurrentISOWeek();
|
||||
|
||||
// Should match YYYY-WXX format
|
||||
assert.match(result, /^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
test("should return a week in reasonable range", () => {
|
||||
const result = getCurrentISOWeek();
|
||||
const year = parseInt(result.split("-")[0]);
|
||||
const week = parseInt(result.split("-W")[1]);
|
||||
|
||||
// Year should be current or adjacent
|
||||
const currentYear = new Date().getFullYear();
|
||||
assert.ok(year >= currentYear - 1 && year <= currentYear + 1);
|
||||
|
||||
// Week should be 1-53
|
||||
assert.ok(week >= 1 && week <= 53);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPostsForWeek", () => {
|
||||
test("should filter posts to correct week", () => {
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post4",
|
||||
publishedDate: "2026-01-19", // 2026-W04
|
||||
}),
|
||||
];
|
||||
|
||||
const week3Posts = getPostsForWeek(posts, "2026-W03");
|
||||
|
||||
assert.equal(week3Posts.length, 2);
|
||||
assert.ok(week3Posts.some(p => p.id === "post2"));
|
||||
assert.ok(week3Posts.some(p => p.id === "post3"));
|
||||
});
|
||||
|
||||
test("should return empty for weeks with no posts", () => {
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getPostsForWeek(posts, "2026-W03");
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("should handle empty posts array", () => {
|
||||
const result = getPostsForWeek([], "2026-W03");
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("should handle posts across year boundary", () => {
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2025-12-29", // 2026-W01 (Monday)
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-01", // 2026-W01 (Thursday)
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2025-12-28", // 2025-W52 (Sunday)
|
||||
}),
|
||||
];
|
||||
|
||||
const week1Posts = getPostsForWeek(posts, "2026-W01");
|
||||
|
||||
assert.equal(week1Posts.length, 2);
|
||||
assert.ok(week1Posts.some(p => p.id === "post1"));
|
||||
assert.ok(week1Posts.some(p => p.id === "post2"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateWeeklyReport", () => {
|
||||
test("should handle no posts", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(report.week, "2026-W03");
|
||||
assert.equal(report.summary.totalPosts, 0);
|
||||
assert.equal(report.summary.totalImpressions, 0);
|
||||
assert.equal(report.summary.avgEngagementRate, 0);
|
||||
assert.deepEqual(report.topPerformers, []);
|
||||
assert.deepEqual(report.underperformers, []);
|
||||
});
|
||||
|
||||
test("should calculate correct summary metrics", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 2000,
|
||||
reactions: 100,
|
||||
comments: 20,
|
||||
shares: 10,
|
||||
clicks: 40,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2026-01-14", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1500,
|
||||
reactions: 75,
|
||||
comments: 15,
|
||||
shares: 7,
|
||||
clicks: 30,
|
||||
engagementRate: 8.47,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-14" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(report.week, "2026-W03");
|
||||
assert.equal(report.summary.totalPosts, 3);
|
||||
assert.equal(report.summary.totalImpressions, 4500);
|
||||
assert.equal(report.summary.totalReactions, 225);
|
||||
assert.equal(report.summary.totalComments, 45);
|
||||
assert.equal(report.summary.totalShares, 22);
|
||||
assert.equal(report.summary.totalClicks, 90);
|
||||
assert.equal(report.summary.avgImpressionsPerPost, 1500);
|
||||
|
||||
// Average engagement rate: (8.5 + 8.5 + 8.47) / 3 ≈ 8.49
|
||||
assert.ok(Math.abs(report.summary.avgEngagementRate - 8.49) < 0.01);
|
||||
});
|
||||
|
||||
test("should identify top performers and underperformers", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "high1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 10.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "high2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 9.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "medium",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 5.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "low1",
|
||||
publishedDate: "2026-01-14", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 3.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "low2",
|
||||
publishedDate: "2026-01-14", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 2.0,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-14" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Top 3 performers (highest engagement)
|
||||
assert.equal(report.topPerformers.length, 3);
|
||||
assert.equal(report.topPerformers[0].id, "high1"); // 10.0
|
||||
assert.equal(report.topPerformers[1].id, "high2"); // 9.0
|
||||
assert.equal(report.topPerformers[2].id, "medium"); // 5.0
|
||||
|
||||
// Bottom 3 underperformers (lowest engagement)
|
||||
assert.equal(report.underperformers.length, 3);
|
||||
assert.equal(report.underperformers[0].id, "low2"); // 2.0
|
||||
assert.equal(report.underperformers[1].id, "low1"); // 3.0
|
||||
assert.equal(report.underperformers[2].id, "medium"); // 5.0
|
||||
});
|
||||
|
||||
test("should handle fewer than 3 posts", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 10.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 5.0,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-13" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Should have 2 top performers
|
||||
assert.equal(report.topPerformers.length, 2);
|
||||
// Should have 2 underperformers (same posts, reversed)
|
||||
assert.equal(report.underperformers.length, 2);
|
||||
});
|
||||
|
||||
test("should calculate trends when previous week data exists", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
// Create previous week data
|
||||
const prevWeekPosts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "prev1",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "prev2",
|
||||
publishedDate: "2026-01-06", // 2026-W02
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const prevBatch = createTestBatch({
|
||||
dateRange: { from: "2026-01-05", to: "2026-01-06" },
|
||||
posts: prevWeekPosts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, prevBatch);
|
||||
|
||||
// Generate previous week report
|
||||
generateWeeklyReport(tempDir, "2026-W02");
|
||||
|
||||
// Create current week data with higher metrics
|
||||
const currentWeekPosts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "curr1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1500,
|
||||
reactions: 75,
|
||||
comments: 15,
|
||||
shares: 7,
|
||||
clicks: 30,
|
||||
engagementRate: 8.47,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "curr2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1500,
|
||||
reactions: 75,
|
||||
comments: 15,
|
||||
shares: 7,
|
||||
clicks: 30,
|
||||
engagementRate: 8.47,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const currentBatch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-13" },
|
||||
posts: currentWeekPosts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, currentBatch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Current impressions: 3000, Previous: 2000 → 50% increase
|
||||
assert.equal(report.trends.comparedTo, "2026-W02");
|
||||
assert.equal(report.trends.impressionsTrend, "up");
|
||||
assert.equal(report.trends.percentChange.impressions, 50);
|
||||
|
||||
// Engagement rate essentially the same
|
||||
assert.equal(report.trends.engagementTrend, "stable");
|
||||
});
|
||||
|
||||
test("should default to stable trends when no previous week data", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-12" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(report.trends.impressionsTrend, "stable");
|
||||
assert.equal(report.trends.engagementTrend, "stable");
|
||||
assert.equal(report.trends.percentChange.impressions, 0);
|
||||
assert.equal(report.trends.percentChange.engagement, 0);
|
||||
});
|
||||
|
||||
test("should filter posts correctly for target week", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "w02-post",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
}),
|
||||
createTestPost({
|
||||
id: "w03-post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "w03-post2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "w04-post",
|
||||
publishedDate: "2026-01-19", // 2026-W04
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-05", to: "2026-01-19" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Should only include W03 posts
|
||||
assert.equal(report.summary.totalPosts, 2);
|
||||
assert.equal(report.topPerformers.length, 2);
|
||||
assert.ok(report.topPerformers.some(p => p.id === "w03-post1"));
|
||||
assert.ok(report.topPerformers.some(p => p.id === "w03-post2"));
|
||||
});
|
||||
|
||||
test("should use current week if week parameter not provided", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report = generateWeeklyReport(tempDir);
|
||||
|
||||
// Should match current ISO week format
|
||||
assert.match(report.week, /^\d{4}-W\d{2}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue