Skip to content

Commit 5bc01c8

Browse files
committed
infra(ci): re-add cargo fmt/clippy gates and raise backend coverage to 60%
- Re-added rustfmt and clippy components to contracts CI job. - Restored backend coverage thresholds to 60% and added 17 new test files to exceed the goal (achieved ~72% on core files). - Removed dead unauthenticated top-level stream routes. - Removed unused sorobanWithdraw import in stream controller. - Fixed small TS error in soroban-worker helpers test.
1 parent 0974e90 commit 5bc01c8

21 files changed

Lines changed: 1077 additions & 16 deletions

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,21 @@ jobs:
123123
with:
124124
toolchain: stable
125125
targets: wasm32-unknown-unknown
126+
components: rustfmt, clippy
126127

127128
- name: Rust Cache
128129
uses: Swatinem/rust-cache@v2
129130
with:
130131
workspaces: "contracts -> target"
131132

133+
- name: Check Formatting
134+
run: cargo fmt --all -- --check
135+
working-directory: contracts
136+
137+
- name: Run Clippy
138+
run: cargo clippy --all-targets -- -D warnings
139+
working-directory: contracts
140+
132141
- name: Build Contracts
133142
run: cargo build --target wasm32-unknown-unknown --release
134143
working-directory: contracts

backend/src/controllers/stream.controller.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
topUpStream,
1111
pauseStream as sorobanPauseStream,
1212
resumeStream as sorobanResumeStream,
13-
withdraw as sorobanWithdraw,
1413
} from '../services/sorobanService.js';
1514
import type { AuthenticatedRequest } from '../types/auth.types.js';
1615

backend/src/routes/stream.routes.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

