Skip to content

Commit fff75e3

Browse files
Release 26.01.1
2 parents 71b6134 + b8674ab commit fff75e3

File tree

4 files changed

+140
-5
lines changed

4 files changed

+140
-5
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { CLNService, HttpService } from './http.service';
2+
import { SCROLL_PAGE_SIZE } from '../utilities/constants';
3+
import { spyOnIsCompatibleVersion, spyOnListOffersSQL, spyOnListOffersSQLWithoutDesc } from '../utilities/test-utilities/mockUtilities';
4+
import { createMockStore } from '../utilities/test-utilities/mockStore';
5+
import { mockNodeInfo, mockRootStoreData } from '../utilities/test-utilities/mockData';
6+
7+
describe('CLNService', () => {
8+
beforeEach(() => {
9+
jest.clearAllMocks();
10+
});
11+
12+
describe('listOffers', () => {
13+
it('should use ListOffersSQL when version is compatible (>= 26.04)', async () => {
14+
const mockStore = createMockStore('/', {
15+
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '26.04' } },
16+
});
17+
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
18+
const listOffersSQLSpy = spyOnListOffersSQL('desc');
19+
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
20+
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({ rows: [] });
21+
await CLNService.listOffers(0, mockStore);
22+
23+
expect(isCompatibleVersionSpy).toHaveBeenCalledWith('26.04', '26.04');
24+
expect(listOffersSQLSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
25+
expect(listOffersSQLWithoutDescSpy).not.toHaveBeenCalled();
26+
});
27+
28+
it('should use ListOffersSQLWithoutDesc when version is not compatible (< 26.04)', async () => {
29+
const mockStore = createMockStore('/', {
30+
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '25.12' } },
31+
});
32+
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
33+
const listOffersSQLSpy = spyOnListOffersSQL('desc');
34+
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
35+
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({ rows: [] });
36+
37+
await CLNService.listOffers(0, mockStore);
38+
39+
expect(isCompatibleVersionSpy).toHaveBeenCalledWith('25.12', '26.04');
40+
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
41+
expect(listOffersSQLSpy).not.toHaveBeenCalled();
42+
});
43+
44+
it('should fallback to ListOffersSQLWithoutDesc when query fails with "no such column: description"', async () => {
45+
const mockStore = createMockStore('/', {
46+
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '26.04' } },
47+
});
48+
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
49+
const listOffersSQLSpy = spyOnListOffersSQL('desc');
50+
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
51+
52+
const clnCallSpy = jest
53+
.spyOn(HttpService, 'clnCall')
54+
// First call fails (new schema not supported)
55+
.mockRejectedValueOnce(new Error('no such column: description'))
56+
// Second call succeeds (fallback query)
57+
.mockResolvedValueOnce({ rows: [] });
58+
const result = await CLNService.listOffers(0, mockStore);
59+
60+
expect(clnCallSpy).toHaveBeenCalledTimes(2);
61+
expect(isCompatibleVersionSpy).toHaveBeenCalledWith('26.04', '26.04');
62+
expect(listOffersSQLSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
63+
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
64+
expect(result.offers).toEqual([]);
65+
});
66+
67+
it('should handle empty version gracefully', async () => {
68+
const mockStore = createMockStore('/', {
69+
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '' } },
70+
});
71+
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
72+
const listOffersSQLSpy = spyOnListOffersSQL('desc');
73+
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
74+
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({ rows: [{ offer_id: '456' }] });
75+
76+
const result = await CLNService.listOffers(0, mockStore);
77+
78+
expect(isCompatibleVersionSpy).toHaveBeenCalledWith('', '26.04');
79+
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
80+
expect(listOffersSQLSpy).not.toHaveBeenCalled();
81+
expect(result.offers).toBeDefined();
82+
});
83+
84+
it('should return empty offers array when rows is undefined', async () => {
85+
const mockStore = createMockStore('/', {
86+
root: { ...mockRootStoreData, nodeInfo: { ...mockNodeInfo, version: '25.12' } },
87+
});
88+
const isCompatibleVersionSpy = spyOnIsCompatibleVersion();
89+
const listOffersSQLSpy = spyOnListOffersSQL('desc');
90+
const listOffersSQLWithoutDescSpy = spyOnListOffersSQLWithoutDesc('no desc');
91+
jest.spyOn(HttpService, 'clnCall').mockResolvedValue({});
92+
const result = await CLNService.listOffers(0, mockStore);
93+
94+
expect(isCompatibleVersionSpy).toHaveBeenCalledWith('25.12', '26.04');
95+
expect(listOffersSQLWithoutDescSpy).toHaveBeenCalledWith(SCROLL_PAGE_SIZE, 0);
96+
expect(listOffersSQLSpy).not.toHaveBeenCalled();
97+
expect(result.offers).toEqual([]);
98+
});
99+
});
100+
});

