Browse Source

implement traffic stats

done by Codex GPT 5.5 Extra High
pull/2677/head
Bernd Storath 3 weeks ago
parent
commit
b269dc318f
  1. 24
      src/server/api/client/[clientId]/enable.post.ts
  2. 35
      src/server/api/client/[clientId]/traffic.get.ts
  3. 19
      src/server/database/migrations/0007_curved_wraith.sql
  4. 1154
      src/server/database/migrations/meta/0007_snapshot.json
  5. 7
      src/server/database/migrations/meta/_journal.json
  6. 3
      src/server/database/repositories/client/schema.ts
  7. 10
      src/server/database/repositories/client/types.ts
  8. 45
      src/server/database/repositories/traffic/schema.ts
  9. 240
      src/server/database/repositories/traffic/service.ts
  10. 36
      src/server/database/repositories/traffic/types.ts
  11. 1
      src/server/database/schema.ts
  12. 3
      src/server/database/sqlite.ts
  13. 53
      src/server/utils/WireGuard.ts
  14. 105
      src/server/utils/traffic.ts
  15. 138
      src/test/unit/traffic-service.spec.ts
  16. 83
      src/test/unit/traffic.spec.ts

24
src/server/api/client/[clientId]/enable.post.ts

@ -18,11 +18,14 @@ export default definePermissionEventHandler(
const client = await Database.clients.get(clientId);
checkPermissions(client);
if (
client &&
client.expiresAt !== null &&
new Date() > new Date(client.expiresAt)
) {
if (!client) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
});
}
if (client.expiresAt !== null && new Date() > new Date(client.expiresAt)) {
throw createError({
statusCode: 422,
statusMessage:
@ -31,6 +34,17 @@ export default definePermissionEventHandler(
});
}
await WireGuard.updateTrafficStats();
const exceededQuotas = await Database.traffic.getExceededQuotas(clientId);
if (exceededQuotas.length > 0) {
const periods = exceededQuotas.join(', ');
throw createError({
statusCode: 422,
statusMessage: `Client traffic quota exceeded: ${periods}`,
message: `Client traffic quota exceeded: ${periods}`,
});
}
await Database.clients.toggle(clientId, true);
await WireGuard.saveConfig();
return { success: true };

35
src/server/api/client/[clientId]/traffic.get.ts

@ -0,0 +1,35 @@
import { createError, getValidatedQuery, getValidatedRouterParams } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { ClientGetSchema } from '#db/repositories/client/types';
import { TrafficQuerySchema } from '#db/repositories/traffic/types';
import type { TrafficReport } from '#db/repositories/traffic/types';
export default definePermissionEventHandler(
'clients',
'view',
async ({ event, checkPermissions }): Promise<TrafficReport> => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const query = await getValidatedQuery(
event,
validateZod(TrafficQuerySchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
if (!client) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
});
}
return Database.traffic.getReport(client, query);
}
);

19
src/server/database/migrations/0007_curved_wraith.sql

@ -0,0 +1,19 @@
CREATE TABLE `client_traffic_state_table` (
`client_id` integer PRIMARY KEY NOT NULL,
`transfer_rx` integer DEFAULT 0 NOT NULL,
`transfer_tx` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `client_traffic_usage_table` (
`client_id` integer NOT NULL,
`date` text NOT NULL,
`received_bytes` integer DEFAULT 0 NOT NULL,
`sent_bytes` integer DEFAULT 0 NOT NULL,
PRIMARY KEY(`client_id`, `date`),
FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `clients_table` ADD `daily_quota` integer;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `weekly_quota` integer;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `monthly_quota` integer;

1154
src/server/database/migrations/meta/0007_snapshot.json

File diff suppressed because it is too large

7
src/server/database/migrations/meta/_journal.json

@ -50,6 +50,13 @@
"when": 1782122902196,
"tag": "0006_clear_leech",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1782131238811,
"tag": "0007_curved_wraith",
"breakpoints": true
}
]
}