backend/tests/api-version.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { apiVersionMiddleware, getApiVersion, DEFAULT_VERSION } from '../src/middleware/api-version.middleware.js';
3+
import type { Response, NextFunction } from 'express';
4+
import type { VersionedRequest } from '../src/middleware/api-version.middleware.js';
5+
6+
describe('API Version Middleware', () => {
7+
let req: Partial<VersionedRequest>;
8+
let res: Partial<Response>;
9+
let next: NextFunction;
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
res = {
14+
status: vi.fn().mockReturnThis(),
15+
json: vi.fn().mockReturnThis(),
16+
};
17+
next = vi.fn();
18+
});
19+
20+
it('should extract v1 from path and rewrite url', () => {
21+
req = { path: '/v1/streams', url: '/v1/streams' };
22+
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
23+
expect(req.apiVersion).toBe('v1');
24+
expect(req.url).toBe('/streams');
25+
expect(next).toHaveBeenCalled();
26+
});
27+
28+
it('should return 400 for unsupported version', () => {
29+
req = { path: '/v2/streams', url: '/v2/streams' };
30+
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
31+
expect(res.status).toHaveBeenCalledWith(400);
32+
expect(next).not.toHaveBeenCalled();
33+
});
34+
35+
it('should skip version extraction if path does not match vN pattern', () => {
36+
req = { path: '/health', url: '/health' };
37+
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
38+
expect(req.apiVersion).toBeUndefined();
39+
expect(req.url).toBe('/health');
40+
expect(next).toHaveBeenCalled();
41+
});
42+
43+
it('should preserve query strings when rewriting url', () => {
44+
req = { path: '/v1/streams', url: '/v1/streams?sender=G123' };
45+
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
46+
expect(req.url).toBe('/streams?sender=G123');
47+
expect(next).toHaveBeenCalled();
48+
});
49+
50+
it('should return default version if apiVersion is missing', () => {
51+
req = {};
52+
expect(getApiVersion(req as VersionedRequest)).toBe(DEFAULT_VERSION);
53+
});
54+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { requireAuth } from '../src/middleware/auth.js';
3+
import type { Request, Response, NextFunction } from 'express';
4+
5+
describe('Auth Middleware', () => {
6+
let req: Partial<Request>;
7+
let res: Partial<Response>;
8+
let next: NextFunction;
9+
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
req = { headers: {} };
13+
res = {
14+
status: vi.fn().mockReturnThis(),
15+
json: vi.fn().mockReturnThis(),
16+
};
17+
next = vi.fn();
18+
});
19+
20+
it('should return 401 if no auth header', () => {
21+
requireAuth(req as Request, res as Response, next);
22+
expect(res.status).toHaveBeenCalledWith(401);
23+
});
24+
25+
it('should return 401 if auth header is not Bearer', () => {
26+
req.headers = { authorization: 'Basic 123' };
27+
requireAuth(req as Request, res as Response, next);
28+
expect(res.status).toHaveBeenCalledWith(401);
29+
});
30+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { cancelStreamHandler } from '../src/controllers/stream/cancel.js';
3+
import { prisma } from '../src/lib/prisma.js';
4+
import * as sorobanService from '../src/services/sorobanService.js';
5+
import * as streamRepository from '../src/repositories/stream.repository.js';
6+
import type { Response } from 'express';
7+
import type { AuthenticatedRequest } from '../src/types/auth.types.js';
8+
9+
vi.mock('../src/lib/prisma.js', () => ({
10+
prisma: {
11+
stream: {
12+
findUnique: vi.fn(),
13+
},
14+
},
15+
}));
16+
17+
vi.mock('../src/services/sorobanService.js', () => ({
18+
cancelStream: vi.fn(),
19+
}));
20+
21+
vi.mock('../src/repositories/stream.repository.js', () => ({
22+
updateStatus: vi.fn(),
23+
}));
24+
25+
vi.mock('../src/logger.js', () => ({
26+
default: {
27+
info: vi.fn(),
28+
error: vi.fn(),
29+
warn: vi.fn(),
30+
},
31+
}));
32+
33+
describe('Cancel Stream Controller', () => {
34+
let req: Partial<AuthenticatedRequest>;
35+
let res: Partial<Response>;
36+
37+
beforeEach(() => {
38+
vi.clearAllMocks();
39+
process.env.SOROBAN_SECRET_KEY = 'SABC123';
40+
req = {
41+
params: { streamId: '123' },
42+
user: { publicKey: 'GSENDER1' } as any,
43+
};
44+
res = {
45+
status: vi.fn().mockReturnThis(),
46+
json: vi.fn().mockReturnThis(),
47+
};
48+
});
49+
50+
it('should return 404 if stream not found', async () => {
51+
(prisma.stream.findUnique as any).mockResolvedValue(null);
52+
53+
await cancelStreamHandler(req as AuthenticatedRequest, res as Response);
54+
55+
expect(res.status).toHaveBeenCalledWith(404);
56+
});
57+
58+
it('should return 403 if caller is not sender', async () => {
59+
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GOTHER', isActive: true });
60+
61+
await cancelStreamHandler(req as AuthenticatedRequest, res as Response);
62+
63+
expect(res.status).toHaveBeenCalledWith(403);
64+
});
65+
66+
it('should return 409 if stream is already inactive', async () => {
67+
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: false });
68+
69+
await cancelStreamHandler(req as AuthenticatedRequest, res as Response);
70+
71+
expect(res.status).toHaveBeenCalledWith(409);
72+
});
73+
74+
it('should successfully cancel stream', async () => {
75+
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: true });
76+
(sorobanService.cancelStream as any).mockResolvedValue('tx_hash_123');
77+
78+
await cancelStreamHandler(req as AuthenticatedRequest, res as Response);
79+
80+
expect(sorobanService.cancelStream).toHaveBeenCalledWith(123, 'SABC123');
81+
expect(streamRepository.updateStatus).toHaveBeenCalledWith(123, 'CANCELLED');
82+
expect(res.status).toHaveBeenCalledWith(200);
83+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'CANCELLED', txHash: 'tx_hash_123' }));
84+
});
85+
86+
it('should return 500 if SOROBAN_SECRET_KEY is missing', async () => {
87+
delete process.env.SOROBAN_SECRET_KEY;
88+
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: true });
89+
90+
await cancelStreamHandler(req as AuthenticatedRequest, res as Response);
91+
92+
expect(res.status).toHaveBeenCalledWith(500);
93+
});
94+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { errorHandler } from '../src/middleware/error.middleware.js';
3+
import { ZodError } from 'zod';
4+
import { Prisma } from '../src/generated/prisma/index.js';
5+
import type { Request, Response, NextFunction } from 'express';
6+
7+
describe('Error Middleware', () => {
8+
let req: Partial<Request>;
9+
let res: Partial<Response>;
10+
let next: NextFunction;
11+
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
req = {};
15+
res = {
16+
status: vi.fn().mockReturnThis(),
17+
json: vi.fn().mockReturnThis(),
18+
};
19+
next = vi.fn();
20+
});
21+
22+
it('should handle ZodError', () => {
23+
const error = new ZodError([{ path: ['field'], message: 'invalid', code: 'custom' }]);
24+
errorHandler(error, req as Request, res as Response, next);
25+
expect(res.status).toHaveBeenCalledWith(400);
26+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Validation Error' }));
27+
});
28+
29+
it('should handle Prisma P2002 error', () => {
30+
const error = new Prisma.PrismaClientKnownRequestError('Conflict', { code: 'P2002', clientVersion: '1.0', meta: { target: ['email'] } });
31+
errorHandler(error, req as Request, res as Response, next);
32+
expect(res.status).toHaveBeenCalledWith(409);
33+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Conflict Error' }));
34+
});
35+
36+
it('should handle Prisma P2025 error', () => {
37+
const error = new Prisma.PrismaClientKnownRequestError('Not found', { code: 'P2025', clientVersion: '1.0' });
38+
errorHandler(error, req as Request, res as Response, next);
39+
expect(res.status).toHaveBeenCalledWith(404);
40+
});
41+
42+
it('should handle generic error', () => {
43+
const error = new Error('Generic error');
44+
errorHandler(error, req as Request, res as Response, next);
45+
expect(res.status).toHaveBeenCalledWith(500);
46+
});
47+
});

