Skip to content

Commit 6d06743

Browse files
authored
Merge pull request #165 from Penielka/fix/penielka-issues-27-34-42-99
fix: resolve issues #27, #34, #42, #99 assigned to Penielka
2 parents b4d1bd3 + e50c604 commit 6d06743

8 files changed

Lines changed: 291 additions & 1 deletion

File tree

.github/workflows/build.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ jobs:
118118
working-directory: onchain
119119
run: cargo test --workspace --locked
120120

121+
# ── Resource bench ─────────────────────────────────────────
122+
# Bench tests for submit_answer budget (issue #34). Output is
123+
# captured as an artifact so budget regressions are visible in
124+
# the CI run summary.
125+
- name: Run resource bench
126+
working-directory: onchain
127+
run: cargo test --workspace --locked -- bench_ --nocapture 2>&1 | tee bench-output.txt
128+
129+
- name: Upload bench artifact
130+
uses: actions/upload-artifact@v4
131+
with:
132+
name: bench-output
133+
path: onchain/bench-output.txt
134+
if-no-files-found: warn
135+
retention-days: 7
136+
121137
# ─────────────────────────────────────────────────────────────────────
122138
# Backend CI
123139
# ─────────────────────────────────────────────────────────────────────

backend/src/app.module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Module } from '@nestjs/common';
22
import { ConfigModule, ConfigService } from '@nestjs/config';
33
import { TypeOrmModule } from '@nestjs/typeorm';
4+
import * as Joi from 'joi';
45