3
src/server/database/repositories/client/schema.ts

@ -43,6 +43,9 @@ export const client = sqliteTable(
string[] | null
>(),
persistentKeepalive: int('persistent_keepalive').notNull(),
dailyQuota: int('daily_quota'),
weeklyQuota: int('weekly_quota'),
monthlyQuota: int('monthly_quota'),
mtu: int().notNull(),
jC: int('j_c'),
jMin: int('j_min'),

10
src/server/database/repositories/client/types.ts

@ -68,6 +68,13 @@ const serverAllowedIps = z.array(AddressSchema, {
message: t('zod.client.serverAllowedIps'),
});
const quota = z
.number()
.int()
.positive()
.max(Number.MAX_SAFE_INTEGER)
.nullable();
export const ClientCreateSchema = z.object({
name: name,
expiresAt: expiresAt,
@ -110,6 +117,9 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
i4: ISchema,
i5: ISchema,
persistentKeepalive: PersistentKeepaliveSchema,
dailyQuota: quota,
weeklyQuota: quota,
monthlyQuota: quota,
serverEndpoint: AddressSchema.nullable(),
dns: DnsSchema.nullable(),
})

45
src/server/database/repositories/traffic/schema.ts

@ -0,0 +1,45 @@
import { relations } from 'drizzle-orm';
import { int, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { client } from '../client/schema';
export const trafficState = sqliteTable('client_traffic_state_table', {
clientId: int('client_id')
.primaryKey()
.references(() => client.id, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),
transferRx: int('transfer_rx').notNull().default(0),
transferTx: int('transfer_tx').notNull().default(0),
});
export const trafficUsage = sqliteTable(
'client_traffic_usage_table',
{
clientId: int('client_id')
.notNull()
.references(() => client.id, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),
date: text().notNull(),
receivedBytes: int('received_bytes').notNull().default(0),
sentBytes: int('sent_bytes').notNull().default(0),
},
(table) => [primaryKey({ columns: [table.clientId, table.date] })]
);
export const trafficStateRelations = relations(trafficState, ({ one }) => ({
client: one(client, {
fields: [trafficState.clientId],
references: [client.id],
}),
}));
export const trafficUsageRelations = relations(trafficUsage, ({ one }) => ({
client: one(client, {
fields: [trafficUsage.clientId],
references: [client.id],
}),
}));

240
src/server/database/repositories/traffic/service.ts

@ -0,0 +1,240 @@
import { and, asc, eq, gte, inArray, lt, sql } from 'drizzle-orm';
import { client } from '../client/schema';
import type { ClientType } from '../client/types';
import { trafficState, trafficUsage } from './schema';
import type { TrafficPeer, TrafficQueryType, TrafficReport } from './types';
import type { DBType } from '#db/sqlite';
import type { ID } from '#server/utils/types';
import {
calculateTrafficDelta,
formatUtcDate,
getExceededTrafficQuotas,
getTrafficPeriodRange,
parseUtcDate,
sumTrafficDays,
type TrafficDay,
type TrafficPeriod,
} from '#server/utils/traffic';
type QuotaClient = Pick<
ClientType,
'id' | 'enabled' | 'dailyQuota' | 'weeklyQuota' | 'monthlyQuota'
>;
const quotaField = {
daily: 'dailyQuota',
weekly: 'weeklyQuota',
monthly: 'monthlyQuota',
} as const satisfies Record<TrafficPeriod, keyof QuotaClient>;
function quotaRanges(date: Date) {
return {
daily: getTrafficPeriodRange('daily', date),
weekly: getTrafficPeriodRange('weekly', date),
monthly: getTrafficPeriodRange('monthly', date),
};
}
async function getRelevantDays(
db: Pick<DBType, 'select'>,
clientId: ID,
date: Date
): Promise<TrafficDay[]> {
const ranges = Object.values(quotaRanges(date));
const start = ranges.map((range) => range.start).sort()[0]!;
const endExclusive = ranges
.map((range) => range.endExclusive)
.sort()
.at(-1)!;
return db
.select({
date: trafficUsage.date,
receivedBytes: trafficUsage.receivedBytes,
sentBytes: trafficUsage.sentBytes,
})
.from(trafficUsage)
.where(
and(
eq(trafficUsage.clientId, clientId),
gte(trafficUsage.date, start),
lt(trafficUsage.date, endExclusive)
)
)
.execute();
}
export class TrafficService {
#db: DBType;
constructor(db: DBType) {
this.#db = db;
}
async record(peers: TrafficPeer[], now = new Date()) {
const peersByPublicKey = new Map(
peers.map((peer) => [peer.publicKey, peer])
);
const date = formatUtcDate(now);
return this.#db.transaction(async (tx) => {
const clients = await tx
.select({
id: client.id,
publicKey: client.publicKey,
enabled: client.enabled,
dailyQuota: client.dailyQuota,
weeklyQuota: client.weeklyQuota,
monthlyQuota: client.monthlyQuota,
})
.from(client)
.execute();
const states = await tx.select().from(trafficState).execute();
const statesByClientId = new Map(
states.map((state) => [state.clientId, state])
);
for (const clientRow of clients) {
const peer = peersByPublicKey.get(clientRow.publicKey);
const state = statesByClientId.get(clientRow.id);
const transferRx = peer?.transferRx ?? 0;
const transferTx = peer?.transferTx ?? 0;
const receivedBytes = peer
? calculateTrafficDelta(state?.transferRx ?? 0, transferRx)
: 0;
const sentBytes = peer
? calculateTrafficDelta(state?.transferTx ?? 0, transferTx)
: 0;
if (receivedBytes > 0 || sentBytes > 0) {
await tx
.insert(trafficUsage)
.values({
clientId: clientRow.id,
date,
receivedBytes,
sentBytes,
})
.onConflictDoUpdate({
target: [trafficUsage.clientId, trafficUsage.date],
set: {
receivedBytes: sql`${trafficUsage.receivedBytes} + ${receivedBytes}`,
sentBytes: sql`${trafficUsage.sentBytes} + ${sentBytes}`,
},
})
.execute();
}
await tx
.insert(trafficState)
.values({ clientId: clientRow.id, transferRx, transferTx })
.onConflictDoUpdate({
target: trafficState.clientId,
set: { transferRx, transferTx },
})
.execute();
}
const disabledClientIds: ID[] = [];
for (const clientRow of clients) {
if (
!clientRow.enabled ||
(clientRow.dailyQuota === null &&
clientRow.weeklyQuota === null &&
clientRow.monthlyQuota === null)
) {
continue;
}
const days = await getRelevantDays(tx, clientRow.id, now);
if (getExceededTrafficQuotas(clientRow, days, now).length > 0) {
disabledClientIds.push(clientRow.id);
}
}
if (disabledClientIds.length > 0) {
await tx
.update(client)
.set({ enabled: false })
.where(inArray(client.id, disabledClientIds))
.execute();
}
return disabledClientIds;
});
}
resetBaselines() {
return this.#db
.update(trafficState)
.set({ transferRx: 0, transferTx: 0 })
.execute();
}
async getExceededQuotas(clientId: ID, now = new Date()) {
const clientRow = await this.#db.query.client.findFirst({
where: eq(client.id, clientId),
columns: {
id: true,
enabled: true,
dailyQuota: true,
weeklyQuota: true,
monthlyQuota: true,
},
});
if (!clientRow) {
return [];
}
const days = await getRelevantDays(this.#db, clientId, now);
return getExceededTrafficQuotas(clientRow, days, now);
}
async getReport(
clientRow: Pick<
ClientType,
'id' | 'dailyQuota' | 'weeklyQuota' | 'monthlyQuota'
>,
{ period, date }: TrafficQueryType
): Promise<TrafficReport> {
const containingDate = date ? parseUtcDate(date)! : new Date();
const range = getTrafficPeriodRange(period, containingDate);
const days = await this.#db
.select({
date: trafficUsage.date,
receivedBytes: trafficUsage.receivedBytes,
sentBytes: trafficUsage.sentBytes,
})
.from(trafficUsage)
.where(
and(
eq(trafficUsage.clientId, clientRow.id),
gte(trafficUsage.date, range.start),
lt(trafficUsage.date, range.endExclusive)
)
)
.orderBy(asc(trafficUsage.date))
.execute();
const usage = sumTrafficDays(days, range);
const quotaBytes = clientRow[quotaField[period]];
const totalBytes = usage.receivedBytes + usage.sentBytes;
return {
period,
start: range.start,
endExclusive: range.endExclusive,
quotaBytes,
receivedBytes: usage.receivedBytes,
sentBytes: usage.sentBytes,
totalBytes,
exceeded: quotaBytes !== null && totalBytes >= quotaBytes,
days: days.map((day) => ({
...day,
totalBytes: day.receivedBytes + day.sentBytes,
})),
};
}
}

