Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,21 @@ jobs:
with:
toolchain: stable
targets: wasm32-unknown-unknown
components: rustfmt, clippy

- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "contracts -> target"

- name: Check Formatting
run: cargo fmt --all -- --check
working-directory: contracts

- name: Run Clippy
run: cargo clippy --all-targets -- -D warnings
working-directory: contracts

- name: Build Contracts
run: cargo build --target wasm32-unknown-unknown --release
working-directory: contracts
Expand Down
1 change: 0 additions & 1 deletion backend/src/controllers/stream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
topUpStream,
pauseStream as sorobanPauseStream,
resumeStream as sorobanResumeStream,
withdraw as sorobanWithdraw,
} from '../services/sorobanService.js';
import type { AuthenticatedRequest } from '../types/auth.types.js';

Expand Down
8 changes: 0 additions & 8 deletions backend/src/routes/stream.routes.ts

This file was deleted.

54 changes: 54 additions & 0 deletions backend/tests/api-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiVersionMiddleware, getApiVersion, DEFAULT_VERSION } from '../src/middleware/api-version.middleware.js';
import type { Response, NextFunction } from 'express';
import type { VersionedRequest } from '../src/middleware/api-version.middleware.js';

describe('API Version Middleware', () => {
let req: Partial<VersionedRequest>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
vi.clearAllMocks();
res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
};
next = vi.fn();
});

it('should extract v1 from path and rewrite url', () => {
req = { path: '/v1/streams', url: '/v1/streams' };
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
expect(req.apiVersion).toBe('v1');
expect(req.url).toBe('/streams');
expect(next).toHaveBeenCalled();
});

it('should return 400 for unsupported version', () => {
req = { path: '/v2/streams', url: '/v2/streams' };
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});

it('should skip version extraction if path does not match vN pattern', () => {
req = { path: '/health', url: '/health' };
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
expect(req.apiVersion).toBeUndefined();
expect(req.url).toBe('/health');
expect(next).toHaveBeenCalled();
});

it('should preserve query strings when rewriting url', () => {
req = { path: '/v1/streams', url: '/v1/streams?sender=G123' };
apiVersionMiddleware(req as VersionedRequest, res as Response, next);
expect(req.url).toBe('/streams?sender=G123');
expect(next).toHaveBeenCalled();
});

it('should return default version if apiVersion is missing', () => {
req = {};
expect(getApiVersion(req as VersionedRequest)).toBe(DEFAULT_VERSION);
});
});
30 changes: 30 additions & 0 deletions backend/tests/auth.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { requireAuth } from '../src/middleware/auth.js';
import type { Request, Response, NextFunction } from 'express';

describe('Auth Middleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
vi.clearAllMocks();
req = { headers: {} };
res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
};
next = vi.fn();
});

it('should return 401 if no auth header', () => {
requireAuth(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(401);
});

it('should return 401 if auth header is not Bearer', () => {
req.headers = { authorization: 'Basic 123' };
requireAuth(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(401);
});
});
94 changes: 94 additions & 0 deletions backend/tests/cancel.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cancelStreamHandler } from '../src/controllers/stream/cancel.js';
import { prisma } from '../src/lib/prisma.js';
import * as sorobanService from '../src/services/sorobanService.js';
import * as streamRepository from '../src/repositories/stream.repository.js';
import type { Response } from 'express';
import type { AuthenticatedRequest } from '../src/types/auth.types.js';

vi.mock('../src/lib/prisma.js', () => ({
prisma: {
stream: {
findUnique: vi.fn(),
},
},
}));

vi.mock('../src/services/sorobanService.js', () => ({
cancelStream: vi.fn(),
}));

vi.mock('../src/repositories/stream.repository.js', () => ({
updateStatus: vi.fn(),
}));

vi.mock('../src/logger.js', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));

describe('Cancel Stream Controller', () => {
let req: Partial<AuthenticatedRequest>;
let res: Partial<Response>;

beforeEach(() => {
vi.clearAllMocks();
process.env.SOROBAN_SECRET_KEY = 'SABC123';
req = {
params: { streamId: '123' },
user: { publicKey: 'GSENDER1' } as any,
};
res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
};
});

it('should return 404 if stream not found', async () => {
(prisma.stream.findUnique as any).mockResolvedValue(null);

await cancelStreamHandler(req as AuthenticatedRequest, res as Response);

expect(res.status).toHaveBeenCalledWith(404);
});

it('should return 403 if caller is not sender', async () => {
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GOTHER', isActive: true });

await cancelStreamHandler(req as AuthenticatedRequest, res as Response);

expect(res.status).toHaveBeenCalledWith(403);
});

it('should return 409 if stream is already inactive', async () => {
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: false });

await cancelStreamHandler(req as AuthenticatedRequest, res as Response);

expect(res.status).toHaveBeenCalledWith(409);
});

