mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
895 lines
27 KiB
TypeScript
895 lines
27 KiB
TypeScript
/* @vitest-environment node */
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { ACTIVITY_TREND_DAYS, getActivityTrendRange } from "./lib/downloadTrend";
|
|
import {
|
|
computeRecommendationScore,
|
|
RECOMMENDATION_SCORE_VERSION,
|
|
} from "./lib/recommendationScore";
|
|
import {
|
|
getActivityTrendForName,
|
|
pruneProcessedPackageStatEventBatchInternal,
|
|
processPackageStatEventsInternal,
|
|
recordPackageDownloadInternal,
|
|
recordPackageInstallInternal,
|
|
} from "./packages";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
|
|
|
type WrappedHandler<TArgs, TResult> = {
|
|
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
|
};
|
|
|
|
const recordDownloadHandler = (
|
|
recordPackageDownloadInternal as unknown as WrappedHandler<{ packageId: string }, void>
|
|
)._handler;
|
|
|
|
const recordInstallHandler = (
|
|
recordPackageInstallInternal as unknown as WrappedHandler<
|
|
{
|
|
packageId: string;
|
|
identityKind?: "user" | "ip";
|
|
identityHash?: string;
|
|
dayStart?: number;
|
|
occurredAt?: number;
|
|
},
|
|
void
|
|
>
|
|
)._handler;
|
|
|
|
const processStatsHandler = (
|
|
processPackageStatEventsInternal as unknown as WrappedHandler<
|
|
{ batchSize?: number },
|
|
{ processed: number; packagesUpdated: number }
|
|
>
|
|
)._handler;
|
|
|
|
const pruneProcessedPackageStatEventBatchHandler = (
|
|
pruneProcessedPackageStatEventBatchInternal as unknown as WrappedHandler<
|
|
{
|
|
cutoffProcessedAt: number;
|
|
dryRun: boolean;
|
|
batchSize?: number;
|
|
confirmationToken?: string;
|
|
},
|
|
{ matched: number; deleted: number; hasMore: boolean }
|
|
>
|
|
)._handler;
|
|
|
|
const getActivityTrendHandler = (
|
|
getActivityTrendForName as unknown as WrappedHandler<
|
|
{ name: string; endDay: number },
|
|
{
|
|
downloads: {
|
|
range: "daily";
|
|
days: number;
|
|
total: number;
|
|
points: Array<{ day: number; value: number }>;
|
|
};
|
|
} | null
|
|
>
|
|
)._handler;
|
|
|
|
describe("package stat events", () => {
|
|
afterEach(() => {
|
|
vi.mocked(getAuthUserId).mockReset();
|
|
});
|
|
|
|
it("records downloads as append-only events", async () => {
|
|
const insert = vi.fn();
|
|
|
|
await recordDownloadHandler(
|
|
{
|
|
db: {
|
|
query: vi.fn(),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert,
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
packageId: "packages:one",
|
|
},
|
|
);
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageStatEvents",
|
|
expect.objectContaining({
|
|
packageId: "packages:one",
|
|
kind: "download",
|
|
processedAt: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("records installs as append-only events", async () => {
|
|
const insert = vi.fn();
|
|
|
|
await recordInstallHandler(
|
|
{
|
|
db: {
|
|
query: vi.fn(),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert,
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
packageId: "packages:one",
|
|
},
|
|
);
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageStatEvents",
|
|
expect.objectContaining({
|
|
packageId: "packages:one",
|
|
kind: "install",
|
|
processedAt: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("dedupes identity-backed installs before appending stat events", async () => {
|
|
const insert = vi.fn();
|
|
const unique = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
|
_id: "packageInstallMetricDedupes:existing",
|
|
});
|
|
const queryBuilder = {
|
|
eq: vi.fn(() => queryBuilder),
|
|
};
|
|
const withIndex = vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
buildQuery(queryBuilder);
|
|
return { unique };
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn(() => ({ withIndex })),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert,
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
const args = {
|
|
packageId: "packages:one",
|
|
identityKind: "ip" as const,
|
|
identityHash: "hash-ip",
|
|
dayStart: 86_400_000,
|
|
occurredAt: 86_500_000,
|
|
};
|
|
|
|
await recordInstallHandler(ctx, args);
|
|
await recordInstallHandler(ctx, args);
|
|
|
|
expect(withIndex).toHaveBeenCalledWith("by_target_metric_identity_day", expect.any(Function));
|
|
expect(queryBuilder.eq).toHaveBeenCalledWith("targetKind", "package");
|
|
expect(queryBuilder.eq).toHaveBeenCalledWith("targetId", "packages:one");
|
|
expect(queryBuilder.eq).toHaveBeenCalledWith("metricKind", "install");
|
|
expect(queryBuilder.eq).toHaveBeenCalledWith("identityKind", "ip");
|
|
expect(queryBuilder.eq).toHaveBeenCalledWith("identityHash", "hash-ip");
|
|
expect(queryBuilder.eq).toHaveBeenCalledWith("dayStart", 86_400_000);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageInstallMetricDedupes",
|
|
expect.objectContaining({
|
|
targetKind: "package",
|
|
targetId: "packages:one",
|
|
metricKind: "install",
|
|
identityKind: "ip",
|
|
identityHash: "hash-ip",
|
|
dayStart: 86_400_000,
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageStatEvents",
|
|
expect.objectContaining({
|
|
packageId: "packages:one",
|
|
kind: "install",
|
|
occurredAt: 86_500_000,
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("aggregates queued downloads and installs before patching package stats", async () => {
|
|
const dayStart = 86_400_000;
|
|
const events = [
|
|
{
|
|
_id: "packageStatEvents:1",
|
|
packageId: "packages:one",
|
|
kind: "download",
|
|
occurredAt: dayStart,
|
|
},
|
|
{
|
|
_id: "packageStatEvents:2",
|
|
packageId: "packages:one",
|
|
kind: "install",
|
|
occurredAt: dayStart,
|
|
},
|
|
{
|
|
_id: "packageStatEvents:3",
|
|
packageId: "packages:two",
|
|
kind: "download",
|
|
occurredAt: dayStart,
|
|
},
|
|
{
|
|
_id: "packageStatEvents:4",
|
|
packageId: "packages:one",
|
|
kind: "download",
|
|
occurredAt: dayStart * 2,
|
|
},
|
|
{
|
|
_id: "packageStatEvents:5",
|
|
packageId: "packages:one",
|
|
kind: "install_clear",
|
|
occurredAt: dayStart,
|
|
},
|
|
];
|
|
const insert = vi.fn();
|
|
const patch = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packageStatEvents") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
take: vi.fn(async () => events),
|
|
})),
|
|
};
|
|
}
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn(async () => null),
|
|
})),
|
|
};
|
|
}),
|
|
get: vi.fn(async (id: string) => ({
|
|
_id: id,
|
|
stats: { downloads: 10, installs: 1, stars: 2, versions: 3 },
|
|
})),
|
|
normalizeId: vi.fn(),
|
|
insert,
|
|
patch,
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
scheduler: {
|
|
runAfter: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const result = await processStatsHandler(ctx, { batchSize: 10 });
|
|
|
|
expect(result).toEqual({ processed: 5, packagesUpdated: 2 });
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageDailyStats",
|
|
expect.objectContaining({
|
|
packageId: "packages:one",
|
|
day: 1,
|
|
downloads: 1,
|
|
installs: 0,
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageDailyStats",
|
|
expect.objectContaining({
|
|
packageId: "packages:two",
|
|
day: 1,
|
|
downloads: 1,
|
|
installs: 0,
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"packageDailyStats",
|
|
expect.objectContaining({
|
|
packageId: "packages:one",
|
|
day: 2,
|
|
downloads: 1,
|
|
installs: 0,
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"packages:one",
|
|
expect.objectContaining({
|
|
stats: expect.objectContaining({ downloads: 12 }),
|
|
recommendedScore: computeRecommendationScore({
|
|
downloads: 12,
|
|
installs: 1,
|
|
stars: 2,
|
|
}),
|
|
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"packages:one",
|
|
expect.objectContaining({
|
|
stats: expect.objectContaining({ installs: 1 }),
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"packages:two",
|
|
expect.objectContaining({
|
|
stats: expect.objectContaining({ downloads: 11 }),
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"packageStatEvents:1",
|
|
expect.objectContaining({ processedAt: expect.any(Number) }),
|
|
);
|
|
});
|
|
|
|
it("updates an existing package daily stat row for another batch on the same day", async () => {
|
|
const dayStart = 86_400_000;
|
|
const events = [
|
|
{
|
|
_id: "packageStatEvents:1",
|
|
packageId: "packages:one",
|
|
kind: "download",
|
|
occurredAt: dayStart,
|
|
},
|
|
{
|
|
_id: "packageStatEvents:2",
|
|
packageId: "packages:one",
|
|
kind: "install",
|
|
occurredAt: dayStart,
|
|
},
|
|
];
|
|
const insert = vi.fn();
|
|
const patch = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packageStatEvents") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
take: vi.fn(async () => events),
|
|
})),
|
|
};
|
|
}
|
|
if (tableName === "packageDailyStats") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn(async () => ({
|
|
_id: "packageDailyStats:existing",
|
|
downloads: 4,
|
|
installs: 2,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected query table ${tableName}`);
|
|
}),
|
|
get: vi.fn(async (id: string) => ({
|
|
_id: id,
|
|
stats: { downloads: 10, installs: 1, stars: 2, versions: 3 },
|
|
})),
|
|
normalizeId: vi.fn(),
|
|
insert,
|
|
patch,
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
scheduler: {
|
|
runAfter: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const result = await processStatsHandler(ctx, { batchSize: 10 });
|
|
|
|
expect(result).toEqual({ processed: 2, packagesUpdated: 1 });
|
|
expect(insert).not.toHaveBeenCalledWith("packageDailyStats", expect.anything());
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"packageDailyStats:existing",
|
|
expect.objectContaining({
|
|
downloads: 5,
|
|
installs: 3,
|
|
updatedAt: expect.any(Number),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("caps package stat batches after adding daily stat writes", async () => {
|
|
const events = Array.from({ length: 100 }, (_, index) => ({
|
|
_id: `packageStatEvents:${index}`,
|
|
packageId: `packages:${index}`,
|
|
kind: "download",
|
|
occurredAt: 86_400_000,
|
|
}));
|
|
const take = vi.fn(async () => events);
|
|
const patch = vi.fn();
|
|
const runAfter = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packageStatEvents") {
|
|
return {
|
|
withIndex: vi.fn(() => ({ take })),
|
|
};
|
|
}
|
|
if (tableName === "packageDailyStats") {
|
|
return {
|
|
withIndex: vi.fn(() => ({ unique: vi.fn(async () => null) })),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected query table ${tableName}`);
|
|
}),
|
|
get: vi.fn(async (id: string) => ({
|
|
_id: id,
|
|
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
|
|
})),
|
|
normalizeId: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch,
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
scheduler: {
|
|
runAfter,
|
|
},
|
|
};
|
|
|
|
const result = await processStatsHandler(ctx, { batchSize: 500 });
|
|
|
|
expect(take).toHaveBeenCalledWith(100);
|
|
expect(result).toEqual({ processed: 100, packagesUpdated: 100 });
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), { batchSize: 100 });
|
|
expect(patch).toHaveBeenCalledTimes(200);
|
|
});
|
|
|
|
it("prunes processed package stat events older than the cutoff", async () => {
|
|
const staleEvents = [
|
|
{ _id: "packageStatEvents:old-1", processedAt: 1_000 },
|
|
{ _id: "packageStatEvents:old-2", processedAt: 2_000 },
|
|
];
|
|
const take = vi.fn(async () => staleEvents);
|
|
const deleteDoc = vi.fn();
|
|
const gt = vi.fn(() => ({ lt: vi.fn(() => "processed-range") }));
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
expect(tableName).toBe("packageStatEvents");
|
|
return {
|
|
withIndex: vi.fn((indexName: string, builder: (q: { gt: typeof gt }) => unknown) => {
|
|
expect(indexName).toBe("by_unprocessed");
|
|
expect(builder({ gt })).toBe("processed-range");
|
|
return { take };
|
|
}),
|
|
};
|
|
}),
|
|
delete: deleteDoc,
|
|
get: vi.fn(),
|
|
insert: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await pruneProcessedPackageStatEventBatchHandler(ctx, {
|
|
cutoffProcessedAt: 3_000,
|
|
dryRun: false,
|
|
batchSize: 2,
|
|
confirmationToken: "PRUNE_PROCESSED_PACKAGE_STAT_EVENTS",
|
|
});
|
|
|
|
expect(gt).toHaveBeenCalledWith("processedAt", 0);
|
|
expect(take).toHaveBeenCalledWith(2);
|
|
expect(deleteDoc).toHaveBeenCalledWith("packageStatEvents:old-1");
|
|
expect(deleteDoc).toHaveBeenCalledWith("packageStatEvents:old-2");
|
|
expect(result).toEqual({
|
|
cutoffProcessedAt: 3_000,
|
|
dryRun: false,
|
|
matched: 2,
|
|
deleted: 2,
|
|
hasMore: true,
|
|
});
|
|
});
|
|
|
|
it("builds package activity from 30 daily package stat rows", async () => {
|
|
const now = Date.UTC(2026, 6, 18) + 1;
|
|
const { startDay, endDay } = getActivityTrendRange(now);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
|
|
try {
|
|
const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) };
|
|
const dailyIndexBuilder = {
|
|
eq: vi.fn(() => dailyIndexBuilder),
|
|
gte: vi.fn(() => dailyIndexBuilder),
|
|
lte: vi.fn(() => dailyIndexBuilder),
|
|
};
|
|
const packageWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => {
|
|
buildQuery(packageIndexBuilder);
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "packages:one",
|
|
_creationTime: Date.UTC(2026, 5, 1),
|
|
createdAt: Date.UTC(2026, 5, 1),
|
|
normalizedName: "demo-plugin",
|
|
channel: "public",
|
|
scanStatus: "clean",
|
|
stats: { downloads: 143, installs: 0, stars: 0, versions: 1 },
|
|
})),
|
|
};
|
|
},
|
|
);
|
|
const takeDailyStats = vi.fn(async () => [
|
|
{ day: endDay - 1, downloads: 2, installs: 1 },
|
|
{ day: endDay, downloads: 1, installs: 3 },
|
|
]);
|
|
const dailyWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => {
|
|
buildQuery(dailyIndexBuilder);
|
|
return { take: takeDailyStats };
|
|
},
|
|
);
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packages") return { withIndex: packageWithIndex };
|
|
if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex };
|
|
throw new Error(`Unexpected table ${tableName}`);
|
|
}),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
|
|
const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay });
|
|
|
|
expect(packageWithIndex).toHaveBeenCalledWith("by_name", expect.any(Function));
|
|
expect(packageIndexBuilder.eq).toHaveBeenCalledWith("normalizedName", "demo-plugin");
|
|
expect(dailyWithIndex).toHaveBeenCalledWith("by_package_day", expect.any(Function));
|
|
expect(dailyIndexBuilder.eq).toHaveBeenCalledWith("packageId", "packages:one");
|
|
expect(dailyIndexBuilder.gte).toHaveBeenCalledWith("day", startDay);
|
|
expect(dailyIndexBuilder.lte).toHaveBeenCalledWith("day", endDay);
|
|
expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.range).toBe("daily");
|
|
expect(trend?.downloads.days).toBe(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.total).toBe(3);
|
|
expect(trend?.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.points[0]).toEqual({ day: startDay, value: 0 });
|
|
expect(trend?.downloads.points.at(-1)).toEqual({ day: endDay, value: 1 });
|
|
expect(trend?.downloads.points.find((point) => point.day === endDay - 1)).toEqual({
|
|
day: endDay - 1,
|
|
value: 2,
|
|
});
|
|
expect(trend && "installs" in trend).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("returns a zero-filled trend for packages without daily rows", async () => {
|
|
const now = Date.UTC(2026, 5, 20) + 1;
|
|
const { startDay, endDay } = getActivityTrendRange(now);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
|
|
try {
|
|
const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) };
|
|
const dailyIndexBuilder = {
|
|
eq: vi.fn(() => dailyIndexBuilder),
|
|
gte: vi.fn(() => dailyIndexBuilder),
|
|
lte: vi.fn(() => dailyIndexBuilder),
|
|
};
|
|
const packageWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => {
|
|
buildQuery(packageIndexBuilder);
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "packages:one",
|
|
_creationTime: Date.UTC(2026, 5, 1),
|
|
createdAt: Date.UTC(2026, 5, 1),
|
|
normalizedName: "demo-plugin",
|
|
channel: "public",
|
|
scanStatus: "clean",
|
|
stats: { downloads: 143, installs: 12, stars: 0, versions: 1 },
|
|
})),
|
|
};
|
|
},
|
|
);
|
|
const takeDailyStats = vi.fn(async () => []);
|
|
const dailyWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => {
|
|
buildQuery(dailyIndexBuilder);
|
|
return { take: takeDailyStats };
|
|
},
|
|
);
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packages") return { withIndex: packageWithIndex };
|
|
if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex };
|
|
throw new Error(`Unexpected table ${tableName}`);
|
|
}),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
|
|
const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay });
|
|
|
|
expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.total).toBe(0);
|
|
expect(trend?.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.points[0]).toEqual({ day: startDay, value: 0 });
|
|
expect(trend?.downloads.points.at(-1)).toEqual({ day: endDay, value: 0 });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("builds package activity from available daily rows", async () => {
|
|
const now = Date.UTC(2026, 5, 20) + 1;
|
|
const { startDay, endDay } = getActivityTrendRange(now);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
|
|
try {
|
|
const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) };
|
|
const dailyIndexBuilder = {
|
|
eq: vi.fn(() => dailyIndexBuilder),
|
|
gte: vi.fn(() => dailyIndexBuilder),
|
|
lte: vi.fn(() => dailyIndexBuilder),
|
|
};
|
|
const packageWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => {
|
|
buildQuery(packageIndexBuilder);
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "packages:one",
|
|
_creationTime: Date.UTC(2026, 4, 1),
|
|
createdAt: Date.UTC(2026, 4, 1),
|
|
normalizedName: "demo-plugin",
|
|
channel: "public",
|
|
scanStatus: "clean",
|
|
stats: { downloads: 143, installs: 12, stars: 0, versions: 1 },
|
|
})),
|
|
};
|
|
},
|
|
);
|
|
const takeDailyStats = vi.fn(async () => [
|
|
{ day: endDay - 2, downloads: 41, installs: 3 },
|
|
{ day: endDay - 1, downloads: 52, installs: 4 },
|
|
{ day: endDay, downloads: 50, installs: 5 },
|
|
]);
|
|
const dailyWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => {
|
|
buildQuery(dailyIndexBuilder);
|
|
return { take: takeDailyStats };
|
|
},
|
|
);
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packages") return { withIndex: packageWithIndex };
|
|
if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex };
|
|
throw new Error(`Unexpected table ${tableName}`);
|
|
}),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
|
|
const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay });
|
|
|
|
expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.total).toBe(143);
|
|
expect(trend?.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS);
|
|
expect(trend?.downloads.points[0]).toEqual({ day: startDay, value: 0 });
|
|
expect(trend?.downloads.points.at(-1)).toEqual({ day: endDay, value: 50 });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("clamps future package trend windows to the current day", async () => {
|
|
const now = Date.UTC(2026, 6, 18) + 1;
|
|
const { endDay: serverEndDay } = getActivityTrendRange(now);
|
|
const { endDay: futureEndDay } = getActivityTrendRange(Date.UTC(2026, 8, 20) + 1);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
|
|
try {
|
|
const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) };
|
|
const dailyIndexBuilder = {
|
|
eq: vi.fn(() => dailyIndexBuilder),
|
|
gte: vi.fn(() => dailyIndexBuilder),
|
|
lte: vi.fn(() => dailyIndexBuilder),
|
|
};
|
|
const packageWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => {
|
|
buildQuery(packageIndexBuilder);
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "packages:one",
|
|
_creationTime: Date.UTC(2026, 5, 1),
|
|
createdAt: Date.UTC(2026, 5, 1),
|
|
normalizedName: "demo-plugin",
|
|
channel: "public",
|
|
scanStatus: "clean",
|
|
stats: { downloads: 143, installs: 12, stars: 0, versions: 1 },
|
|
})),
|
|
};
|
|
},
|
|
);
|
|
const dailyWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => {
|
|
buildQuery(dailyIndexBuilder);
|
|
return { take: vi.fn(async () => [{ day: serverEndDay, downloads: 1, installs: 1 }]) };
|
|
},
|
|
);
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packages") return { withIndex: packageWithIndex };
|
|
if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex };
|
|
throw new Error(`Unexpected table ${tableName}`);
|
|
}),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
|
|
const trend = await getActivityTrendHandler(ctx, {
|
|
name: "demo-plugin",
|
|
endDay: futureEndDay,
|
|
});
|
|
|
|
expect(dailyIndexBuilder.lte).toHaveBeenCalledWith("day", serverEndDay);
|
|
expect(trend?.downloads.points.at(-1)).toEqual({ day: serverEndDay, value: 1 });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("returns private package trends when the owner is signed in", async () => {
|
|
const now = Date.UTC(2026, 6, 18) + 1;
|
|
const { endDay } = getActivityTrendRange(now);
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
|
|
try {
|
|
const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) };
|
|
const dailyIndexBuilder = {
|
|
eq: vi.fn(() => dailyIndexBuilder),
|
|
gte: vi.fn(() => dailyIndexBuilder),
|
|
lte: vi.fn(() => dailyIndexBuilder),
|
|
};
|
|
const packageWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => {
|
|
buildQuery(packageIndexBuilder);
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "packages:one",
|
|
_creationTime: Date.UTC(2026, 5, 1),
|
|
createdAt: Date.UTC(2026, 5, 1),
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: undefined,
|
|
normalizedName: "demo-plugin",
|
|
channel: "private",
|
|
scanStatus: "clean",
|
|
stats: { downloads: 143, installs: 12, stars: 0, versions: 1 },
|
|
})),
|
|
};
|
|
},
|
|
);
|
|
const takeDailyStats = vi.fn(async () => [{ day: endDay, downloads: 2, installs: 1 }]);
|
|
const dailyWithIndex = vi.fn(
|
|
(_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => {
|
|
buildQuery(dailyIndexBuilder);
|
|
return { take: takeDailyStats };
|
|
},
|
|
);
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:owner") {
|
|
return { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined };
|
|
}
|
|
return null;
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((tableName: string) => {
|
|
if (tableName === "packages") return { withIndex: packageWithIndex };
|
|
if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex };
|
|
throw new Error(`Unexpected table ${tableName}`);
|
|
}),
|
|
get,
|
|
normalizeId: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
system: {
|
|
get: vi.fn(),
|
|
query: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
|
|
const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay });
|
|
|
|
expect(getAuthUserId).toHaveBeenCalled();
|
|
expect(get).toHaveBeenCalledWith("users:owner");
|
|
expect(trend?.downloads.total).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|