36
src/server/database/repositories/traffic/types.ts

@ -0,0 +1,36 @@
import z from 'zod';
import { TRAFFIC_PERIODS, parseUtcDate } from '#server/utils/traffic';
export const TrafficQuerySchema = z.object({
period: z.enum(TRAFFIC_PERIODS),
date: z
.string()
.refine((value) => parseUtcDate(value) !== null, 'Invalid UTC date')
.optional(),
});
export type TrafficQueryType = z.infer<typeof TrafficQuerySchema>;
export type TrafficPeer = {
publicKey: string;
transferRx: number;
transferTx: number;
};
export type TrafficReport = {
period: TrafficQueryType['period'];
start: string;
endExclusive: string;
quotaBytes: number | null;
receivedBytes: number;
sentBytes: number;
totalBytes: number;
exceeded: boolean;
days: Array<{
date: string;
receivedBytes: number;
sentBytes: number;
totalBytes: number;
}>;
};

1
src/server/database/schema.ts

@ -4,5 +4,6 @@ export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';
export * from './repositories/oneTimeLink/schema';
export * from './repositories/traffic/schema';
export * from './repositories/user/schema';
export * from './repositories/userConfig/schema';

3
src/server/database/sqlite.ts

@ -11,6 +11,7 @@ import { InterfaceService } from '#db/repositories/interface/service';
import { HooksService } from '#db/repositories/hooks/service';
import { OneTimeLinkService } from '#db/repositories/oneTimeLink/service';
import { ClientService } from '#db/repositories/client/service';
import { TrafficService } from '#db/repositories/traffic/service';
import * as schema from '#db/schema';
import { WG_ENV, WG_INITIAL_ENV } from '#server/utils/config';
@ -43,6 +44,7 @@ class DBService {
interfaces: InterfaceService;
hooks: HooksService;
oneTimeLinks: OneTimeLinkService;
traffic: TrafficService;
constructor(db: DBType) {
this.clients = new ClientService(db);
@ -52,6 +54,7 @@ class DBService {
this.interfaces = new InterfaceService(db);
this.hooks = new HooksService(db);
this.oneTimeLinks = new OneTimeLinkService(db);
this.traffic = new TrafficService(db);
}
}

53
src/server/utils/WireGuard.ts

@ -18,16 +18,59 @@ const generateRandomHeaderValue = () =>
Math.floor(Math.random() * 2147483642) + 5;
class WireGuard {
#trafficUpdate: Promise<ID[]> | null = null;
/**
* Save and sync config
*/
async saveConfig() {
const wgInterface = await Database.interfaces.get();
await this.#recordTraffic(wgInterface);
await this.#saveWireguardConfig(wgInterface);
await this.#syncWireguardConfig(wgInterface);
await this.#applyFirewallRules(wgInterface);
}
async #recordTraffic(wgInterface: InterfaceType) {
if (this.#trafficUpdate) {
return this.#trafficUpdate;
}
const update = (async () => {
const dump = await wg.dump(wgInterface.name);
const disabledClientIds = await Database.traffic.record(dump);
for (const clientId of disabledClientIds) {
WG_DEBUG(`Client ${clientId} exceeded a traffic quota.`);
}
return disabledClientIds;
})();
this.#trafficUpdate = update;
try {
return await update;
} finally {
if (this.#trafficUpdate === update) {
this.#trafficUpdate = null;
}
}
}
/**
* Record current traffic and immediately remove peers that exceeded a quota.
*/
async updateTrafficStats() {
const wgInterface = await Database.interfaces.get();
const disabledClientIds = await this.#recordTraffic(wgInterface);
if (disabledClientIds.length > 0) {
await this.#saveWireguardConfig(wgInterface);
await this.#syncWireguardConfig(wgInterface);
await this.#applyFirewallRules(wgInterface);
}
return disabledClientIds;
}
/**
* Apply firewall rules based on current config
*/
@ -245,6 +288,7 @@ class WireGuard {
throw err;
});
await this.#syncWireguardConfig(wgInterface);
await Database.traffic.resetBaselines();
WG_DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`);
// Check if firewall was enabled but iptables isn't available
@ -283,15 +327,24 @@ class WireGuard {
// Shutdown wireguard
async Shutdown() {
const wgInterface = await Database.interfaces.get();
await this.#recordTraffic(wgInterface).catch((err) => {
WG_DEBUG('Saving traffic stats before shutdown failed.');
console.error(err);
});
await wg.down(wgInterface.name).catch(() => {});
}
async Restart() {
const wgInterface = await Database.interfaces.get();
await this.#recordTraffic(wgInterface);
await this.#saveWireguardConfig(wgInterface);
await wg.restart(wgInterface.name);
await Database.traffic.resetBaselines();
await this.#applyFirewallRules(wgInterface);
}
async cronJob() {
await this.updateTrafficStats();
const clients = await Database.clients.getAll();
let needsSave = false;
// Expires Feature

105
src/server/utils/traffic.ts

@ -0,0 +1,105 @@
export const TRAFFIC_PERIODS = ['daily', 'weekly', 'monthly'] as const;
export type TrafficPeriod = (typeof TRAFFIC_PERIODS)[number];
export type TrafficDay = {
date: string;
receivedBytes: number;
sentBytes: number;
};
export type TrafficQuotas = {
dailyQuota: number | null;
weeklyQuota: number | null;
monthlyQuota: number | null;
};
const DAY_MS = 24 * 60 * 60 * 1000;
export function formatUtcDate(date: Date) {
return date.toISOString().slice(0, 10);
}
export function parseUtcDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || formatUtcDate(date) !== value) {
return null;
}
return date;
}
export function getTrafficPeriodRange(
period: TrafficPeriod,
containingDate: Date
) {
const year = containingDate.getUTCFullYear();
const month = containingDate.getUTCMonth();
const day = containingDate.getUTCDate();
let start: Date;
let endExclusive: Date;
if (period === 'daily') {
start = new Date(Date.UTC(year, month, day));
endExclusive = new Date(start.getTime() + DAY_MS);
} else if (period === 'weekly') {
const midnight = new Date(Date.UTC(year, month, day));
const daysSinceMonday = (midnight.getUTCDay() + 6) % 7;
start = new Date(midnight.getTime() - daysSinceMonday * DAY_MS);
endExclusive = new Date(start.getTime() + 7 * DAY_MS);
} else {
start = new Date(Date.UTC(year, month, 1));
endExclusive = new Date(Date.UTC(year, month + 1, 1));
}
return {
start: formatUtcDate(start),
endExclusive: formatUtcDate(endExclusive),
};
}
export function calculateTrafficDelta(previous: number, current: number) {
return current >= previous ? current - previous : current;
}
export function sumTrafficDays(
days: TrafficDay[],
range: { start: string; endExclusive: string }
) {
return days.reduce(
(result, day) => {
if (day.date >= range.start && day.date < range.endExclusive) {
result.receivedBytes += day.receivedBytes;
result.sentBytes += day.sentBytes;
}
return result;
},
{ receivedBytes: 0, sentBytes: 0 }
);
}
export function getExceededTrafficQuotas(
quotas: TrafficQuotas,
days: TrafficDay[],
date: Date
) {
const quotaField = {
daily: 'dailyQuota',
weekly: 'weeklyQuota',
monthly: 'monthlyQuota',
} as const satisfies Record<TrafficPeriod, keyof TrafficQuotas>;
return TRAFFIC_PERIODS.filter((period) => {
const quota = quotas[quotaField[period]];
if (quota === null) {
return false;
}
const usage = sumTrafficDays(days, getTrafficPeriodRange(period, date));
return usage.receivedBytes + usage.sentBytes >= quota;
});
}

138
src/test/unit/traffic-service.spec.ts

@ -0,0 +1,138 @@
import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { TrafficService } from '#server/database/repositories/traffic/service';
import * as schema from '#server/database/schema';
describe('TrafficService', () => {
let libsql: ReturnType<typeof createClient>;
let traffic: TrafficService;
let databasePath: string;
beforeEach(async () => {
databasePath = join(tmpdir(), `wg-easy-traffic-${randomUUID()}.db`);
libsql = createClient({ url: `file:${databasePath}` });
await libsql.executeMultiple(`
PRAGMA foreign_keys = ON;
CREATE TABLE clients_table (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_key TEXT NOT NULL,
enabled INTEGER NOT NULL,
daily_quota INTEGER,
weekly_quota INTEGER,
monthly_quota INTEGER,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE client_traffic_state_table (
client_id INTEGER PRIMARY KEY NOT NULL,
transfer_rx INTEGER DEFAULT 0 NOT NULL,
transfer_tx INTEGER DEFAULT 0 NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients_table(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE client_traffic_usage_table (
client_id INTEGER NOT NULL,
date TEXT NOT NULL,
received_bytes INTEGER DEFAULT 0 NOT NULL,
sent_bytes INTEGER DEFAULT 0 NOT NULL,
PRIMARY KEY (client_id, date),
FOREIGN KEY (client_id) REFERENCES clients_table(id) ON UPDATE CASCADE ON DELETE CASCADE
);
`);
const db = drizzle({ client: libsql, schema });
traffic = new TrafficService(db);
});
afterEach(async () => {
libsql.close();
await rm(databasePath, { force: true });
});
test('accumulates deltas, handles resets, and reports sparse days', async () => {
await libsql.execute({
sql: `INSERT INTO clients_table
(id, public_key, enabled, daily_quota, weekly_quota, monthly_quota)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [1, 'public-key', 1, null, null, null],
});
await traffic.record(
[{ publicKey: 'public-key', transferRx: 100, transferTx: 50 }],
new Date('2026-06-22T12:00:00Z')
);
await traffic.record(
[{ publicKey: 'public-key', transferRx: 140, transferTx: 70 }],
new Date('2026-06-22T12:01:00Z')
);
await traffic.record(
[{ publicKey: 'public-key', transferRx: 10, transferTx: 5 }],
new Date('2026-06-23T00:01:00Z')
);
await expect(
traffic.getReport(
{
id: 1,
dailyQuota: null,
weeklyQuota: null,
monthlyQuota: null,
},
{ period: 'weekly', date: '2026-06-23' }
)
).resolves.toMatchObject({
receivedBytes: 150,
sentBytes: 75,
totalBytes: 225,
days: [
{
date: '2026-06-22',
receivedBytes: 140,
sentBytes: 70,
totalBytes: 210,
},
{
date: '2026-06-23',
receivedBytes: 10,
sentBytes: 5,
totalBytes: 15,
},
],
});
});
test('disables at the exact combined quota and cascades history', async () => {
await libsql.execute({
sql: `INSERT INTO clients_table
(id, public_key, enabled, daily_quota, weekly_quota, monthly_quota)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [1, 'public-key', 1, 150, 1000, null],
});
await expect(
traffic.record(
[{ publicKey: 'public-key', transferRx: 100, transferTx: 50 }],
new Date('2026-06-22T12:00:00Z')
)
).resolves.toEqual([1]);
const client = await libsql.execute(
'SELECT enabled FROM clients_table WHERE id = 1'
);
expect(client.rows[0]?.enabled).toBe(0);
await libsql.execute('DELETE FROM clients_table WHERE id = 1');
const usage = await libsql.execute(
'SELECT COUNT(*) AS count FROM client_traffic_usage_table'
);
const state = await libsql.execute(
'SELECT COUNT(*) AS count FROM client_traffic_state_table'
);
expect(usage.rows[0]?.count).toBe(0);
expect(state.rows[0]?.count).toBe(0);
});
});

83
src/test/unit/traffic.spec.ts

@ -0,0 +1,83 @@
import { describe, expect, test } from 'vitest';
import {
calculateTrafficDelta,
getExceededTrafficQuotas,
getTrafficPeriodRange,
parseUtcDate,
sumTrafficDays,
} from '#server/utils/traffic';
describe('traffic accounting', () => {
test('calculates increasing and reset counter deltas', () => {
expect(calculateTrafficDelta(100, 150)).toBe(50);
expect(calculateTrafficDelta(100, 100)).toBe(0);
expect(calculateTrafficDelta(100, 25)).toBe(25);
});
test('validates UTC date keys', () => {
expect(parseUtcDate('2024-02-29')).not.toBeNull();
expect(parseUtcDate('2023-02-29')).toBeNull();
expect(parseUtcDate('2024-2-9')).toBeNull();
});
test('uses UTC calendar day boundaries', () => {
expect(
getTrafficPeriodRange('daily', new Date('2026-06-22T23:59:59Z'))
).toEqual({ start: '2026-06-22', endExclusive: '2026-06-23' });
});
test('uses Monday-based weeks across month boundaries', () => {
expect(
getTrafficPeriodRange('weekly', new Date('2026-06-01T12:00:00Z'))
).toEqual({ start: '2026-06-01', endExclusive: '2026-06-08' });
expect(
getTrafficPeriodRange('weekly', new Date('2026-05-31T12:00:00Z'))
).toEqual({ start: '2026-05-25', endExclusive: '2026-06-01' });
});
test('uses calendar months including leap years', () => {
expect(
getTrafficPeriodRange('monthly', new Date('2024-02-29T12:00:00Z'))
).toEqual({ start: '2024-02-01', endExclusive: '2024-03-01' });
});
test('sums only sparse days in the selected range', () => {
expect(
sumTrafficDays(
[
{ date: '2026-05-31', receivedBytes: 100, sentBytes: 100 },
{ date: '2026-06-01', receivedBytes: 10, sentBytes: 20 },
{ date: '2026-06-03', receivedBytes: 30, sentBytes: 40 },
],
{ start: '2026-06-01', endExclusive: '2026-06-04' }
)
).toEqual({ receivedBytes: 40, sentBytes: 60 });
});
test('enforces simultaneous quotas at the exact combined limit', () => {
const date = new Date('2026-06-03T12:00:00Z');
const days = [
{ date: '2026-06-01', receivedBytes: 30, sentBytes: 20 },
{ date: '2026-06-03', receivedBytes: 40, sentBytes: 10 },
];
expect(
getExceededTrafficQuotas(
{ dailyQuota: 50, weeklyQuota: 100, monthlyQuota: 101 },
days,
date
)
).toEqual(['daily', 'weekly']);
});
test('does not enforce disabled quotas', () => {
expect(
getExceededTrafficQuotas(
{ dailyQuota: null, weeklyQuota: null, monthlyQuota: null },
[{ date: '2026-06-03', receivedBytes: 100, sentBytes: 100 }],
new Date('2026-06-03T12:00:00Z')
)
).toEqual([]);
});
});
Loading…
Cancel
Save