backend/tests/redis.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { cache, isRedisAvailable } from '../src/lib/redis.js';
3+
4+
describe('Memory Cache', () => {
5+
it('should set and get values', () => {
6+
cache.set('key1', 'value1', 10);
7+
expect(cache.get('key1')).toBe('value1');
8+
});
9+
10+
it('should return null for expired values', () => {
11+
vi.useFakeTimers();
12+
cache.set('key-exp', 'value1', 1);
13+
vi.advanceTimersByTime(1500);
14+
expect(cache.get('key-exp')).toBeNull();
15+
vi.useRealTimers();
16+
});
17+
18+
it('should delete values', () => {
19+
cache.set('key-del', 'value1', 10);
20+
cache.del('key-del');
21+
expect(cache.get('key-del')).toBeNull();
22+
});
23+
24+
it('should return stats', () => {
25+
const initialStats = cache.getStats();
26+
cache.set('key-stats', 'value1', 10);
27+
cache.get('key-stats');
28+
cache.get('key-missing');
29+
const finalStats = cache.getStats();
30+
expect(finalStats.hits).toBe(initialStats.hits + 1);
31+
expect(finalStats.misses).toBe(initialStats.misses + 1);
32+
});
33+
});
34+
35+
describe('Redis Available', () => {
36+
it('should return false if redis not initialized', () => {
37+
expect(isRedisAvailable()).toBe(false);
38+
});
39+
});

backend/tests/requestId.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { requestIdMiddleware } from '../src/middleware/requestId.js';
3+
import type { Request, Response, NextFunction } from 'express';
4+
5+
describe('RequestId Middleware', () => {
6+
let req: Partial<Request>;
7+
let res: Partial<Response>;
8+
let next: NextFunction;
9+
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
req = {
13+
headers: {},
14+
method: 'GET',
15+
path: '/test',
16+
};
17+
res = {
18+
setHeader: vi.fn(),
19+
on: vi.fn(),
20+
};
21+
next = vi.fn();
22+
});
23+
24+
it('should generate a new requestId if missing', () => {
25+
requestIdMiddleware(req as Request, res as Response, next);
26+
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', expect.any(String));
27+
expect(next).toHaveBeenCalled();
28+
});
29+
30+
it('should use existing requestId from header', () => {
31+
req.headers = { 'x-request-id': 'existing-id' };
32+
requestIdMiddleware(req as Request, res as Response, next);
33+
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', 'existing-id');
34+
expect(next).toHaveBeenCalled();
35+
});
36+
37+
it('should generate new id if header is too long', () => {
38+
req.headers = { 'x-request-id': 'a'.repeat(129) };
39+
requestIdMiddleware(req as Request, res as Response, next);
40+
const call = (res.setHeader as any).mock.calls[0];
41+
expect(call[1]).not.toBe('a'.repeat(129));
42+
});
43+
});

0 commit comments

Comments
 (0)