diff --git a/apps/frontend/src/services/http.service.test.tsx b/apps/frontend/src/services/http.service.test.tsx new file mode 100644 index 00000000..0f271235 --- /dev/null +++ b/apps/frontend/src/services/http.service.test.tsx @@ -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([]); + }); + }); +}); diff --git a/apps/frontend/src/services/http.service.ts b/apps/frontend/src/services/http.service.ts index 79d8878e..11868d7a 100644 --- a/apps/frontend/src/services/http.service.ts +++ b/apps/frontend/src/services/http.service.ts @@ -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, @@ -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) { diff --git a/apps/frontend/src/utilities/cln-sql.ts b/apps/frontend/src/utilities/cln-sql.ts index de0b26e2..dc2e5854 100644 --- a/apps/frontend/src/utilities/cln-sql.ts +++ b/apps/frontend/src/utilities/cln-sql.ts @@ -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 diff --git a/apps/frontend/src/utilities/test-utilities/mockUtilities.tsx b/apps/frontend/src/utilities/test-utilities/mockUtilities.tsx new file mode 100644 index 00000000..236c3da1 --- /dev/null +++ b/apps/frontend/src/utilities/test-utilities/mockUtilities.tsx @@ -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);