Phase7
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/metering": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
@@ -42,6 +43,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
CatalogModule,
|
||||
BillingModule,
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
|
||||
@@ -5,6 +5,7 @@ import Redis from 'ioredis';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -18,7 +19,7 @@ import { MeController } from './me.controller';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
imports: [AppConfigModule, BillingModule],
|
||||
controllers: [AuthController, MeController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
const LOCKOUT_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -74,6 +75,7 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly billingService: BillingService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
||||
@@ -123,6 +125,8 @@ export class AuthService {
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.billingService.ensureWalletForUser(user.id);
|
||||
|
||||
await this.createEmailVerificationToken(user.id, user.email);
|
||||
|
||||
await this.auditService.record({
|
||||
|
||||
32
apps/api/src/billing/billing.controller.ts
Normal file
32
apps/api/src/billing/billing.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
@ApiTags('account')
|
||||
@Controller('account')
|
||||
@UseGuards(SessionGuard)
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get('wallet')
|
||||
@ApiOperation({ summary: 'Get credit wallet balance and recent transactions' })
|
||||
getWallet(@CurrentUser() user: { id: string }): Promise<WalletResponse> {
|
||||
return this.billingService.getWallet(user.id);
|
||||
}
|
||||
|
||||
@Get('usage')
|
||||
@ApiOperation({ summary: 'List recent usage records' })
|
||||
listUsage(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<UsageListResponse> {
|
||||
const parsed = limit ? Number.parseInt(limit, 10) : 20;
|
||||
return this.billingService.listUsage(user.id, Number.isFinite(parsed) ? parsed : 20);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/billing/billing.module.ts
Normal file
14
apps/api/src/billing/billing.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { BillingController } from './billing.controller';
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
190
apps/api/src/billing/billing.service.ts
Normal file
190
apps/api/src/billing/billing.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureWalletForUser(userId: string, grantCredits = 0): Promise<void> {
|
||||
const existing = await this.prisma.creditWallet.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freePlan = await this.prisma.plan.findFirst({
|
||||
where: { slug: 'free', isActive: true },
|
||||
});
|
||||
|
||||
const initialGrant = grantCredits || freePlan?.monthlyCreditGrant || 0;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const wallet = await tx.creditWallet.create({
|
||||
data: {
|
||||
userId,
|
||||
balance: initialGrant,
|
||||
},
|
||||
});
|
||||
|
||||
if (initialGrant > 0) {
|
||||
await tx.creditTransaction.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
amount: initialGrant,
|
||||
type: 'GRANT',
|
||||
referenceType: 'signup',
|
||||
referenceId: userId,
|
||||
idempotencyKey: `grant:signup:${userId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getWallet(userId: string): Promise<WalletResponse> {
|
||||
await this.ensureWalletForUser(userId);
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
include: {
|
||||
transactions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
balance: wallet.balance,
|
||||
periodStart: wallet.periodStart.toISOString(),
|
||||
transactions: wallet.transactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
amount: tx.amount,
|
||||
type: tx.type,
|
||||
referenceType: tx.referenceType,
|
||||
referenceId: tx.referenceId,
|
||||
createdAt: tx.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listUsage(userId: string, limit = 20): Promise<UsageListResponse> {
|
||||
const records = await this.prisma.usageRecord.findMany({
|
||||
where: { userId },
|
||||
orderBy: { periodStart: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
id: record.id,
|
||||
serverId: record.serverId,
|
||||
ramMb: record.ramMb,
|
||||
durationSeconds: record.durationSeconds,
|
||||
creditsCharged: record.creditsCharged,
|
||||
periodStart: record.periodStart.toISOString(),
|
||||
periodEnd: record.periodEnd.toISOString(),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async assertCanStartServer(userId: string): Promise<void> {
|
||||
await this.ensureWalletForUser(userId);
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!wallet || wallet.balance <= 0) {
|
||||
throw new ForbiddenException(
|
||||
'Insufficient credits to start a server. Wait for the next grant or upgrade your plan.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanCreateServer(
|
||||
userId: string,
|
||||
planId?: string | null,
|
||||
): Promise<void> {
|
||||
const plan = planId
|
||||
? await this.prisma.plan.findUnique({ where: { id: planId } })
|
||||
: await this.prisma.plan.findFirst({ where: { slug: 'free', isActive: true } });
|
||||
|
||||
if (!plan?.maxServersPerUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await this.prisma.gameServer.count({
|
||||
where: {
|
||||
userId,
|
||||
status: { not: 'DELETED' },
|
||||
},
|
||||
});
|
||||
|
||||
if (count >= plan.maxServersPerUser) {
|
||||
throw new ForbiddenException(
|
||||
`Server quota reached (${plan.maxServersPerUser} servers on ${plan.name})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async grantMonthlyCredits(): Promise<number> {
|
||||
const plans = await this.prisma.plan.findMany({
|
||||
where: { isActive: true, monthlyCreditGrant: { gt: 0 } },
|
||||
});
|
||||
|
||||
let granted = 0;
|
||||
|
||||
for (const plan of plans) {
|
||||
const servers = await this.prisma.gameServer.findMany({
|
||||
where: { planId: plan.id },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
|
||||
for (const { userId } of servers) {
|
||||
await this.ensureWalletForUser(userId);
|
||||
const key = `grant:monthly:${plan.slug}:${userId}:${new Date().toISOString().slice(0, 7)}`;
|
||||
|
||||
const wallet = await this.prisma.creditWallet.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
const existing = await this.prisma.creditTransaction.findUnique({
|
||||
where: { idempotencyKey: key },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.creditWallet.update({
|
||||
where: { id: wallet.id },
|
||||
data: { balance: { increment: plan.monthlyCreditGrant } },
|
||||
}),
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
amount: plan.monthlyCreditGrant,
|
||||
type: 'GRANT',
|
||||
referenceType: 'plan',
|
||||
referenceId: plan.id,
|
||||
idempotencyKey: key,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
granted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return granted;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,18 @@ export class SchedulingService {
|
||||
actorId: string,
|
||||
correlationId: string,
|
||||
): Promise<{ server: GameServer; queuePosition: number }> {
|
||||
const fullServer = server.planId
|
||||
? server
|
||||
: await this.prisma.gameServer.findUniqueOrThrow({
|
||||
where: { id: server.id },
|
||||
});
|
||||
|
||||
const plan = fullServer.planId
|
||||
? await this.prisma.plan.findUnique({ where: { id: fullServer.planId } })
|
||||
: null;
|
||||
|
||||
const expiresAt = new Date(Date.now() + START_QUEUE_EXPIRY_MS);
|
||||
const priority = plan?.queuePriority ?? 0;
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.serverStartQueueEntry.upsert({
|
||||
@@ -39,11 +50,13 @@ export class SchedulingService {
|
||||
serverId: server.id,
|
||||
nodeId: server.nodeId!,
|
||||
status: 'QUEUED',
|
||||
priority,
|
||||
correlationId,
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
status: 'QUEUED',
|
||||
priority,
|
||||
correlationId,
|
||||
expiresAt,
|
||||
requestedAt: new Date(),
|
||||
|
||||
13
apps/api/src/servers/idle/idle.module.ts
Normal file
13
apps/api/src/servers/idle/idle.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
import { IdleService } from './idle.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NodeBridgeModule],
|
||||
providers: [IdleService],
|
||||
exports: [IdleService],
|
||||
})
|
||||
export class IdleModule {}
|
||||
79
apps/api/src/servers/idle/idle.service.ts
Normal file
79
apps/api/src/servers/idle/idle.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { IdleStatusResponse } from '@hexahost/contracts';
|
||||
import { parseMinecraftListOutput } from '@hexahost/metering';
|
||||
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
@Injectable()
|
||||
export class IdleService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
) {}
|
||||
|
||||
async getIdleStatus(userId: string, serverId: string): Promise<IdleStatusResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
let playerCount: number | null = null;
|
||||
|
||||
if (server.status === 'RUNNING' && server.nodeId) {
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.command',
|
||||
{
|
||||
serverId: server.id,
|
||||
generation: server.version,
|
||||
command: 'list',
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
const output =
|
||||
typeof response.result === 'object' &&
|
||||
response.result !== null &&
|
||||
'output' in response.result &&
|
||||
typeof (response.result as { output: unknown }).output === 'string'
|
||||
? (response.result as { output: string }).output
|
||||
: typeof response.result === 'string'
|
||||
? response.result
|
||||
: '';
|
||||
|
||||
playerCount = parseMinecraftListOutput(output);
|
||||
}
|
||||
}
|
||||
|
||||
const secondsRemaining =
|
||||
server.idleStopAt && server.idleStopAt.getTime() > Date.now()
|
||||
? Math.ceil((server.idleStopAt.getTime() - Date.now()) / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
enabled: server.idleShutdownEnabled,
|
||||
playerCount,
|
||||
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||
secondsRemaining,
|
||||
lastPlayerSeenAt: server.lastPlayerSeenAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async extendIdleCountdown(userId: string, serverId: string): Promise<IdleStatusResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
if (!server.idleStopAt) {
|
||||
throw new BadRequestException('No idle shutdown countdown is active');
|
||||
}
|
||||
|
||||
const extended = new Date(server.idleStopAt.getTime() + 5 * 60_000);
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { idleStopAt: extended },
|
||||
});
|
||||
|
||||
return this.getIdleStatus(userId, serverId);
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,16 @@ import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { ServersService } from './servers.service';
|
||||
import { IdleService } from './idle/idle.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers')
|
||||
@UseGuards(SessionGuard)
|
||||
export class ServersController {
|
||||
constructor(private readonly serversService: ServersService) {}
|
||||
constructor(
|
||||
private readonly serversService: ServersService,
|
||||
private readonly idleService: IdleService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@@ -104,4 +108,23 @@ export class ServersController {
|
||||
) {
|
||||
return this.serversService.cancelQueuedStart(user.id, id);
|
||||
}
|
||||
|
||||
@Get(':id/idle')
|
||||
@ApiOperation({ summary: 'Get idle shutdown status' })
|
||||
idleStatus(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.idleService.getIdleStatus(user.id, id);
|
||||
}
|
||||
|
||||
@Post(':id/idle/extend')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Extend idle shutdown countdown by 5 minutes' })
|
||||
extendIdle(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.idleService.extendIdleCountdown(user.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { IdleModule } from './idle/idle.module';
|
||||
import { WorldsModule } from './worlds/worlds.module';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { PropertiesModule } from './properties/properties.module';
|
||||
@@ -33,6 +35,8 @@ import { PlansService } from './plans.service';
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
SchedulingModule,
|
||||
BillingModule,
|
||||
IdleModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => AddonsModule),
|
||||
forwardRef(() => BackupsModule),
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { GameServer } from '@hexahost/database';
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { SchedulingService } from '../scheduling/scheduling.service';
|
||||
|
||||
import { findOwnedServer } from './shared/server-ownership.util';
|
||||
@@ -36,6 +37,7 @@ export class ServersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly scheduling: SchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(SERVER_LIFECYCLE_QUEUE)
|
||||
private readonly lifecycleQueue: Queue,
|
||||
@@ -47,6 +49,8 @@ export class ServersService {
|
||||
userId: string,
|
||||
input: CreateServerRequest,
|
||||
): Promise<ServerResponse> {
|
||||
await this.billing.assertCanCreateServer(userId, input.planId);
|
||||
|
||||
const server = await this.prisma.gameServer.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -110,6 +114,8 @@ export class ServersService {
|
||||
const correlationId = randomUUID();
|
||||
|
||||
if (targetStatus === 'STARTING') {
|
||||
await this.billing.assertCanStartServer(userId);
|
||||
|
||||
const canStart = await this.scheduling.canStartServer(server);
|
||||
if (!canStart) {
|
||||
const queued = await this.scheduling.enqueueStart(
|
||||
@@ -288,6 +294,21 @@ export class ServersService {
|
||||
data: {
|
||||
status: toStatus,
|
||||
version: { increment: 1 },
|
||||
...(toStatus === 'RUNNING'
|
||||
? {
|
||||
runningSince: new Date(),
|
||||
lastPlayerSeenAt: new Date(),
|
||||
lastMeteredAt: new Date(),
|
||||
idleStopAt: null,
|
||||
}
|
||||
: {}),
|
||||
...(toStatus === 'STOPPED' || toStatus === 'ERROR'
|
||||
? {
|
||||
runningSince: null,
|
||||
idleStopAt: null,
|
||||
lastMeteredAt: null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -347,6 +368,8 @@ export class ServersService {
|
||||
dataPath: server.dataPath,
|
||||
activeWorldName: server.activeWorldName,
|
||||
ramMb: server.ramMb,
|
||||
idleShutdownEnabled: server.idleShutdownEnabled,
|
||||
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
28
apps/api/test/phase7.test.js
Normal file
28
apps/api/test/phase7.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('phase 7 api contract', () => {
|
||||
it('documents idle shutdown and billing routes', () => {
|
||||
const billingSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'billing/billing.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(billingSource.includes("'wallet'"));
|
||||
assert.ok(billingSource.includes("'usage'"));
|
||||
|
||||
const idleSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/idle/idle.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(idleSource.includes('getIdleStatus'));
|
||||
assert.ok(idleSource.includes('extendIdleCountdown'));
|
||||
|
||||
const serversSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/servers.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(serversSource.includes(':id/idle'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user