Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b849161
exposes collectibles tab in send flow, adds collectible details to tr…
aristidesstaffieri Oct 9, 2025
35c7935
adds SendCollectibleReview with basic layout for sending a collectible
aristidesstaffieri Oct 14, 2025
e25b60d
adds transaction service method for building a send for collectibles,…
aristidesstaffieri Oct 14, 2025
58d3a24
adds collectible option type to tx details bottom sheet and processin…
aristidesstaffieri Oct 14, 2025
f1b4693
adds interface detection to distinguish between sep41 and sep 50 inte…
aristidesstaffieri Oct 14, 2025
76b5a96
adds SorobanCollectibleTransferTransactionDetailsContent and renders …
aristidesstaffieri Oct 15, 2025
3206be3
adds unit tests for new beahvior in transactionBuilder and settings
aristidesstaffieri Oct 15, 2025
d1dce6e
undo unrelated change to test mocks
aristidesstaffieri Oct 15, 2025
08aa57d
refactors all review screens for design update, icon on the left, no …
aristidesstaffieri Oct 15, 2025
c04b263
adds default token selection in transaction amount screen, adds dynam…
aristidesstaffieri Oct 15, 2025
2a83f44
adds send button to collectible detail view
aristidesstaffieri Oct 16, 2025
6768fb1
removes debug settings for collectibles
aristidesstaffieri Oct 16, 2025
588b646
fixes typo in js doc comment
aristidesstaffieri Oct 16, 2025
5388288
Merge remote-tracking branch 'origin' into feature/send-collectibles-ui
aristidesstaffieri Oct 16, 2025
384f731
tweaks padding for cta row in collectible details screen
aristidesstaffieri Oct 16, 2025
3fa4248
extracts common default collectible transfer details
aristidesstaffieri Oct 20, 2025
8a7404b
adds contractId to unknown collection default
aristidesstaffieri Oct 20, 2025
0c07a0c
extracts common transaction oepration type for result types, renames …
aristidesstaffieri Oct 20, 2025
8eeb993
makes collectionAddress not optional for collectible param list
aristidesstaffieri Oct 20, 2025
08943c6
uses correct length check for backend collection match
aristidesstaffieri Oct 20, 2025
073f2b5
updates TransactionOperationType to be a string enum, applies it in c…
aristidesstaffieri Oct 20, 2025
ad64b10
uses prepared transaction from simualation
aristidesstaffieri Oct 20, 2025
1fb119a
Update src/components/screens/SendScreen/screens/TransactionAmountScr…
aristidesstaffieri Oct 20, 2025
9b90028
Update src/components/screens/SendScreen/screens/TransactionProcessin…
aristidesstaffieri Oct 20, 2025
f911baf
Update src/components/screens/SendScreen/screens/TransactionProcessin…
aristidesstaffieri Oct 20, 2025
4361451
uses SendType enum in remaining components
aristidesstaffieri Oct 20, 2025
2f2f3ec
adds memo and xdr to tx history detail
aristidesstaffieri Oct 20, 2025
514d118
refactor nav flows in order to avoid circular nav loops and to clear …
aristidesstaffieri Oct 21, 2025
054ea8d
use popTo navigation in order to avoid search contact page in nav sta…
aristidesstaffieri Oct 22, 2025
d9fd966
simplifies popTo calls using navigation directly
aristidesstaffieri Oct 23, 2025
3d12b63
Merge branch 'main' into feature/send-collectibles-ui
aristidesstaffieri Oct 23, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -81,26 +81,35 @@ jest.mock("hooks/useGetActiveAccount", () => ({
}),
}));

const mockGetCollectible = jest.fn(() => ({
name: "Test Collectible",
collectionName: "Test Collection",
tokenId: "123",
image: "https://example.com/image.jpg",
description: "Test description",
traits: [
{ name: "Color", value: "Blue" },
{ name: "Rarity", value: "Common" },
],
externalUrl: "https://example.com",
}));

// Mock the useCollectiblesStore hook
jest.mock("ducks/collectibles", () => ({
useCollectiblesStore: () => ({
fetchCollectibles: jest.fn(),
getCollectible: jest.fn(() => ({
name: "Test Collectible",
collectionName: "Test Collection",
tokenId: "123",
image: "https://example.com/image.jpg",
description: "Test description",
traits: [
{ name: "Color", value: "Blue" },
{ name: "Rarity", value: "Common" },
],
externalUrl: "https://example.com",
})),
getCollectible: mockGetCollectible,
isLoading: false,
}),
}));

const mockSaveSelectedCollectibleDetails = jest.fn();
jest.mock("ducks/transactionSettings", () => ({
useTransactionSettingsStore: () => ({
saveSelectedCollectibleDetails: mockSaveSelectedCollectibleDetails,
}),
}));

