mirror of https://github.com/wg-easy/wg-easy
16 changed files with 1951 additions and 5 deletions
@ -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); |
||||
|
} |
||||
|
); |
||||
@ -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; |
||||
File diff suppressed because it is too large
@ -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], |
||||
|
}), |
||||
|
})); |
||||
@ -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, |
||||
|
})), |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
}>; |
||||
|
}; |
||||
@ -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; |
||||
|
}); |
||||
|
} |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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…
Reference in new issue