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