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
100 changes: 100 additions & 0 deletions apps/frontend/src/services/http.service.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { CLNService, HttpService } from './http.service';
import { SCROLL_PAGE_SIZE } from '../utilities/constants';
import { spyOnIsCompatibleVersion, spyOnListOffersSQL, spyOnListOffersSQLWithoutDesc } from '../utilities/test-utilities/mockUtilities';
import { createMockStore } from '../utilities/test-utilities/mockStore';
import { mockNodeInfo, mockRootStoreData } from '../utilities/test-utilities/mockData';

describe('CLNService', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('listOffers', () => {
it('should use ListOffersSQL when version is compatible (>= 26.04)', async () => {
const mockStore = createMockStore('/', {
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '26.04' } },
});
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
const listOffersSQLSpy = spyOnListOffersSQL('desc');
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({ rows: [] });
await CLNService.listOffers(0, mockStore);

expect(isCompatibleVersionSpy).toHaveBeenCalledWith('26.04', '26.04');
expect(listOffersSQLSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
expect(listOffersSQLWithoutDescSpy).not.toHaveBeenCalled();
});

it('should use ListOffersSQLWithoutDesc when version is not compatible (< 26.04)', async () => {
const mockStore = createMockStore('/', {
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '25.12' } },
});
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
const listOffersSQLSpy = spyOnListOffersSQL('desc');
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({ rows: [] });

await CLNService.listOffers(0, mockStore);

expect(isCompatibleVersionSpy).toHaveBeenCalledWith('25.12', '26.04');
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
expect(listOffersSQLSpy).not.toHaveBeenCalled();
});

it('should fallback to ListOffersSQLWithoutDesc when query fails with "no such column: description"', async () => {
const mockStore = createMockStore('/', {
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '26.04' } },
});
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
const listOffersSQLSpy = spyOnListOffersSQL('desc');
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');

const clnCallSpy = jest
.spyOn(HttpService, 'clnCall')
// First call fails (new schema not supported)
.mockRejectedValueOnce(new Error('no such column: description'))
// Second call succeeds (fallback query)
.mockResolvedValueOnce({ rows: [] });
const result = await CLNService.listOffers(0, mockStore);

expect(clnCallSpy).toHaveBeenCalledTimes(2);
expect(isCompatibleVersionSpy).toHaveBeenCalledWith('26.04', '26.04');
expect(listOffersSQLSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
expect(result.offers).toEqual([]);
});

it('should handle empty version gracefully', async () => {
const mockStore = createMockStore('/', {
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '' } },
});
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
const listOffersSQLSpy = spyOnListOffersSQL('desc');
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({ rows: [{ offer_id: '456' }] });

const result = await CLNService.listOffers(0, mockStore);

expect(isCompatibleVersionSpy).toHaveBeenCalledWith('', '26.04');
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
expect(listOffersSQLSpy).not.toHaveBeenCalled();
expect(result.offers).toBeDefined();
});

it('should return empty offers array when rows is undefined', async () => {
const mockStore = createMockStore('/', {
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '25.12' } },
});
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
const listOffersSQLSpy = spyOnListOffersSQL('desc');
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({});
const result = await CLNService.listOffers(0, mockStore);

expect(isCompatibleVersionSpy).toHaveBeenCalledWith('25.12', '26.04');
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
expect(listOffersSQLSpy).not.toHaveBeenCalled();
expect(result.offers).toEqual([]);
});
});
});
23 changes: 19 additions & 4 deletions apps/frontend/src/services/http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { defaultRootState } from '../store/rootSelectors';
import { AppState } from '../store/store.type';
import { appStore } from '../store/appStore';
import { AccountEventsAccount, SatsFlowEvent, VolumeRow } from '../types/bookkeeper.type';
import { listBTCTransactionsSQL, listLightningTransactionsSQL, ListOffersSQL } from '../utilities/cln-sql';
import { listBTCTransactionsSQL, listLightningTransactionsSQL, ListOffersSQL, ListOffersSQLWithoutDesc } from '../utilities/cln-sql';
import { setConnectWallet, setListChannels, setListFunds, setNodeInfo } from '../store/rootSlice';
import { setFeeRate, setListBitcoinTransactions, setListLightningTransactions, setListOffers } from '../store/clnSlice';
import { setAccountEvents, setSatsFlow, setVolume } from '../store/bkprSlice';
import { isCompatibleVersion } from '../utilities/data-formatters';