56
import appConfig from 'config/app.config';
67
import databaseConfig from 'config/database.config';
@@ -48,6 +49,14 @@ import { UserReportCardModule } from './user-report-card/user-report-card.module
4849
envFilePath: ['.env'],
4950
load: [appConfig, databaseConfig],
5051
cache: true,
52+
validationSchema: Joi.object({
53+
JWT_SECRET: Joi.string().required(),
54+
DATABASE_HOST: Joi.string().required(),
55+
DATABASE_PORT: Joi.number().default(5432),
56+
DATABASE_USER: Joi.string().required(),
57+
DATABASE_PASSWORD: Joi.string().required(),
58+
DATABASE_NAME: Joi.string().required(),
59+
}),
5160
}),
5261
TypeOrmModule.forRootAsync({
5362
imports: [ConfigModule],
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import * as Joi from 'joi';
2+
3+
// Replicates the validation schema used in AppModule so we can unit-test
4+
// it without booting the full NestJS DI container.
5+
const validationSchema = Joi.object({
6+
JWT_SECRET: Joi.string().required(),
7+
DATABASE_HOST: Joi.string().required(),
8+
DATABASE_PORT: Joi.number().default(5432),
9+
DATABASE_USER: Joi.string().required(),
10+
DATABASE_PASSWORD: Joi.string().required(),
11+
DATABASE_NAME: Joi.string().required(),
12+
});
13+
14+
describe('Config validation schema', () => {
15+
it('passes with all required env vars set', () => {
16+
const env = {
17+
JWT_SECRET: 'super-secret',
18+
DATABASE_HOST: 'localhost',
19+
DATABASE_PORT: 5432,
20+
DATABASE_USER: 'postgres',
21+
DATABASE_PASSWORD: 'password',
22+
DATABASE_NAME: 'stellarhunts',
23+
};
24+
const { error } = validationSchema.validate(env, { allowUnknown: true });
25+
expect(error).toBeUndefined();
26+
});
27+
28+
it('uses default DATABASE_PORT when omitted', () => {
29+
const env = {
30+
JWT_SECRET: 'super-secret',
31+
DATABASE_HOST: 'localhost',
32+
// DATABASE_PORT omitted
33+
DATABASE_USER: 'postgres',
34+
DATABASE_PASSWORD: 'password',
35+
DATABASE_NAME: 'stellarhunts',
36+
};
37+
const { error, value } = validationSchema.validate(env, {
38+
allowUnknown: true,
39+
});
40+
expect(error).toBeUndefined();
41+
expect(value.DATABASE_PORT).toBe(5432);
42+
});
43+
44+
it('fails when JWT_SECRET is missing', () => {
45+
const env = {
46+
// JWT_SECRET omitted
47+
DATABASE_HOST: 'localhost',
48+
DATABASE_PORT: 5432,
49+
DATABASE_USER: 'postgres',
50+
DATABASE_PASSWORD: 'password',
51+
DATABASE_NAME: 'stellarhunts',
52+
};
53+
const { error } = validationSchema.validate(env, { allowUnknown: true });
54+
expect(error).toBeDefined();
55+
expect(error!.details.some((d) => d.path.includes('JWT_SECRET'))).toBe(
56+
true,
57+
);
58+
});
59+
60+
it('fails when DATABASE_HOST is missing', () => {
61+
const env = {
62+
JWT_SECRET: 'super-secret',
63+
// DATABASE_HOST omitted
64+
DATABASE_PORT: 5432,
65+
DATABASE_USER: 'postgres',
66+
DATABASE_PASSWORD: 'password',
67+
DATABASE_NAME: 'stellarhunts',
68+
};
69+
const { error } = validationSchema.validate(env, { allowUnknown: true });
70+
expect(error).toBeDefined();
71+
expect(error!.details.some((d) => d.path.includes('DATABASE_HOST'))).toBe(
72+
true,
73+
);
74+
});
75+
76+
it('fails when DATABASE_USER is missing', () => {
77+
const env = {
78+
JWT_SECRET: 'super-secret',
79+
DATABASE_HOST: 'localhost',
80+
DATABASE_PORT: 5432,
81+
// DATABASE_USER omitted
82+
DATABASE_PASSWORD: 'password',
83+
DATABASE_NAME: 'stellarhunts',
84+
};
85+
const { error } = validationSchema.validate(env, { allowUnknown: true });
86+
expect(error).toBeDefined();
87+
expect(error!.details.some((d) => d.path.includes('DATABASE_USER'))).toBe(
88+
true,
89+
);
90+
});
91+
92+
it('fails when DATABASE_PASSWORD is missing', () => {
93+
const env = {
94+
JWT_SECRET: 'super-secret',
95+
DATABASE_HOST: 'localhost',
96+
DATABASE_PORT: 5432,
97+
DATABASE_USER: 'postgres',
98+
// DATABASE_PASSWORD omitted
99+
DATABASE_NAME: 'stellarhunts',
100+
};
101+
const { error } = validationSchema.validate(env, { allowUnknown: true });
102+
expect(error).toBeDefined();
103+
expect(
104+
error!.details.some((d) => d.path.includes('DATABASE_PASSWORD')),
105+
).toBe(true);
106+
});
107+
108+
it('fails when DATABASE_NAME is missing', () => {
109+
const env = {
110+
JWT_SECRET: 'super-secret',
111+
DATABASE_HOST: 'localhost',
112+
DATABASE_PORT: 5432,
113+
DATABASE_USER: 'postgres',
114+
DATABASE_PASSWORD: 'password',
115+
// DATABASE_NAME omitted
116+
};
117+
const { error } = validationSchema.validate(env, { allowUnknown: true });
118+
expect(error).toBeDefined();
119+
expect(error!.details.some((d) => d.path.includes('DATABASE_NAME'))).toBe(
120+
true,
121+
);
122+
});
123+
});

backend/src/multiplayer-queue/entities/queue.entity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export enum SkillLevel {
1717
@Index(["status", "skillLevel"]) // Index for efficient matchmaking queries
1818
@Index(["userId"]) // Index for user-specific queries
1919
@Index(["createdAt"]) // Index for queue ordering
20+
@Index(["status", "createdAt"]) // Composite index for cron matchmaking scans
2021
export class Queue {
2122
@PrimaryGeneratedColumn("uuid")
2223
id: string

backend/src/multiplayer-queue/multiplayer-queue.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export class MultiplayerQueueService {
188188
const waitingPlayers = await this.queueRepository.find({
189189
where: { status: QueueStatus.WAITING },
190190
order: { createdAt: "ASC" },
191+
take: 200,
191192
})
192193

193194
if (waitingPlayers.length < 2) {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#![cfg(test)]
2+
3+
// Resource fee / budget benchmark for `submit_answer`.
4+
// Uses `env.cost_estimate()` and `env.budget()` to assert that the per-call
5+
// CPU and memory cost of a typical `submit_answer` stays within bounds,
6+
// catching accidental storage or computation blow-ups early.
7+
//
8+
// The test is included in the normal `cargo test --workspace --locked` run
9+
// so it gates on every CI push. If you need to see live budget numbers
10+
// interactively run with:
11+
// cargo test --workspace --locked -- bench_ --nocapture
12+
13+
use crate::{StellarHunts, StellarHuntsClient};
14+
use soroban_sdk::testutils::Address as _;
15+
use soroban_sdk::{Address, Bytes, Env};
16+
17+
fn b(env: &Env, s: &str) -> Bytes {
18+
Bytes::from_slice(env, s.as_bytes())
19+
}
20+
21+
/// Full lifecycle: init → 1 question → correct answer.
22+
///
23+
/// Asserts that the CPU instruction cost of `submit_answer` (including
24+
/// first-call player-initialization side effects) stays below a generous
25+
/// ceiling of 5 million instructions — any non-trivial logic regression
26+
/// will be caught by this gate.
27+
#[test]
28+
fn bench_submit_answer_cpu_budget() {
29+
let env = Env::default();
30+
env.mock_all_auths();
31+
32+
let admin = Address::generate(&env);
33+
let contract_id = env.register_contract(None, StellarHunts);
34+
let client = StellarHuntsClient::new(&env, &contract_id);
35+
client.init(&admin);
36+
37+
client.set_question_per_level(&1u32);
38+
let level = crate::Levels::Easy;
39+
let question = b(&env, "Bench question");
40+
let answer = b(&env, "Bench answer");
41+
let hint = b(&env, "Bench hint");
42+
client.add_question(&level, &question, &answer, &hint);
43+
44+
let player = Address::generate(&env);
45+
46+
// Reset the budget so we only measure the submit_answer call itself.
47+
let budget = env.budget();
48+
budget.reset_default();
49+
50+
let ok = client.submit_answer(&player, &1u64, &answer);
51+
assert!(ok);
52+
53+
let cpu = budget.cpu_instruction_count();
54+
let mem = budget.mem_bytes_count();
55+
56+
// Log diagnostics when run with --nocapture.
57+
eprintln!(
58+
"submit_answer budget cpu={} mem={} bytes",
59+
cpu, mem
60+
);
61+
62+
// Budget ceiling: 5M CPU instructions is generous for a single
63+
// submit_answer call (typical is ~200-500k). If this ever trips,
64+
// investigate what storage or crypto work is being done on the hot
65+
// path.
66+
assert!(
67+
cpu < 5_000_000,
68+
"submit_answer CPU budget exceeded: {} instructions (max 5_000_000)",
69+
cpu
70+
);
71+
72+
// Memory ceiling: 128 KB.
73+
assert!(
74+
mem < 131_072,
75+
"submit_answer memory budget exceeded: {} bytes (max 131_072)",
76+
mem
77+
);
78+
}
79+
80+
/// Ten consecutive correct answers to measure amortised cost.
81+
/// The per-call average should stay well under the ceiling.
82+
#[test]
83+
fn bench_ten_submit_answers_amortised() {
84+
let env = Env::default();
85+
env.mock_all_auths();
86+
87+
let admin = Address::generate(&env);
88+
let contract_id = env.register_contract(None, StellarHunts);
89+
let client = StellarHuntsClient::new(&env, &contract_id);
90+
client.init(&admin);
91+
92+
let per_level: u32 = 10;
93+
client.set_question_per_level(&per_level);
94+
95+
let level = crate::Levels::Easy;
96+
for i in 0..per_level {
97+
let q = b(&env, &format!("Q{}", i));
98+
let a = b(&env, &format!("A{}", i));
99+
let h = b(&env, &format!("H{}", i));
100+
client.add_question(&level, &q, &a, &h);
101+
}
102+
103+
let player = Address::generate(&env);
104+
let budget = env.budget();
105+
budget.reset_default();
106+
107+
for i in 0..per_level {
108+
let answer = b(&env, &format!("A{}", i));
109+
let ok = client.submit_answer(&player, &((i as u64) + 1), &answer);
110+
assert!(ok);
111+
}
112+
113+
let total_cpu = budget.cpu_instruction_count();
114+
let avg_cpu = total_cpu / (per_level as u64);
115+
116+
eprintln!(
117+
"10x submit_answer total_cpu={} avg_cpu={}",
118+
total_cpu, avg_cpu
119+
);
120+
121+
assert!(
122+
avg_cpu < 5_000_000,
123+
"amortised submit_answer CPU budget exceeded: {} avg instructions",
124+
avg_cpu
125+
);
126+
}

onchain/contracts/stellar_hunts/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,9 +601,11 @@ fn require_admin(env: &Env) {
601601
.storage()
602602
.instance()
603603
.get(&DataKey::Admin)
604-
.expect("admin not set");
604+
.unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized));
605605
admin.require_auth();
606606
}
607607

608+
#[cfg(test)]
609+
mod bench;
608610
#[cfg(test)]
609611
mod test;

onchain/contracts/stellar_hunts/src/test.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,15 @@ fn test_next_level_logic() {
162162
crate::Levels::Master
163163
);
164164
}
165+
166+
#[test]
167+
#[should_panic(expected = "Error(Contract, #6)")]
168+
fn test_require_admin_not_initialized() {
169+
let env = Env::default();
170+
env.mock_all_auths();
171+
// Register the contract WITHOUT calling init — admin key is unset.
172+
let contract_id = env.register_contract(None, StellarHunts);
173+
let client = StellarHuntsClient::new(&env, &contract_id);
174+
// Calling any admin-gated function should panic with Error::NotInitialized (#6).
175+
client.set_question_per_level(&5u32);
176+
}

0 commit comments

Comments
 (0)