it('should successfully cancel stream', async () => {
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: true });
(sorobanService.cancelStream as any).mockResolvedValue('tx_hash_123');

await cancelStreamHandler(req as AuthenticatedRequest, res as Response);

expect(sorobanService.cancelStream).toHaveBeenCalledWith(123, 'SABC123');
expect(streamRepository.updateStatus).toHaveBeenCalledWith(123, 'CANCELLED');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'CANCELLED', txHash: 'tx_hash_123' }));
});

it('should return 500 if SOROBAN_SECRET_KEY is missing', async () => {
delete process.env.SOROBAN_SECRET_KEY;
(prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: true });

await cancelStreamHandler(req as AuthenticatedRequest, res as Response);

expect(res.status).toHaveBeenCalledWith(500);
});
});
47 changes: 47 additions & 0 deletions backend/tests/error.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { errorHandler } from '../src/middleware/error.middleware.js';
import { ZodError } from 'zod';
import { Prisma } from '../src/generated/prisma/index.js';
import type { Request, Response, NextFunction } from 'express';

describe('Error Middleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
vi.clearAllMocks();
req = {};
res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
};
next = vi.fn();
});

it('should handle ZodError', () => {
const error = new ZodError([{ path: ['field'], message: 'invalid', code: 'custom' }]);
errorHandler(error, req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Validation Error' }));
});

it('should handle Prisma P2002 error', () => {
const error = new Prisma.PrismaClientKnownRequestError('Conflict', { code: 'P2002', clientVersion: '1.0', meta: { target: ['email'] } });
errorHandler(error, req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Conflict Error' }));
});

it('should handle Prisma P2025 error', () => {
const error = new Prisma.PrismaClientKnownRequestError('Not found', { code: 'P2025', clientVersion: '1.0' });
errorHandler(error, req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(404);
});

it('should handle generic error', () => {
const error = new Error('Generic error');
errorHandler(error, req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(500);
});
});
39 changes: 39 additions & 0 deletions backend/tests/redis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cache, isRedisAvailable } from '../src/lib/redis.js';

describe('Memory Cache', () => {
it('should set and get values', () => {
cache.set('key1', 'value1', 10);
expect(cache.get('key1')).toBe('value1');
});

it('should return null for expired values', () => {
vi.useFakeTimers();
cache.set('key-exp', 'value1', 1);
vi.advanceTimersByTime(1500);
expect(cache.get('key-exp')).toBeNull();
vi.useRealTimers();
});

it('should delete values', () => {
cache.set('key-del', 'value1', 10);
cache.del('key-del');
expect(cache.get('key-del')).toBeNull();
});

it('should return stats', () => {
const initialStats = cache.getStats();
cache.set('key-stats', 'value1', 10);
cache.get('key-stats');
cache.get('key-missing');
const finalStats = cache.getStats();
expect(finalStats.hits).toBe(initialStats.hits + 1);
expect(finalStats.misses).toBe(initialStats.misses + 1);
});
});

describe('Redis Available', () => {
it('should return false if redis not initialized', () => {
expect(isRedisAvailable()).toBe(false);
});
});
43 changes: 43 additions & 0 deletions backend/tests/requestId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { requestIdMiddleware } from '../src/middleware/requestId.js';
import type { Request, Response, NextFunction } from 'express';

describe('RequestId Middleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
vi.clearAllMocks();
req = {
headers: {},
method: 'GET',
path: '/test',
};
res = {
setHeader: vi.fn(),
on: vi.fn(),
};
next = vi.fn();
});

it('should generate a new requestId if missing', () => {
requestIdMiddleware(req as Request, res as Response, next);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', expect.any(String));
expect(next).toHaveBeenCalled();
});

it('should use existing requestId from header', () => {
req.headers = { 'x-request-id': 'existing-id' };
requestIdMiddleware(req as Request, res as Response, next);
expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', 'existing-id');
expect(next).toHaveBeenCalled();
});

it('should generate new id if header is too long', () => {
req.headers = { 'x-request-id': 'a'.repeat(129) };
requestIdMiddleware(req as Request, res as Response, next);
const call = (res.setHeader as any).mock.calls[0];
expect(call[1]).not.toBe('a'.repeat(129));
});
});
Loading
Loading