const axiosInstance = axios.create({
baseURL: API_BASE_URL + API_VERSION,
Expand Down Expand Up @@ -287,9 +288,23 @@ export class CLNService {
return { clnTransactions: convertArrayToLightningTransactionsObj(listCLNTransactionsArr.rows ? listCLNTransactionsArr.rows : []) };
}

static async listOffers(offset: number) {
const listOffersArr: any = await HttpService.clnCall('sql', { query: ListOffersSQL(SCROLL_PAGE_SIZE, offset) });
return { offers: convertArrayToOffersObj(listOffersArr.rows ? listOffersArr.rows : []) };
static async listOffers(offset: number, store = appStore) {
const state = store.getState() as AppState;
const nodeInfo = state.root.nodeInfo;
const isCompatible = isCompatibleVersion(nodeInfo.version || '', '26.04');
const primaryQuery = isCompatible ? ListOffersSQL(SCROLL_PAGE_SIZE, offset) : ListOffersSQLWithoutDesc(SCROLL_PAGE_SIZE, offset);
try {
const listOffersArr: any = await HttpService.clnCall('sql', { query: primaryQuery });
return { offers: convertArrayToOffersObj(listOffersArr.rows ?? []) };
} catch (err: any) {
// Fallback ONLY for older nodes missing `description`
if (isCompatible && typeof err?.message === 'string' && err.message.includes('no such column: description')) {
const fallbackQuery = ListOffersSQLWithoutDesc(SCROLL_PAGE_SIZE, offset);
const listOffersArr: any = await HttpService.clnCall('sql', { query: fallbackQuery });
return { offers: convertArrayToOffersObj(listOffersArr.rows ?? []) };
}
throw err; // Throw all other errors
}
}

static async listBTCTransactions(offset: number) {
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend/src/utilities/cln-sql.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const ListPeerChannelsSQL = "SELECT n.alias as node_alias, pc.peer_id, pc.channel_id, pc.short_channel_id, pc.state, pc.peer_connected, pc.to_us_msat, pc.total_msat, pc.their_to_self_delay, pc.opener, pc.private, pc.dust_limit_msat, pc.spendable_msat, pc.receivable_msat, pc.funding_txid FROM peerchannels pc LEFT JOIN nodes n ON pc.peer_id = n.nodeid;";

export const ListOffersSQL = (limit, offset) => "SELECT offer_id, active, single_use, bolt12, used, label, COALESCE(description, NULL) as description FROM offers ORDER BY offer_id LIMIT " + limit + " OFFSET " + offset;
export const ListOffersSQL = (limit, offset) => "SELECT offer_id, active, single_use, bolt12, used, label, COALESCE(description, '') as description FROM offers ORDER BY offer_id LIMIT " + limit + " OFFSET " + offset;

export const ListOffersSQLWithoutDesc = (limit, offset) => "SELECT offer_id, active, single_use, bolt12, used, label, '' as description FROM offers ORDER BY offer_id LIMIT " + limit + " OFFSET " + offset;

// We use a single-query approach instead of splitting these queries because it:
// - Keeps the code simpler and easier to reason about
Expand Down
18 changes: 18 additions & 0 deletions apps/frontend/src/utilities/test-utilities/mockUtilities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as DataFormatters from '../../utilities/data-formatters';
import * as ClnSql from '../../utilities/cln-sql';

export const spyOnIsCompatibleVersion = () => {
const realImpl = DataFormatters.isCompatibleVersion;

return jest
.spyOn(DataFormatters, 'isCompatibleVersion')
.mockImplementation((currentVersion: string, checkVersion: string) =>
realImpl(currentVersion, checkVersion)
);
};

export const spyOnListOffersSQL = (returnValue = 'SQL_WITH_DESC') =>
jest.spyOn(ClnSql, 'ListOffersSQL').mockReturnValue(returnValue);

export const spyOnListOffersSQLWithoutDesc = (returnValue = 'SQL_WITHOUT_DESC') =>
jest.spyOn(ClnSql, 'ListOffersSQLWithoutDesc').mockReturnValue(returnValue);