apps/frontend/src/services/http.service.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import { defaultRootState } from '../store/rootSelectors';
99
import { AppState } from '../store/store.type';
1010
import { appStore } from '../store/appStore';
1111
import { AccountEventsAccount, SatsFlowEvent, VolumeRow } from '../types/bookkeeper.type';
12-
import { listBTCTransactionsSQL, listLightningTransactionsSQL, ListOffersSQL } from '../utilities/cln-sql';
12+
import { listBTCTransactionsSQL, listLightningTransactionsSQL, ListOffersSQL, ListOffersSQLWithoutDesc } from '../utilities/cln-sql';
1313
import { setConnectWallet, setListChannels, setListFunds, setNodeInfo } from '../store/rootSlice';
1414
import { setFeeRate, setListBitcoinTransactions, setListLightningTransactions, setListOffers } from '../store/clnSlice';
1515
import { setAccountEvents, setSatsFlow, setVolume } from '../store/bkprSlice';
16+
import { isCompatibleVersion } from '../utilities/data-formatters';
1617

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

290-
static async listOffers(offset: number) {
291-
const listOffersArr: any = await HttpService.clnCall('sql', { query: ListOffersSQL(SCROLL_PAGE_SIZE, offset) });
292-
return { offers: convertArrayToOffersObj(listOffersArr.rows ? listOffersArr.rows : []) };
291+
static async listOffers(offset: number, store = appStore) {
292+
const state = store.getState() as AppState;
293+
const nodeInfo = state.root.nodeInfo;
294+
const isCompatible = isCompatibleVersion(nodeInfo.version || '', '26.04');
295+
const primaryQuery = isCompatible ? ListOffersSQL(SCROLL_PAGE_SIZE, offset) : ListOffersSQLWithoutDesc(SCROLL_PAGE_SIZE, offset);
296+
try {
297+
const listOffersArr: any = await HttpService.clnCall('sql', { query: primaryQuery });
298+
return { offers: convertArrayToOffersObj(listOffersArr.rows ?? []) };
299+
} catch (err: any) {
300+
// Fallback ONLY for older nodes missing `description`
301+
if (isCompatible && typeof err?.message === 'string' && err.message.includes('no such column: description')) {
302+
const fallbackQuery = ListOffersSQLWithoutDesc(SCROLL_PAGE_SIZE, offset);
303+
const listOffersArr: any = await HttpService.clnCall('sql', { query: fallbackQuery });
304+
return { offers: convertArrayToOffersObj(listOffersArr.rows ?? []) };
305+
}
306+
throw err; // Throw all other errors
307+
}
293308
}
294309

295310
static async listBTCTransactions(offset: number) {

apps/frontend/src/utilities/cln-sql.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
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;";
22

3-
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;
3+
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;
4+
5+
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;
46

57
// We use a single-query approach instead of splitting these queries because it:
68
// - Keeps the code simpler and easier to reason about
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as DataFormatters from '../../utilities/data-formatters';
2+
import * as ClnSql from '../../utilities/cln-sql';
3+
4+
export const spyOnIsCompatibleVersion = () => {
5+
const realImpl = DataFormatters.isCompatibleVersion;
6+
7+
return jest
8+
.spyOn(DataFormatters, 'isCompatibleVersion')
9+
.mockImplementation((currentVersion: string, checkVersion: string) =>
10+
realImpl(currentVersion, checkVersion)
11+
);
12+
};
13+
14+
export const spyOnListOffersSQL = (returnValue = 'SQL_WITH_DESC') =>
15+
jest.spyOn(ClnSql, 'ListOffersSQL').mockReturnValue(returnValue);
16+
17+
export const spyOnListOffersSQLWithoutDesc = (returnValue = 'SQL_WITHOUT_DESC') =>
18+
jest.spyOn(ClnSql, 'ListOffersSQLWithoutDesc').mockReturnValue(returnValue);

0 commit comments

Comments
 (0)