Skip to content

Commit d111e64

Browse files
authored
Merge pull request #722 from boseshittu2323-design/feat/678-backend-contract-integration-tests
test(#678): backend-to-Soroban mint flow integration tests
2 parents e0a5d14 + 079777e commit d111e64

1 file changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
2+
import StellarSdk from '@stellar/stellar-sdk';
3+
import {
4+
MintSignatureVerificationService,
5+
buildMintChallenge,
6+
} from './mint-signature-verification.service';
7+
import { NftMetadataService } from './nft-metadata.service';
8+
import { IpfsUploadService, NftMetadata } from './ipfs-upload.service';
9+
import { RoyaltyConfigurationService } from './royalty-configuration.service';
10+
import { NftService } from './nft.service';
11+
import { NftConfig } from './nft.config';
12+
import { RoyaltyQueryService } from './royalty-query.service';
13+
import { CircuitBreakerService } from '../common/circuit-breaker/circuit-breaker.service';
14+
import { ConfigService } from '../config/config.service';
15+
16+
/**
17+
* End-to-end coverage of the backend-to-Soroban mint flow (Issue #678):
18+
* metadata upload -> wallet signature verification -> mint -> royalty query
19+
* on resale. Each stage uses the real service (no stubbing of the class
20+
* under test); only true external boundaries — Soroban RPC, IPFS HTTP, and
21+
* Redis — are mocked. This is deliberately separate from each service's own
22+
* *.spec.ts, which tests that service in isolation; here the goal is to
23+
* confirm the pieces compose correctly end-to-end and share consistent data
24+
* (the same clipId/wallet/royaltyBps) across every stage.
25+
*
26+
* The payloads below intentionally match the Swagger examples in
27+
* CreateMintDto (mint-clip.dto.ts) — clipId '42', the same creator wallet,
28+
* and royaltyBps 1000 — so the documented API examples are the same ones
29+
* this suite actually exercises.
30+
*/
31+
describe('Backend-to-contract mint flow integration (Issue #678)', () => {
32+
const CREATOR_WALLET = 'GC6XOTK6L6LGBKIWH3IRUZPVUY4COGEMW4J5YINOSPKO27YKTUUHTZF3';
33+
const PLATFORM_WALLET = 'GDV76E6XN6A3Q3WXVZ4KPRQ7L6E6XN6A3Q3WXVZ4KPRQ7L6E6XN6';
34+
const CLIP_ID = 42;
35+
const ROYALTY_BPS = 1000;
36+
37+
const royaltyConfigurationService = new RoyaltyConfigurationService({
38+
creatorRoyaltyBps: ROYALTY_BPS,
39+
platformRoyaltyBps: 100,
40+
platformWallet: PLATFORM_WALLET,
41+
royaltyAssetCode: 'native',
42+
royaltyAssetContractId: '',
43+
} as ConfigService);
44+
45+
const metadataService = new NftMetadataService(royaltyConfigurationService);
46+
47+
const circuitBreakerMock = {
48+
execute: jest.fn().mockImplementation((_config: unknown, fn: () => unknown) => fn()),
49+
};
50+
51+
const ipfsService = new IpfsUploadService(
52+
circuitBreakerMock as unknown as CircuitBreakerService,
53+
{
54+
ipfsProvider: 'pinata',
55+
pinataJwt: 'test-pinata-jwt',
56+
ipfsApiUrl: 'https://api.pinata.cloud/pinning/pinJSONToIPFS',
57+
nftStorageApiKey: '',
58+
} as ConfigService,
59+
);
60+
61+
const signatureService = new MintSignatureVerificationService();
62+
63+
beforeEach(() => {
64+
jest.clearAllMocks();
65+
circuitBreakerMock.execute.mockImplementation((_config: unknown, fn: () => unknown) => fn());
66+
global.fetch = jest.fn();
67+
});
68+
69+
// ── 1. Mock backend signature ──────────────────────────────────────────
70+
71+
describe('wallet signature verification', () => {
72+
it('accepts a real Ed25519 signature over the canonical mint challenge', () => {
73+
const wallet = StellarSdk.Keypair.random();
74+
const challenge = buildMintChallenge(CLIP_ID, wallet.publicKey());
75+
const signature = wallet.sign(Buffer.from(challenge, 'utf8')).toString('hex');
76+
77+
expect(() =>
78+
signatureService.verify(CLIP_ID, wallet.publicKey(), signature),
79+
).not.toThrow();
80+
});
81+
82+
it('accepts the same signature encoded as base64', () => {
83+
const wallet = StellarSdk.Keypair.random();
84+
const challenge = buildMintChallenge(CLIP_ID, wallet.publicKey());
85+
const signature = wallet.sign(Buffer.from(challenge, 'utf8')).toString('base64');
86+
87+
expect(() =>
88+
signatureService.verify(CLIP_ID, wallet.publicKey(), signature),
89+
).not.toThrow();
90+
});
91+
});
92+
93+
// ── 2. Metadata upload ──────────────────────────────────────────────────
94+
95+
describe('metadata build + IPFS upload', () => {
96+
it('builds OpenSea-compatible metadata and uploads it, returning an ipfs:// URI', async () => {
97+
const metadata = metadataService.build({
98+
id: CLIP_ID,
99+
title: 'Amazing Clip',
100+
caption: 'A test clip',
101+
clipUrl: 'https://cdn.example.com/video.mp4',
102+
thumbnail: 'https://cdn.example.com/thumb.jpg',
103+
duration: 27,
104+
viralityScore: 88,
105+
createdAt: new Date('2026-03-01T00:00:00.000Z'),
106+
royaltyBps: ROYALTY_BPS,
107+
royaltyRecipient: CREATOR_WALLET,
108+
});
109+
110+
expect(metadata.seller_fee_basis_points).toBe(ROYALTY_BPS);
111+
expect(metadata.royalty).toMatchObject({ bps: ROYALTY_BPS, percent: 10 });
112+
113+
(global.fetch as jest.Mock).mockResolvedValue({
114+
ok: true,
115+
json: async () => ({ IpfsHash: 'bafyIntegrationTestCid' }),
116+
});
117+
118+
const uri = await ipfsService.uploadMetadata(metadata, CLIP_ID);
119+
120+
expect(uri).toBe('ipfs://bafyIntegrationTestCid');
121+
expect(global.fetch).toHaveBeenCalledTimes(1);
122+
});
123+
});
124+
125+
// ── 3. Mint transaction ─────────────────────────────────────────────────
126+
127+
describe('mint transaction', () => {
128+
function makeNftService(overrides: Partial<NftConfig> = {}): NftService {
129+
const config = Object.assign(new NftConfig(), {
130+
creatorRoyaltyBps: ROYALTY_BPS,
131+
platformRoyaltyBps: 100,
132+
platformWallet: PLATFORM_WALLET,
133+
...overrides,
134+
});
135+
return new NftService(config);
136+
}
137+
138+
it('mints using the uploaded metadata URI and a verified wallet signature', async () => {
139+
// Signature stage (reuses the same challenge/verify pair as above).
140+
const wallet = StellarSdk.Keypair.random();
141+
const challenge = buildMintChallenge(CLIP_ID, wallet.publicKey());
142+
const signature = wallet.sign(Buffer.from(challenge, 'utf8')).toString('hex');
143+
signatureService.verify(CLIP_ID, wallet.publicKey(), signature);
144+
145+
// Metadata + upload stage.
146+
const metadata = metadataService.build({
147+
id: CLIP_ID,
148+
title: 'Amazing Clip',
149+
caption: null,
150+
clipUrl: 'https://cdn.example.com/video.mp4',
151+
thumbnail: null,
152+
duration: 27,
153+
viralityScore: null,
154+
createdAt: new Date('2026-03-01T00:00:00.000Z'),
155+
royaltyBps: ROYALTY_BPS,
156+
royaltyRecipient: CREATOR_WALLET,
157+
});
158+
(global.fetch as jest.Mock).mockResolvedValue({
159+
ok: true,
160+
json: async () => ({ IpfsHash: 'bafyMintFlowCid' }),
161+
});
162+
const metadataUri = await ipfsService.uploadMetadata(metadata, CLIP_ID);
163+
164+
// Mint stage.
165+
const nftService = makeNftService();
166+
const result = await nftService.mintClip({
167+
clipId: String(CLIP_ID),
168+
creatorWallet: CREATOR_WALLET,
169+
metadataUri,
170+
royaltyBps: ROYALTY_BPS,
171+
});
172+
173+
expect(result.txHash).toMatch(new RegExp(`^sim_tx_${CLIP_ID}_`));
174+
expect(result.transaction.metadataUri).toBe('ipfs://bafyMintFlowCid');
175+
expect(result.transaction.royalties).toEqual([
176+
{ wallet: CREATOR_WALLET, bps: ROYALTY_BPS, label: 'creator' },
177+
{ wallet: PLATFORM_WALLET, bps: 100, label: 'platform' },
178+
]);
179+
});
180+
});
181+
182+
// ── 4. Royalty on resale ────────────────────────────────────────────────
183+
184+
describe('royalty query on resale', () => {
185+
function makeRoyaltyQueryService() {
186+
const stellarService = {
187+
rpcUrl: 'https://soroban-testnet.stellar.org',
188+
networkPassphrase: 'Test SDF Network ; September 2015',
189+
};
190+
const redisService = { get: jest.fn().mockResolvedValue(null), setex: jest.fn() };
191+
const cb = { execute: jest.fn((_config: unknown, fn: () => unknown) => fn()) };
192+
return new RoyaltyQueryService(
193+
stellarService as any,
194+
redisService as any,
195+
cb as any,
196+
);
197+
}
198+
199+
it('resolves the royalty owed on a resale from the on-chain split', async () => {
200+
const royaltyQueryService = makeRoyaltyQueryService();
201+
202+
// The token was minted with a 1000 bps (10%) creator royalty above;
203+
// simulate the chain reporting that same split on a resale query.
204+
jest.spyOn(royaltyQueryService as any, 'queryOnChainRoyalty').mockResolvedValue({
205+
royaltyBps: ROYALTY_BPS,
206+
recipient: CREATOR_WALLET,
207+
});
208+
209+
const info = await royaltyQueryService.getRoyaltyInfo(String(CLIP_ID));
210+
211+
expect(info).toEqual({ royaltyBps: ROYALTY_BPS, recipient: CREATOR_WALLET });
212+
213+
// A resale at 100 XLM (1_000_000_000 stroops) should owe 10% = 10 XLM,
214+
// using the same BPS math the contract applies in transfer_with_royalty.
215+
const salePriceStroops = 1_000_000_000;
216+
const expectedRoyaltyStroops = (salePriceStroops * info.royaltyBps) / 10_000;
217+
expect(expectedRoyaltyStroops).toBe(100_000_000);
218+
});
219+
});
220+
221+
// ── 5. Failure scenarios ────────────────────────────────────────────────
222+
223+
describe('failure scenarios', () => {
224+
it('rejects a signature that does not match the signing wallet', () => {
225+
const signer = StellarSdk.Keypair.random();
226+
const impersonated = StellarSdk.Keypair.random();
227+
const challenge = buildMintChallenge(CLIP_ID, impersonated.publicKey());
228+
const signature = signer.sign(Buffer.from(challenge, 'utf8')).toString('hex');
229+
230+
expect(() =>
231+
signatureService.verify(CLIP_ID, impersonated.publicKey(), signature),
232+
).toThrow(UnauthorizedException);
233+
});
234+
235+
it('rejects a signature over a tampered clipId (replay across clips)', () => {
236+
const wallet = StellarSdk.Keypair.random();
237+
const signature = wallet
238+
.sign(Buffer.from(buildMintChallenge(CLIP_ID, wallet.publicKey()), 'utf8'))
239+
.toString('hex');
240+
241+
// Same signature, different clipId — must not verify.
242+
expect(() =>
243+
signatureService.verify(CLIP_ID + 1, wallet.publicKey(), signature),
244+
).toThrow(UnauthorizedException);
245+
});
246+
247+
it('rejects a malformed Stellar wallet address before touching crypto', () => {
248+
expect(() =>
249+
signatureService.verify(CLIP_ID, 'not-a-stellar-address', 'a'.repeat(128)),
250+
).toThrow(UnauthorizedException);
251+
});
252+
253+
it('fails metadata upload validation when royalty info is missing', async () => {
254+
const invalidMetadata = {
255+
name: 'Bad',
256+
description: 'Missing royalty block',
257+
image: 'https://cdn.example.com/thumb.jpg',
258+
animation_url: 'https://cdn.example.com/video.mp4',
259+
attributes: [],
260+
seller_fee_basis_points: ROYALTY_BPS,
261+
} as unknown as NftMetadata;
262+
263+
await expect(
264+
ipfsService.uploadMetadata(invalidMetadata, CLIP_ID),
265+
).rejects.toBeInstanceOf(BadRequestException);
266+
expect(global.fetch).not.toHaveBeenCalled();
267+
});
268+
269+
it('rejects minting when the platform wallet is not configured', async () => {
270+
const config = Object.assign(new NftConfig(), {
271+
creatorRoyaltyBps: ROYALTY_BPS,
272+
platformRoyaltyBps: 100,
273+
platformWallet: '',
274+
});
275+
const nftService = new NftService(config);
276+
277+
await expect(
278+
nftService.mintClip({ clipId: String(CLIP_ID), creatorWallet: CREATOR_WALLET }),
279+
).rejects.toBeInstanceOf(BadRequestException);
280+
});
281+
282+
it('rejects a batch mint over the 50-clip limit', async () => {
283+
const config = Object.assign(new NftConfig(), {
284+
creatorRoyaltyBps: ROYALTY_BPS,
285+
platformRoyaltyBps: 100,
286+
platformWallet: PLATFORM_WALLET,
287+
});
288+
const nftService = new NftService(config);
289+
290+
const clips = Array.from({ length: 51 }, (_, i) => ({ clipId: String(i + 1) }));
291+
292+
await expect(
293+
nftService.batchMintClips({ creatorWallet: CREATOR_WALLET, clips } as any),
294+
).rejects.toBeInstanceOf(BadRequestException);
295+
});
296+
297+
it('surfaces NotFoundException when the chain has no royalty data for a resale query', async () => {
298+
const royaltyQueryService = makeRoyaltyQueryServiceForFailure();
299+
300+
jest
301+
.spyOn(royaltyQueryService as any, 'queryOnChainRoyalty')
302+
.mockRejectedValue(new NotFoundException('Royalty data not found for mint address 999'));
303+
304+
await expect(royaltyQueryService.getRoyaltyInfo('999')).rejects.toBeInstanceOf(
305+
NotFoundException,
306+
);
307+
});
308+
309+
function makeRoyaltyQueryServiceForFailure() {
310+
const stellarService = {
311+
rpcUrl: 'https://soroban-testnet.stellar.org',
312+
networkPassphrase: 'Test SDF Network ; September 2015',
313+
};
314+
const redisService = { get: jest.fn().mockResolvedValue(null), setex: jest.fn() };
315+
const cb = { execute: jest.fn((_config: unknown, fn: () => unknown) => fn()) };
316+
return new RoyaltyQueryService(stellarService as any, redisService as any, cb as any);
317+
}
318+
});
319+
});

0 commit comments

Comments
 (0)