// Mock the getStellarExpertUrl helper
jest.mock("helpers/stellarExpert", () => ({
getStellarExpertUrl: jest.fn(() => "https://testnet.stellar.expert"),
Expand Down Expand Up @@ -199,4 +208,81 @@ describe("CollectibleDetailsScreen", () => {
headerTitle: "Test Collectible",
});
});

it("renders the Send button", () => {
const { getByText } = renderWithProviders(
<CollectibleDetailsScreen
route={mockRoute as any}
navigation={mockNavigationObject as any}
/>,
);

const sendButton = getByText("tokenDetailsScreen.send");
expect(sendButton).toBeTruthy();
});

it("navigates to send flow when Send button is pressed", () => {
mockNavigationObject.navigate.mockClear();
mockSaveSelectedCollectibleDetails.mockClear();

const collectibleData = mockGetCollectible();

const { getByText } = renderWithProviders(
<CollectibleDetailsScreen
route={mockRoute as any}
navigation={mockNavigationObject as any}
/>,
);

const sendButton = getByText("tokenDetailsScreen.send");
fireEvent.press(sendButton);

expect(mockSaveSelectedCollectibleDetails).toHaveBeenCalledWith(
collectibleData,
);

expect(mockNavigationObject.navigate).toHaveBeenCalledWith(
"SendPaymentStack",
{
screen: "SendSearchContactsScreen",
},
);
});

it("renders View and Send buttons in a row when externalUrl exists", () => {
const { getByText } = renderWithProviders(
<CollectibleDetailsScreen
route={mockRoute as any}
navigation={mockNavigationObject as any}
/>,
);

expect(getByText("collectibleDetails.view")).toBeTruthy();
expect(getByText("tokenDetailsScreen.send")).toBeTruthy();
});

it("renders only Send button when externalUrl is not present", () => {
mockGetCollectible.mockReturnValueOnce({
name: "Test Collectible",
collectionName: "Test Collection",
tokenId: "123",
image: "https://example.com/image.jpg",
description: "Test description",
traits: [
{ name: "Color", value: "Blue" },
{ name: "Rarity", value: "Common" },
],
externalUrl: "",
});

const { getByText, queryByText } = renderWithProviders(
<CollectibleDetailsScreen
route={mockRoute as any}
navigation={mockNavigationObject as any}
/>,
);

expect(getByText("tokenDetailsScreen.send")).toBeTruthy();
expect(queryByText("collectibleDetails.view")).toBeNull();
});
});
149 changes: 149 additions & 0 deletions __tests__/ducks/transactionBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,153 @@ describe("transactionBuilder Duck", () => {
expect(state.transactionHash).toBeNull();
expect(state.error).toBeNull();
});

describe("buildSendCollectibleTransaction", () => {
const mockCollectionAddress =
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
const mockTokenId = 12345;

beforeEach(() => {
(
transactionService.buildSendCollectibleTransaction as jest.Mock
).mockResolvedValue({
xdr: mockBuiltXDR,
tx: { toXDR: () => mockBuiltXDR, sequence: "1" },
});
(
transactionService.simulateCollectibleTransfer as jest.Mock
).mockResolvedValue(mockPreparedXDR);
});

it("should build and simulate a collectible transaction successfully", async () => {
await act(async () => {
await store.getState().buildSendCollectibleTransaction({
collectionAddress: mockCollectionAddress,
destinationAccount: mockRecipientAddress,
tokenId: mockTokenId,
transactionFee: "0.001",
transactionTimeout: 300,
network: mockNetwork,
senderAddress: mockPublicKey,
});
});

const state = store.getState();
expect(state.isBuilding).toBe(false);
expect(state.transactionXDR).toBe(mockPreparedXDR);
expect(state.signedTransactionXDR).toBeNull();
expect(state.transactionHash).toBeNull();
expect(state.error).toBeNull();
expect(
transactionService.buildSendCollectibleTransaction,
).toHaveBeenCalledWith(
expect.objectContaining({
collectionAddress: mockCollectionAddress,
recipientAddress: mockRecipientAddress,
tokenId: mockTokenId,
transactionFee: "0.001",
transactionTimeout: 300,
network: mockNetwork,
senderAddress: mockPublicKey,
}),
);
expect(transactionService.simulateCollectibleTransfer).toHaveBeenCalled();
});

it("should build collectible transaction with memo", async () => {
const memo = "Sending collectible";

await act(async () => {
await store.getState().buildSendCollectibleTransaction({
collectionAddress: mockCollectionAddress,
destinationAccount: mockRecipientAddress,
tokenId: mockTokenId,
transactionFee: "0.001",
transactionTimeout: 300,
transactionMemo: memo,
network: mockNetwork,
senderAddress: mockPublicKey,
});
});

expect(
transactionService.buildSendCollectibleTransaction,
).toHaveBeenCalledWith(
expect.objectContaining({
transactionMemo: memo,
}),
);
});

it("should handle errors during buildSendCollectibleTransaction", async () => {
const buildError = new Error("Failed to build collectible transaction");
(
transactionService.buildSendCollectibleTransaction as jest.Mock
).mockRejectedValue(buildError);

await act(async () => {
await store.getState().buildSendCollectibleTransaction({
collectionAddress: mockCollectionAddress,
destinationAccount: mockRecipientAddress,
tokenId: mockTokenId,
transactionFee: "0.001",
transactionTimeout: 300,
network: mockNetwork,
senderAddress: mockPublicKey,
});
});

const state = store.getState();
expect(state.isBuilding).toBe(false);
expect(state.transactionXDR).toBeNull();
expect(state.error).toBe(buildError.message);
});

it("should handle errors during simulateCollectibleTransfer", async () => {
const simulateError = new Error(
"Failed to simulate collectible transfer",
);
(
transactionService.simulateCollectibleTransfer as jest.Mock
).mockRejectedValue(simulateError);

await act(async () => {
await store.getState().buildSendCollectibleTransaction({
collectionAddress: mockCollectionAddress,
destinationAccount: mockRecipientAddress,
tokenId: mockTokenId,
transactionFee: "0.001",
transactionTimeout: 300,
network: mockNetwork,
senderAddress: mockPublicKey,
});
});

const state = store.getState();
expect(state.isBuilding).toBe(false);
expect(state.transactionXDR).toBeNull();
expect(state.error).toBe(simulateError.message);
});

it("should return the prepared XDR from simulation", async () => {
let result: string | null = null;
await act(async () => {
result = await store.getState().buildSendCollectibleTransaction({
collectionAddress: mockCollectionAddress,
destinationAccount: mockRecipientAddress,
tokenId: mockTokenId,
transactionFee: "0.001",
transactionTimeout: 300,
network: mockNetwork,
senderAddress: mockPublicKey,
});
});

expect(result).toBe(mockPreparedXDR);
expect(
transactionService.buildSendCollectibleTransaction,
).toHaveBeenCalled();
expect(transactionService.simulateCollectibleTransfer).toHaveBeenCalled();
});
});
});
95 changes: 95 additions & 0 deletions __tests__/ducks/transactionSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,99 @@ describe("transactionSettings Duck", () => {
expect(store.getState().recipientAddress).toBe("");
expect(store.getState().selectedTokenId).toBe("");
});

describe("selectedCollectibleDetails", () => {
it("should have correct initial collectible details state", () => {
const initialState = store.getState();
expect(initialState.selectedCollectibleDetails).toEqual({
collectionAddress: "",
tokenId: "",
});
});

it("should save collectible details", () => {
const collectibleDetails = {
collectionAddress:
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
tokenId: "12345",
};

act(() => {
store.getState().saveSelectedCollectibleDetails(collectibleDetails);
});

expect(store.getState().selectedCollectibleDetails).toEqual(
collectibleDetails,
);
});

it("should update collectible details when changed", () => {
const firstCollectible = {
collectionAddress:
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
tokenId: "100",
};
const secondCollectible = {
collectionAddress:
"CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
tokenId: "999",
};

act(() => {
store.getState().saveSelectedCollectibleDetails(firstCollectible);
});
expect(store.getState().selectedCollectibleDetails).toEqual(
firstCollectible,
);

act(() => {
store.getState().saveSelectedCollectibleDetails(secondCollectible);
});
expect(store.getState().selectedCollectibleDetails).toEqual(
secondCollectible,
);
});

it("should reset collectible details when resetSettings is called", () => {
const collectibleDetails = {
collectionAddress:
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
tokenId: "12345",
};

act(() => {
store.getState().saveSelectedCollectibleDetails(collectibleDetails);
});

expect(store.getState().selectedCollectibleDetails).toEqual(
collectibleDetails,
);

act(() => {
store.getState().resetSettings();
});

expect(store.getState().selectedCollectibleDetails).toEqual({
collectionAddress: "",
tokenId: "",
});
});

it("should handle empty string values for collectible details", () => {
const emptyCollectibleDetails = {
collectionAddress: "",
tokenId: "",
};

act(() => {
store
.getState()
.saveSelectedCollectibleDetails(emptyCollectibleDetails);
});

expect(store.getState().selectedCollectibleDetails).toEqual(
emptyCollectibleDetails,
);
});
});
});
7 changes: 2 additions & 5 deletions __tests__/helpers/collectibles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,11 +613,8 @@ describe("collectibles helpers", () => {
});

it("throws error when transformation fails", async () => {
const invalidCollections = null as any;

await expect(
transformBackendCollections(invalidCollections),
).rejects.toThrow();
// @ts-expect-error Testing invalid input type
await expect(transformBackendCollections(null)).rejects.toThrow();

expect(logger.error).toHaveBeenCalledWith(
"transformBackendCollections",
Expand Down
Loading