Compare commits

...
Author SHA1 Message Date
Vlad Stan 005f384c82 test: clicks 2026-07-16 10:40:28 +03:00
Vlad Stan 912c0cb1fa refactor: split 2026-07-16 07:13:39 +03:00
Vlad Stan ad90e95b10 feat: nicer 2026-07-15 22:54:52 +03:00
Vlad Stan 9961c76caa feat: first migration 2026-07-15 21:49:42 +03:00
22 changed files with 3279 additions and 69 deletions
+3 -3
View File
@@ -85,15 +85,15 @@ jobs:
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jmeter:
integration:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.12"]
uses: ./.github/workflows/jmeter.yml
uses: ./.github/workflows/integration.yml
with:
python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, integration ]
uses: ./.github/workflows/bundle.yml
+45
View File
@@ -0,0 +1,45 @@
name: Extension Integration Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
jobs:
integration:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
npm: "true"
- name: run Playwright integration tests
env:
EXTENSIONS_MANIFEST_URL: "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly,satspay,tipjar,tpos,lnurlp,withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
AUTH_HTTPS_ONLY: false
run: npm run test:integration
- name: print lnbits log
if: ${{ always() }}
run: |
sleep 1
cat tests/data/integration/logs/debug.log || true
- name: upload integration test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: extension-integration-test-results
path: |
playwright-report/integration/
test-results/
tests/data/integration/logs/
-65
View File
@@ -1,65 +0,0 @@
name: JMeter Extension Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
jobs:
jmeter:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
AUTH_HTTPS_ONLY: false
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
uv run lnbits &
sleep 10
- name: setup java version
run: |
update-java-alternatives --list
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
java -version
- name: clone lnbits-extensions, install jmeter and run tests
env:
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
mkdir logs
mkdir reports
make install-jmeter
make start-mirror-server
make test
- name: print lnbits log
if: ${{ always() }}
run: |
# catch up time for lnbits
sleep 1
cat data/logs/debug.log
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: jmeter-extension-test-results
path: |
lnbits-extensions/reports/
lnbits-extensions/logs/
+2
View File
@@ -18,6 +18,8 @@ __pycache__
.webassets-cache
htmlcov
test-reports
playwright-report
test-results
tests/data/*.sqlite3
*.swo
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: test
.PHONY: test test-integration
all: format check
@@ -69,6 +69,9 @@ test-regtest:
rm -rf ./tests/data \
uv run pytest tests/regtest
test-integration:
npm run test:integration
test-migration:
LNBITS_ADMIN_UI=True \
make test-api
+64
View File
@@ -21,6 +21,7 @@
"vuex": "4.1.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
@@ -576,6 +577,22 @@
"node": ">=0.10"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
@@ -1754,6 +1771,53 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+2
View File
@@ -1,6 +1,7 @@
{
"name": "lnbits",
"scripts": {
"test:integration": "playwright test --config=playwright.integration.config.js",
"sass": "./node_modules/.bin/sass ./lnbits/static/scss/base.scss > ./lnbits/static/css/base.css",
"vendor_copy": "node -e \"require('./package.json').vendor.forEach((file) => require('fs').copyFileSync(file, './lnbits/static/vendor/'+file.split('/').pop()))\"",
"vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"",
@@ -13,6 +14,7 @@
"bundle": "npm run sass && npm run vendor_copy && npm run vendor_json && npm run vendor_bundle_css && npm run vendor_bundle_js && npm run vendor_bundle_components && npm run vendor_minify_css && npm run vendor_minify_js && npm run vendor_minify_components"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
+31
View File
@@ -0,0 +1,31 @@
const {defineConfig} = require('@playwright/test')
const port = process.env.PORT || '5000'
const baseURL = process.env.LNBITS_BASE_URL || `http://127.0.0.1:${port}`
module.exports = defineConfig({
testDir: './tests/integration',
testMatch: '**/*.spec.js',
timeout: 120_000,
globalTimeout: 60 * 60 * 1000,
fullyParallel: false,
workers: 1,
reporter: [
['list'],
['html', {outputFolder: 'playwright-report/integration', open: 'never'}]
],
use: {
baseURL,
extraHTTPHeaders: {
Accept: 'application/json, text/plain, */*'
}
},
webServer: process.env.LNBITS_SKIP_WEB_SERVER
? undefined
: {
command: 'node tests/integration/server.cjs',
url: baseURL,
timeout: 180_000,
reuseExistingServer: !process.env.CI
}
})
@@ -0,0 +1,60 @@
const {
test,
expect,
setupIntegration,
state,
pageStatus,
uninstallExtension,
getAdminSession,
installLatestExtensionViaUi,
enableExtensionViaUi,
disableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('001 extensions can be installed, enabled, disabled, and re-enabled', async ({
browser
}) => {
test.setTimeout(45 * 60 * 1000)
const admin = await getAdminSession(browser)
const excluded = new Set([
'discordbot',
'usermanager',
'lnurldevice',
'scheduler',
'deezy',
'nostrrelay',
'tpos',
'webpages'
])
const extensionIds = [...new Set([...state.extensionById.keys()])]
.filter(extension => !excluded.has(extension))
.sort()
expect(extensionIds.length).toBeGreaterThan(0)
for (const extension of extensionIds) {
await test.step(`uninstall ${extension}`, async () => {
await uninstallExtension(state.adminContext, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
})
}
for (const extension of extensionIds) {
await test.step(`install and toggle ${extension}`, async () => {
const installed = await installLatestExtensionViaUi(admin, extension)
if (!installed) {
return
}
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await enableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await disableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await enableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
})
}
})
+150
View File
@@ -0,0 +1,150 @@
const {
test,
expect,
setupIntegration,
apiKeyHeaders,
clearMirror,
createWallet,
getPayment,
jsonRequest,
lnurlScan,
pageStatus,
pollPayment,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payLnurlViaUi,
withdrawLnurlViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createPayLink,
createWithdrawLink
} = require('./scenario-helpers')
setupIntegration()
test('002 lnurlp and withdraw scenarios', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
await clearMirror()
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lnurlp', 'withdraw'])
const user = await newUserWithUi(browser, 'lnurl')
await enableExtensionViaUi(user, 'lnurlp')
await pageStatus(user.context, '/lnurlp/')
const initialPayLinks = await jsonRequest(
user.context,
'get',
'/lnurlp/api/v1/links',
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(initialPayLinks).toEqual([])
let userBalanceSats = 0
for (const index of [1, 2, 3]) {
const payLink = await createPayLink(user.context, user, index)
await pageStatus(user.context, `/lnurlp/link/${payLink.id}`)
const fetched = await jsonRequest(
user.context,
'get',
`/lnurlp/api/v1/links/${payLink.id}`,
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(fetched.description).toBe(`receive payments ${index}`)
const lnurlResponse = await lnurlScan(
user.context,
fetched.lnurl,
user.adminkey
)
expect(lnurlResponse.tag).toBe('payRequest')
expect(lnurlResponse.commentAllowed).toBe(128)
for (let paymentIndex = 0; paymentIndex < 2; paymentIndex++) {
const payment = await payLnurlViaUi(
admin,
fetched.lnurl,
(10 + index) * 1000,
`receive payments ${index}`
)
expect(payment.payment_hash).toBeTruthy()
userBalanceSats += 10 + index
await pollWalletBalance(user.context, user.inkey, userBalanceSats * 1000)
const stored = await pollPayment(
user.context,
user.inkey,
payment.payment_hash,
result => Boolean(result?.details?.extra?.wh_response)
)
expect(stored.details.extra.wh_response).toContain('"b2":2')
expect(stored.details.extra.wh_response).toContain(payment.payment_hash)
}
}
await enableExtensionViaUi(user, 'withdraw')
await pageStatus(user.context, '/withdraw/')
const initialWithdrawLinks = await jsonRequest(
user.context,
'get',
'/withdraw/api/v1/links',
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(initialWithdrawLinks.data ?? initialWithdrawLinks).toEqual([])
const receiveWallet = await createWallet(user.context, 'receive wallet')
for (const index of [1, 2, 3]) {
const withdrawLink = await createWithdrawLink(user.context, user, index)
await pageStatus(user.context, `/withdraw/${withdrawLink.id}`)
const fetched = await jsonRequest(
user.context,
'get',
`/withdraw/api/v1/links/${withdrawLink.id}`,
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(fetched.title).toBe(`withdraw ${index}`)
const lnurlResponse = await lnurlScan(
user.context,
fetched.lnurl,
user.adminkey
)
expect(lnurlResponse.tag).toBe('withdrawRequest')
await user.page.waitForTimeout(2100)
for (let withdrawIndex = 0; withdrawIndex < 2; withdrawIndex++) {
const withdrawal = await withdrawLnurlViaUi(
user,
fetched.lnurl,
10,
`withdraw ${index}`,
receiveWallet.walletId
)
expect(withdrawal.payment_hash).toBeTruthy()
userBalanceSats -= 10
await pollWalletBalance(user.context, user.inkey, userBalanceSats * 1000)
await getPayment(
user.context,
receiveWallet.inkey,
withdrawal.payment_hash
)
if (withdrawIndex === 0) {
await user.page.waitForTimeout(1100)
}
}
}
await pollWalletBalance(user.context, receiveWallet.inkey, 60_000)
})
+172
View File
@@ -0,0 +1,172 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
createWallet,
getWallet,
jsonRequest,
pageStatus,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
withdrawLnurl
} = require('./scenario-helpers')
setupIntegration()
test('003 tpos payments, tips, and ATM withdraw', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['tpos'])
const user = await newUserWithUi(browser, 'tpos')
await enableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos/')
const tposs = await jsonRequest(user.context, 'get', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
})
expect(tposs).toEqual([])
const tipsWallet = await createWallet(user.context, 'tips wallet')
expect(tipsWallet.userId).toBe(user.userId)
const tpos = await jsonRequest(user.context, 'post', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.adminkey),
data: {name: 'Test', wallet: user.walletId, currency: 'USD'},
expected: 201
})
expect(tpos.wallet).toBe(user.walletId)
const updated = await jsonRequest(
user.context,
'put',
`/tpos/api/v1/tposs/${tpos.id}`,
{
headers: apiKeyHeaders(user.adminkey),
data: {
name: 'Test',
wallet: user.walletId,
currency: 'USD',
tip_options: '[2, 5]',
tip_wallet: tipsWallet.walletId
}
}
)
expect(updated.tip_wallet).toBe(tipsWallet.walletId)
const atm = await jsonRequest(user.context, 'post', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.adminkey),
data: {
withdraw_between: 1,
name: 'Test ATM',
wallet: user.walletId,
currency: 'EUR',
withdraw_limit: 100000
},
expected: 201
})
expect(atm.wallet).toBe(user.walletId)
await pageStatus(user.context, `/tpos/${tpos.id}`)
await jsonRequest(user.context, 'get', '/api/v1/rate/EUR')
let expectedMerchantBalance = 0
for (const amount of [21, 42]) {
const invoice = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/tposs/${tpos.id}/invoices`,
{
headers: apiKeyHeaders(user.inkey),
data: {
amount,
memo: 'TPoS test',
...(amount === 42 ? {tipAmount: 3} : {})
},
expected: 201
}
)
expect(invoice.bolt11).toBeTruthy()
await payInvoiceViaUi(admin, invoice.bolt11)
await jsonRequest(
user.context,
'get',
`/tpos/api/v1/tposs/${tpos.id}/invoices/${invoice.payment_hash}`,
{headers: apiKeyHeaders(user.inkey)}
)
expectedMerchantBalance += amount
await pollWalletBalance(
user.context,
user.inkey,
expectedMerchantBalance * 1000
)
}
await pageStatus(user.context, `/tpos/${atm.id}`)
await jsonRequest(user.context, 'get', '/api/v1/rate/EUR')
const atmInvoice = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/tposs/${atm.id}/invoices`,
{
headers: apiKeyHeaders(user.inkey),
data: {amount: 11000, memo: 'TPoS test'},
expected: 201
}
)
await payInvoiceViaUi(admin, atmInvoice.bolt11)
const beforeAdmin = await getWallet(
state.adminContext,
state.adminWallet.inkey
)
const beforeUser = await getWallet(user.context, user.inkey)
const atmPin = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/atm/${atm.id}/create`,
{
headers: apiKeyHeaders(user.adminkey)
}
)
expect(atmPin.id).toBeTruthy()
const withdraw = await jsonRequest(
user.context,
'get',
`/tpos/api/v1/atm/withdraw/${atmPin.id}/100`,
{
headers: apiKeyHeaders(state.adminWallet.adminkey)
}
)
const withdrawResponse = await jsonRequest(
user.context,
'get',
`/tpos/api/v1/lnurl/${withdraw.id}/100`,
{headers: apiKeyHeaders(state.adminWallet.adminkey)}
)
const payment = await withdrawLnurl(
user.context,
state.adminWallet,
withdrawResponse,
100,
'TPoS withdraw'
)
expect(payment.payment_hash).toBeTruthy()
await pollWalletBalance(
state.adminContext,
state.adminWallet.inkey,
beforeAdmin.balance + 100_000
)
await pollWalletBalance(
user.context,
user.inkey,
beforeUser.balance - 100_000
)
})
@@ -0,0 +1,58 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
latestRelease,
pageStatus,
topUpWallet,
newUserWithUi,
getAdminSession,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
disableExtensionViaUi,
payToEnableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('004 tpos pay-to-enable flow', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['tpos'])
const user = await newUserWithUi(browser, 'pay-to-enable')
await topUpWallet(state.adminContext, user.walletId, 1000)
const release = await latestRelease(state.adminContext, 'tpos')
expect(release.version).toBeTruthy()
await enableExtensionViaUi(admin, 'tpos')
await jsonRequest(state.adminContext, 'put', '/api/v1/extension/tpos/sell', {
data: {required: true, amount: 21, wallet: state.adminWallet.walletId}
})
await pageStatus(state.adminContext, '/tpos')
await pageStatus(user.context, '/tpos', [200, 402, 403])
const lowInvoice = await jsonRequest(
user.context,
'put',
'/api/v1/extension/tpos/invoice/enable',
{data: {amount: 1}, expected: 400}
)
expect(JSON.stringify(lowInvoice)).toContain('21')
await jsonRequest(user.context, 'put', '/api/v1/extension/tpos/enable', {
expected: 402
})
await payToEnableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos')
await disableExtensionViaUi(user, 'tpos')
await enableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos')
await jsonRequest(state.adminContext, 'put', '/api/v1/extension/tpos/sell', {
data: {required: false, amount: 0, wallet: state.adminWallet.walletId}
})
})
+78
View File
@@ -0,0 +1,78 @@
const {
test,
expect,
setupIntegration,
createWallet,
getPayments,
jsonRequest,
newUserWithUi,
getAdminSession,
payLnurlViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createPayLink,
createWithdrawLink,
withdrawLnurl
} = require('./scenario-helpers')
setupIntegration()
test('005 lnurlw race limits successful withdrawals', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lnurlp', 'withdraw'])
const user = await newUserWithUi(browser, 'lnurl-race')
await enableExtensionViaUi(user, 'lnurlp')
await enableExtensionViaUi(user, 'withdraw')
const payLink = await createPayLink(user.context, user, 1, {
description: 'receive payments',
min: 1,
max: 100_000_000,
webhook_url: undefined,
webhook_headers: undefined,
webhook_body: undefined
})
const payResponse = await jsonRequest(
user.context,
'get',
`/lnurlp/${payLink.id}`
)
expect(payResponse.tag).toBe('payRequest')
await payLnurlViaUi(admin, payLink.lnurl, 10_000_000, 'receive payments')
const receiveWallet = await createWallet(user.context, 'race receive wallet')
const withdrawLink = await createWithdrawLink(user.context, user, 1, {
uses: 2,
max_withdrawable: 10
})
const withdrawResponse = await jsonRequest(
user.context,
'get',
`/withdraw/api/v1/lnurl/${withdrawLink.unique_hash}`
)
// The race assertion is intentionally API-driven: it needs concurrent calls,
// not serialized browser clicks.
const attempts = Array.from({length: 100}, () =>
withdrawLnurl(
user.context,
receiveWallet,
withdrawResponse,
10,
'withdraw 1'
)
)
await Promise.all(attempts)
const payments = await getPayments(user.context, receiveWallet.inkey, {
limit: 100
})
expect(payments).toHaveLength(100)
const successCount = payments.filter(
payment => payment.status === 'success'
).length
expect(successCount).toBeGreaterThan(0)
expect(successCount).toBeLessThanOrEqual(2)
})
@@ -0,0 +1,233 @@
const {
test,
expect,
setupIntegration,
apiKeyHeaders,
clearMirror,
getPayments,
jsonRequest,
mirrorUrl,
pageStatus,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('006 watchonly, satspay, and tipjar scenario', async ({browser}) => {
test.setTimeout(7 * 60 * 1000)
await clearMirror()
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, [
'watchonly',
'satspay',
'tipjar'
])
const user = await newUserWithUi(browser, 'watchonly-satspay-tipjar')
await enableExtensionViaUi(user, 'watchonly')
await pageStatus(user.context, `/watchonly/?usr=${user.userId}`)
await jsonRequest(user.context, 'get', '/watchonly/api/v1/config', {
headers: apiKeyHeaders(user.inkey)
})
await jsonRequest(user.context, 'get', '/watchonly/api/v1/wallet', {
headers: apiKeyHeaders(user.inkey),
params: {network: 'Mainnet'}
})
const onchain = await jsonRequest(
user.context,
'post',
'/watchonly/api/v1/wallet',
{
headers: apiKeyHeaders(user.adminkey),
data: {
title: 'segwit',
masterpub:
'zpub6rsRjqj6BTbD9DjqrY4p14tUx5kdA8ZGCTJD99wZTxD5wfvCkyXKrK3s7M3B1eFN6NbRhmbDDRDC8LF3Bn5gmxxN9rF8mDpZsGC6isGrK1g',
network: 'Mainnet',
meta: '{"accountPath":"m/84\'/0\'/0\'"}'
},
expected: [200, 201]
}
)
expect(onchain.id).toBeTruthy()
await jsonRequest(
user.context,
'get',
`/watchonly/api/v1/addresses/${onchain.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await jsonRequest(
user.context,
'get',
`/watchonly/api/v1/address/${onchain.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await enableExtensionViaUi(user, 'satspay')
await pageStatus(user.context, '/satspay/')
await jsonRequest(user.context, 'get', '/satspay/api/v1/charges', {
headers: apiKeyHeaders(user.adminkey)
})
const onchainCharge = await jsonRequest(
user.context,
'post',
'/satspay/api/v1/charge',
{
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: true,
onchainwallet: onchain.id,
lnbits: false,
description: 'Onchain Charge',
time: 1111,
amount: 1111,
lnbitswallet: null
},
expected: [200, 201]
}
)
expect(onchainCharge.id).toBeTruthy()
await jsonRequest(user.context, 'post', '/satspay/api/v1/charge', {
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: false,
onchainwallet: null,
lnbits: true,
description: 'lightning charge - to expire',
time: 1,
amount: 10,
lnbitswallet: user.walletId,
webhook: 'https://google.com',
completelink: 'https://twitter.com',
completelinktext: 'Have Fun'
},
expected: [200, 201]
})
let expectedBalanceSats = 0
for (let index = 1; index <= 5; index++) {
const amount = 9 + index
const charge = await jsonRequest(
user.context,
'post',
'/satspay/api/v1/charge',
{
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: false,
onchainwallet: null,
lnbits: true,
description: `lightning charge [${index}]`,
time: 1220,
amount,
lnbitswallet: user.walletId,
webhook: mirrorUrl(),
completelink: 'https://twitter.com',
completelinktext: 'Have Fun'
},
expected: [200, 201]
}
)
expect(charge.payment_request).toBeTruthy()
await pageStatus(user.context, `/satspay/${charge.id}`)
await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/balance/${charge.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await payInvoiceViaUi(admin, charge.payment_request)
await expect
.poll(async () => {
const updatedCharge = await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/${charge.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
return Boolean(updatedCharge.paid || updatedCharge.lnbitswallet)
})
.toBeTruthy()
expectedBalanceSats += amount
}
await pollWalletBalance(user.context, user.inkey, expectedBalanceSats * 1000)
const charges = await jsonRequest(
user.context,
'get',
'/satspay/api/v1/charges',
{
headers: apiKeyHeaders(user.adminkey)
}
)
expect(charges.length).toBeGreaterThanOrEqual(7)
const payments = await getPayments(user.context, user.inkey)
expect(payments.length).toBeGreaterThanOrEqual(6)
await enableExtensionViaUi(user, 'tipjar')
await pageStatus(user.context, `/tipjar/?usr=${user.userId}`)
await jsonRequest(user.context, 'get', '/tipjar/api/v1/tipjars', {
headers: apiKeyHeaders(user.inkey)
})
const tipjar = await jsonRequest(
user.context,
'post',
'/tipjar/api/v1/tipjars',
{
headers: apiKeyHeaders(user.adminkey),
data: {wallet: user.walletId, name: 'Nakamoto', webhook: mirrorUrl()},
expected: [200, 201]
}
)
expect(tipjar.id).toBeTruthy()
await pageStatus(user.context, `/tipjar/${tipjar.id}`)
for (let index = 1; index <= 5; index++) {
const tip = await jsonRequest(user.context, 'post', '/tipjar/api/v1/tips', {
headers: apiKeyHeaders(user.adminkey),
data: {
tipjar: tipjar.id,
name: 'Hal',
sats: 21,
message: `Let's go ...${index}!`
},
expected: [200, 201]
})
expect(tip.redirect_url).toBeTruthy()
const tipChargeId = tip.redirect_url.split('/').filter(Boolean).at(-1)
const charge = await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/${tipChargeId}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
expect(charge.description).toBe(`Let's go ...${index}!`)
const tips = await jsonRequest(user.context, 'get', '/tipjar/api/v1/tips', {
headers: apiKeyHeaders(user.inkey)
})
expect(tips.length).toBe(index)
await payInvoiceViaUi(admin, tip.payment_request || charge.payment_request)
expectedBalanceSats += 21
await pollWalletBalance(
user.context,
user.inkey,
expectedBalanceSats * 1000
)
}
})
+139
View File
@@ -0,0 +1,139 @@
const {
test,
expect,
setupIntegration,
jsonRequest,
pageStatus,
newUserWithUi,
getAdminSession,
createInvoiceViaUi,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
lndhubHeaders
} = require('./scenario-helpers')
setupIntegration()
test('007 lndhub mobile wallet API scenario', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lndhub'])
const user = await newUserWithUi(browser, 'lndhub')
await enableExtensionViaUi(user, 'lndhub')
await pageStatus(user.context, '/lndhub/')
await jsonRequest(user.context, 'post', '/api/v1/auth/logout')
await jsonRequest(user.context, 'get', '/lndhub/ext/getinfo')
for (const index of [1, 2, 3]) {
const userInvoice = await createInvoiceViaUi(
user,
21,
`user invoice ${index}`
)
await payInvoiceViaUi(admin, userInvoice.bolt11)
const adminInvoice = await createInvoiceViaUi(
admin,
1,
`admin invoice ${index}`
)
await payInvoiceViaUi(user, adminInvoice.bolt11)
}
const auth = await jsonRequest(user.context, 'post', '/lndhub/ext/auth', {
data: {login: 'some_user', password: user.adminkey, refresh_token: ''}
})
expect(auth.access_token).toBeTruthy()
const authHeaders = await lndhubHeaders(auth.access_token)
const balance = await jsonRequest(
user.context,
'get',
'/lndhub/ext/balance',
{
headers: authHeaders
}
)
expect(balance.BTC.AvailableBalance).toBe(60)
const txs = await jsonRequest(user.context, 'get', '/lndhub/ext/gettxs', {
headers: authHeaders
})
expect(txs).toHaveLength(3)
expect(txs.every(tx => tx.value === -1)).toBeTruthy()
const invoices = await jsonRequest(
user.context,
'get',
'/lndhub/ext/getuserinvoices',
{
headers: authHeaders
}
)
expect(invoices).toHaveLength(3)
expect(invoices.every(invoice => invoice.amt === 21)).toBeTruthy()
const mobileInvoice = await jsonRequest(
user.context,
'post',
'/lndhub/ext/addinvoice',
{
headers: authHeaders,
data: {amt: 50, memo: '50 sats'}
}
)
await payInvoiceViaUi(admin, mobileInvoice.payment_request)
const invoicesAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/getuserinvoices',
{
headers: authHeaders
}
)
expect(invoicesAfter).toHaveLength(4)
const adminInvoice = await createInvoiceViaUi(admin, 30, '30 sats')
const paid = await jsonRequest(
user.context,
'post',
'/lndhub/ext/payinvoice',
{
headers: authHeaders,
data: {invoice: adminInvoice.bolt11}
}
)
expect(JSON.stringify(paid)).toContain('30 sats')
const txsAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/gettxs',
{
headers: authHeaders
}
)
expect(txsAfter).toHaveLength(4)
const balanceAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/balance',
{
headers: authHeaders
}
)
expect(balanceAfter.BTC.AvailableBalance).toBe(80)
const invalidAuthHeaders = await lndhubHeaders('YmFkOnRva2Vu')
for (const [method, path, data] of [
['get', '/lndhub/ext/balance'],
['get', '/lndhub/ext/gettxs'],
['get', '/lndhub/ext/getuserinvoices'],
['post', '/lndhub/ext/addinvoice', {amt: 50, memo: '50 sats'}],
['post', '/lndhub/ext/payinvoice', {invoice: adminInvoice.bolt11}]
]) {
await jsonRequest(user.context, method, path, {
headers: invalidAuthHeaders,
data,
expected: [400, 404]
})
}
})
@@ -0,0 +1,54 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
uninstallExtension,
getAdminSession,
refreshExtensionCatalog,
installLatestExtensionViaUi,
setExtensionActiveViaUi
} = require('./scenario-helpers')
setupIntegration()
test('008 lnaddress and lnurlp redirect conflict handling', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await jsonRequest(state.adminContext, 'patch', '/admin/api/v1/settings', {
data: {
lnbits_extensions_manifests: [
'https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json',
'https://raw.githubusercontent.com/lnbits/lnaddress/refs/heads/main/manifest.json'
]
}
})
await refreshExtensionCatalog(state.adminContext)
await uninstallExtension(state.adminContext, 'lnaddress')
await installLatestExtensionViaUi(admin, 'lnurlp')
await installLatestExtensionViaUi(admin, 'lnaddress')
await setExtensionActiveViaUi(admin, 'lnurlp', false)
const conflict = await setExtensionActiveViaUi(
admin,
'lnurlp',
true,
[200, 400]
)
const conflictText = JSON.stringify(conflict)
if (conflictText.includes('Already mapped')) {
expect(conflictText).toContain('Already mapped')
} else {
expect(conflict?.success).toBeTruthy()
}
await setExtensionActiveViaUi(admin, 'lnaddress', false).catch(error => {
if (!String(error.message).includes('Could not find extension card')) {
throw error
}
})
await setExtensionActiveViaUi(admin, 'lnurlp', true)
})
@@ -0,0 +1,49 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
textRequest,
getAdminSession,
installExtensionVersionViaUi,
enableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('009 example extension can downgrade and upgrade data shape', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
async function installAndCheck(testVersion, extensionVersion) {
const releases = await jsonRequest(
state.adminContext,
'get',
'/api/v1/extension/example/releases'
)
const release = releases.find(item => item.version === extensionVersion)
expect(release).toBeTruthy()
await installExtensionVersionViaUi(admin, 'example', release.version)
await enableExtensionViaUi(admin, 'example')
const page = await textRequest(state.adminContext, 'get', '/example')
expect(page).toContain(
`Do not remove. Test install extension version: ${testVersion}`
)
const response = await jsonRequest(
state.adminContext,
'get',
'/example/api/v1/test/00000000'
)
expect(response.version ?? response.id).toBe(String(testVersion))
expect(response.test_id ?? response.wallet).toBe('00000000')
}
await installAndCheck(1, '1.0.1')
await installAndCheck(2, '1.0.6')
await installAndCheck(1, '1.0.1')
await installAndCheck(2, '1.0.6')
await installAndCheck(1, '1.0.1')
})
+382
View File
@@ -0,0 +1,382 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
createWallet,
decodePayment,
getWallet,
jsonRequest,
textRequest,
newContext,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
topUpWallet,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createNip5Domain,
createNip5Address,
activateNip5Address,
getNostrJson,
buyNip5Address
} = require('./scenario-helpers')
setupIntegration()
test('010 nostrnip5 domain, search, pricing, and referral flow', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await topUpWallet(state.adminContext, state.adminWallet.walletId, 1_000_000)
await ensureExtensionsInstalledViaUi(admin, ['nostrnip5', 'lnurlp'])
const anonymous = await newContext()
await textRequest(anonymous, 'patch', '/nostrnip5/api/v1/domain/ranking/0', {
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'},
expected: 401
})
await enableExtensionViaUi(admin, 'nostrnip5')
await enableExtensionViaUi(admin, 'lnurlp')
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/0',
{
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'}
}
)
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/200',
{
data: 'rank_200_a\r\nrank_200_b\r\nyyy',
headers: {'content-type': 'text/plain'}
}
)
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/1000',
{
data: 'rank_1000_a\r\nrank_1000_b\r\nrank_1000_c\r\nxxx',
headers: {'content-type': 'text/plain'}
}
)
await jsonRequest(state.adminContext, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(state.adminWallet.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const owner = await newUserWithUi(browser, 'nostrnip5-owner')
await enableExtensionViaUi(owner, 'nostrnip5')
await enableExtensionViaUi(owner, 'lnurlp')
await textRequest(
owner.context,
'patch',
'/nostrnip5/api/v1/domain/ranking/0',
{
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'},
expected: 403
}
)
const domain = await createNip5Domain(owner.context, owner)
const domainId = domain.id
await jsonRequest(
owner.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}`,
{
headers: apiKeyHeaders(owner.inkey)
}
)
await jsonRequest(owner.context, 'put', '/nostrnip5/api/v1/domain', {
headers: apiKeyHeaders(owner.adminkey),
data: {
cost: 100,
cost_extra: {
char_count_cost: [
{bracket: '1', amount: '1001'},
{bracket: '2', amount: '505'},
{bracket: '3', amount: '202'}
],
max_years: '5',
promotions: [
{
code: 'code_10',
buyer_discount_percent: '10',
referer_bonus_percent: '0'
},
{
code: 'code_20',
buyer_discount_percent: '20',
referer_bonus_percent: '5'
},
{
code: 'code_alan',
buyer_discount_percent: '25',
referer_bonus_percent: '20',
selected_referer: 'alan'
}
],
rank_cost: [
{bracket: 200, amount: '10000'},
{bracket: 1000, amount: '500'}
]
},
currency: 'USD',
domain: domain.domain,
id: domainId,
wallet: owner.walletId
}
})
const identifierOne = `one_${Date.now()}`
const identifierTwo = `two_${Date.now()}`
const identifierThree = `three_${Date.now()}`
const address = await createNip5Address(
owner.context,
owner,
domainId,
identifierOne,
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9'
)
await getNostrJson(owner.context, domainId, identifierOne, 404)
await activateNip5Address(owner.context, owner, domainId, address.id)
let nostrJson = await getNostrJson(owner.context, domainId, identifierOne)
expect(nostrJson.names[identifierOne]).toBeTruthy()
await jsonRequest(
owner.context,
'put',
`/nostrnip5/api/v1/domain/${domainId}/address/${address.id}`,
{
headers: apiKeyHeaders(owner.adminkey),
data: {
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbeeee',
relays: ['wss://relay.nostr.com', 'ws://relay.nostr.org']
}
}
)
nostrJson = await getNostrJson(owner.context, domainId, identifierOne)
expect(nostrJson.relays).toBeTruthy()
const second = await createNip5Address(
owner.context,
owner,
domainId,
identifierTwo,
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa'
)
await activateNip5Address(owner.context, owner, domainId, second.id)
let addresses = await jsonRequest(
owner.context,
'get',
'/nostrnip5/api/v1/addresses/paginated',
{
headers: apiKeyHeaders(owner.inkey),
params: {
all_wallets: true,
limit: 10,
offset: 0,
sortby: 'time',
direction: 'asc'
}
}
)
expect(JSON.stringify(addresses)).toContain(identifierTwo)
await jsonRequest(
owner.context,
'delete',
`/nostrnip5/api/v1/domain/${domainId}/address/${address.id}`,
{
headers: apiKeyHeaders(owner.adminkey)
}
)
await getNostrJson(owner.context, domainId, identifierOne, 404)
const alan = await createNip5Address(
owner.context,
owner,
domainId,
'alan',
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaabb'
)
await activateNip5Address(owner.context, owner, domainId, alan.id)
const alanWallet = await createWallet(owner.context, 'Alan Wallet')
await jsonRequest(
owner.context,
'put',
`/nostrnip5/api/v1/user/domain/${domainId}/address/${alan.id}/lnaddress`,
{
headers: apiKeyHeaders(owner.adminkey),
data: {wallet: alanWallet.walletId, min: 5, max: '100005'}
}
)
for (const [query, expectedAvailable] of [
['a', true],
['aa', true],
['aaa', true],
['aaaa', true],
[identifierTwo, false],
['reserved_a', false],
['rank_200_b', true],
['rank_1000_b', true],
['yyy', true],
['xxx', true]
]) {
const result = await jsonRequest(
owner.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/search`,
{
headers: apiKeyHeaders(owner.inkey),
params: {q: query}
}
)
expect(Boolean(result.available)).toBe(expectedAvailable)
}
const client = await newUserWithUi(browser, 'nostrnip5-client')
await enableExtensionViaUi(client, 'nostrnip5')
await enableExtensionViaUi(client, 'lnurlp')
await buyNip5Address(client.context, client, domainId, 'abc1', {
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years: 3
})
await buyNip5Address(client.context, client, domainId, 'a1', {
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years: 3
})
const cartItems = await jsonRequest(
client.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(client.inkey),
params: {active: false}
}
)
expect(JSON.stringify(cartItems)).toContain('abc1')
expect(JSON.stringify(cartItems)).toContain('a1')
const priceChecks = [
['abc1', 3, null, false, 300.0],
['abc1', 5, null, true, 500.0],
['abc1', 7, null, false, null, 400],
['abc1', 3, 'code_10', true, 270.0],
['abc1', 3, 'code_20', false, 240.0],
['abc1', 3, 'code_alan', true, 225.0],
['xyz', 3, null, false, 606.0],
['xyz', 5, null, true, 1010.0],
['xyz', 7, null, false, null, 400],
['xyz', 3, 'code_10', true, 545.4],
['xyz', 3, 'code_20', false, 484.8],
['xyz', 3, 'code_alan', true, 454.5]
]
for (const [
localPart,
years,
promoCode,
createInvoiceFlag,
expectedPrice,
expectedStatus
] of priceChecks) {
const result = await buyNip5Address(
client.context,
client,
domainId,
localPart,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years,
promo_code: promoCode,
create_invoice: createInvoiceFlag,
expected: expectedStatus ?? 201
}
)
if (expectedPrice !== null) {
expect(result.extra.price).toBeCloseTo(expectedPrice)
}
if (createInvoiceFlag && result.payment_request) {
const decoded = await decodePayment(
client.context,
result.payment_request
)
expect(Number(result.extra.price_in_sats) * 1000).toBe(
decoded.amount_msat
)
}
}
const paidOne = await buyNip5Address(
client.context,
client,
domainId,
identifierOne,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaab',
years: 3,
create_invoice: true
}
)
await payInvoiceViaUi(admin, paidOne.payment_request)
await getNostrJson(client.context, domainId, identifierOne)
const quoteThree = await buyNip5Address(
client.context,
client,
domainId,
identifierThree,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaac',
years: 5
}
)
expect(quoteThree.extra.price).toBeCloseTo(500.0)
const paidThree = await buyNip5Address(
client.context,
client,
domainId,
identifierThree,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaac',
years: 5,
promo_code: 'code_alan',
create_invoice: true
}
)
await payInvoiceViaUi(admin, paidThree.payment_request)
await getNostrJson(client.context, domainId, identifierThree)
const refererBonus = Math.floor(quoteThree.extra.price_in_sats / 5)
await expect
.poll(
async () => {
const alanBalance = await getWallet(owner.context, alanWallet.inkey)
return Math.abs(Math.floor(alanBalance.balance / 1000) - refererBonus)
},
{timeout: 15_000}
)
.toBeLessThanOrEqual(100)
})
+267
View File
@@ -0,0 +1,267 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
getPayments,
jsonRequest,
newUser,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createNip5Domain,
createUserNip5Address,
activateNip5Address,
createAuctionRoom,
updateAuctionRoom,
createAuctionItem,
bidOnItem,
auctionNip5Webhooks
} = require('./scenario-helpers')
setupIntegration()
test('011 auction house transfers NIP5 addresses through bids and fixed-price sale', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, [
'auction_house',
'nostrnip5',
'lnurlp'
])
await enableExtensionViaUi(admin, 'nostrnip5')
await enableExtensionViaUi(admin, 'lnurlp')
await jsonRequest(state.adminContext, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(state.adminWallet.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const owner = await newUserWithUi(browser, 'auction-owner')
await enableExtensionViaUi(owner, 'auction_house')
await enableExtensionViaUi(owner, 'nostrnip5')
await enableExtensionViaUi(owner, 'lnurlp')
const domain = await createNip5Domain(owner.context, owner, {
cost_extra: {transfer_secret: '1234'}
})
const domainId = domain.id
await jsonRequest(owner.context, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(owner.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const seller = await newUserWithUi(browser, 'auction-seller')
await enableExtensionViaUi(seller, 'auction_house')
await enableExtensionViaUi(seller, 'nostrnip5')
const createdAddresses = []
for (let index = 1; index <= 10; index++) {
const address = await createUserNip5Address(
seller.context,
seller,
domainId,
`id_${index}__${domainId}`
)
await activateNip5Address(owner.context, owner, domainId, address.id)
createdAddresses.push(address)
}
const sellRoom = await createAuctionRoom(owner.context, owner, {
name: 'nip5 sales',
description: 'Auctions for Nip5',
fee_wallet_id: owner.walletId,
currency: 'USD',
type: 'fixed_price'
})
await updateAuctionRoom(owner.context, owner, {
id: sellRoom.id,
name: 'nip5 sells',
description: 'Sells for Nip5',
currency: 'sat',
type: 'fixed_price',
room_percentage: 20,
fee_wallet_id: owner.walletId,
is_open_room: true,
extra: auctionNip5Webhooks(domainId)
})
const auctionRoom = await createAuctionRoom(owner.context, owner, {
name: 'nip5 auctions',
description: 'Auctions for Nip5',
fee_wallet_id: owner.walletId,
currency: 'USD',
type: 'auction'
})
await updateAuctionRoom(owner.context, owner, {
id: auctionRoom.id,
name: 'nip5 auctions',
description: 'Auctions for Nip5',
currency: 'sat',
type: 'auction',
min_bid_up_percentage: 5,
room_percentage: 10,
fee_wallet_id: owner.walletId,
is_open_room: true,
extra: auctionNip5Webhooks(domainId)
})
const sellerAddresses = await jsonRequest(
seller.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(seller.inkey),
params: {active: true}
}
)
expect(Array.isArray(sellerAddresses)).toBeTruthy()
const addressForAuction = createdAddresses[0]
const transfer = await jsonRequest(
seller.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/address/${addressForAuction.id}/transfer`,
{headers: apiKeyHeaders(seller.adminkey)}
)
expect(transfer.transfer_code).toBeTruthy()
const item = await createAuctionItem(seller.context, seller, auctionRoom.id, {
name: addressForAuction.local_part,
ask_price: 10.1,
transfer_code: transfer.transfer_code
})
let winningBidder
for (let index = 1; index <= 15; index++) {
const bidder = await newUser(`auction-bidder-${index}`)
const bid = await bidOnItem(
bidder.context,
bidder,
item.id,
index * 100,
`bid index: ${index}`
)
await payInvoiceViaUi(admin, bid.payment_request)
winningBidder = bidder
}
await expect
.poll(
async () => {
const bids = await jsonRequest(
owner.context,
'get',
`/auction_house/api/v1/bids/${item.id}/paginated`,
{
headers: apiKeyHeaders(owner.inkey),
params: {limit: 20}
}
)
return bids.total
},
{timeout: 15_000}
)
.toBeGreaterThanOrEqual(15)
await getPayments(owner.context, owner.inkey)
await jsonRequest(
owner.context,
'delete',
`/auction_house/api/v1/items/${item.id}`,
{
headers: apiKeyHeaders(owner.adminkey),
params: {force_close: true}
}
)
const winnerAddresses = await jsonRequest(
winningBidder.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(winningBidder.inkey),
params: {active: true}
}
)
expect(JSON.stringify(winnerAddresses)).toContain(
addressForAuction.local_part
)
const fixedPriceAddress = createdAddresses[1]
const fixedItems = await jsonRequest(
seller.context,
'get',
`/auction_house/api/v1/items/${auctionRoom.id}/paginated`,
{
headers: apiKeyHeaders(seller.inkey),
params: {
sortby: 'ask_price',
direction: 'asc',
name: fixedPriceAddress.local_part
}
}
)
if (fixedItems.data?.[0]?.id) {
await jsonRequest(
seller.context,
'delete',
`/auction_house/api/v1/items/${fixedItems.data[0].id}`,
{
headers: apiKeyHeaders(seller.adminkey),
params: {force_close: true}
}
)
}
const fixedTransfer = await jsonRequest(
seller.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/address/${fixedPriceAddress.id}/transfer`,
{headers: apiKeyHeaders(seller.adminkey)}
)
const sellItem = await createAuctionItem(
seller.context,
seller,
sellRoom.id,
{
name: fixedPriceAddress.local_part,
ask_price: 5000,
transfer_code: fixedTransfer.transfer_code
}
)
const buyer = await newUser('auction-buyer')
const buy = await bidOnItem(
buyer.context,
buyer,
sellItem.id,
5000,
'fixed price buy'
)
await payInvoiceViaUi(admin, buy.payment_request)
await expect
.poll(
async () => {
const buyerAddresses = await jsonRequest(
buyer.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(buyer.inkey),
params: {active: true}
}
)
return JSON.stringify(buyerAddresses)
},
{timeout: 15_000}
)
.toContain(fixedPriceAddress.local_part)
})
+387
View File
@@ -0,0 +1,387 @@
const {expect} = require('@playwright/test')
const DEFAULT_MANIFEST_URL =
'https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json'
function apiKeyHeaders(key) {
return key ? {'X-Api-Key': key} : {}
}
async function expectStatus(response, expected, label) {
const statuses = Array.isArray(expected) ? expected : [expected]
if (!statuses.includes(response.status())) {
const body = await response.text()
throw new Error(
`${label || response.url()} expected status ${statuses.join(
'/'
)}, got ${response.status()}: ${body.slice(0, 800)}`
)
}
}
async function responseJson(response) {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch (error) {
throw new Error(
`Expected JSON from ${response.url()}: ${text.slice(0, 800)}`
)
}
}
async function jsonRequest(context, method, url, options = {}) {
const response = await context[method](url, {
data: options.data,
form: options.form,
headers: options.headers,
params: options.params,
failOnStatusCode: false,
maxRedirects: options.maxRedirects
})
await expectStatus(
response,
options.expected ?? 200,
`${method.toUpperCase()} ${url}`
)
return responseJson(response)
}
async function textRequest(context, method, url, options = {}) {
const response = await context[method](url, {
data: options.data,
headers: options.headers,
params: options.params,
failOnStatusCode: false,
maxRedirects: options.maxRedirects
})
await expectStatus(
response,
options.expected ?? 200,
`${method.toUpperCase()} ${url}`
)
return response.text()
}
async function loginAdmin(context) {
await jsonRequest(context, 'post', '/api/v1/auth', {
data: {username: 'admin', password: 'secret1234'}
})
}
async function initServer(context) {
const home = await context.get('/', {
failOnStatusCode: false,
maxRedirects: 0
})
const location = home.headers().location || ''
if (
[301, 302, 303, 307, 308].includes(home.status()) &&
location.includes('first_install')
) {
await jsonRequest(context, 'put', '/api/v1/auth/first_install', {
data: {
username: 'admin',
password: 'secret1234',
password_repeat: 'secret1234'
}
})
}
await loginAdmin(context)
const wallets = await jsonRequest(context, 'get', '/api/v1/wallets')
expect(wallets.length).toBeGreaterThan(0)
const adminWallet = wallets[0]
await jsonRequest(context, 'put', '/admin/api/v1/settings', {
data: {lnbits_callback_url_rules: []}
})
await topUpWallet(context, adminWallet.id, 1_000_000)
return {
walletId: adminWallet.id,
inkey: adminWallet.inkey,
adminkey: adminWallet.adminkey
}
}
async function logout(context) {
await jsonRequest(context, 'post', '/api/v1/auth/logout')
}
async function createAccount(context, name) {
await textRequest(context, 'get', '/')
const wallet = await jsonRequest(context, 'post', '/api/v1/account', {
data: {name}
})
await jsonRequest(context, 'post', '/api/v1/auth/usr', {
data: {usr: wallet.user}
})
return {
userId: wallet.user,
walletId: wallet.id,
inkey: wallet.inkey,
adminkey: wallet.adminkey,
raw: wallet
}
}
async function createWallet(context, name) {
const wallet = await jsonRequest(context, 'post', '/api/v1/wallet', {
data: {name}
})
return {
userId: wallet.user,
walletId: wallet.id,
inkey: wallet.inkey,
adminkey: wallet.adminkey,
raw: wallet
}
}
async function topUpWallet(adminContext, walletId, amount) {
return jsonRequest(adminContext, 'put', '/users/api/v1/balance', {
data: {amount: String(amount), id: walletId}
})
}
async function enableExtension(context, extension) {
const enabled = await jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/enable`,
{
form: {enable: extension}
}
)
if (enabled && Object.prototype.hasOwnProperty.call(enabled, 'is_enabled')) {
expect(enabled.is_enabled).toBeTruthy()
}
return enabled
}
async function disableExtension(context, extension) {
return jsonRequest(context, 'put', `/api/v1/extension/${extension}/disable`, {
form: {enable: extension}
})
}
async function deactivateExtension(context, extension) {
return jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/deactivate`
)
}
async function activateExtension(context, extension, expected = 200) {
return jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/activate`,
{
expected
}
)
}
async function uninstallExtension(context, extension) {
const response = await context.delete(`/api/v1/extension/${extension}`, {
failOnStatusCode: false
})
await expectStatus(
response,
[200, 204, 404],
`DELETE /api/v1/extension/${extension}`
)
}
async function pageStatus(context, path, expected = 200) {
const response = await context.get(path, {failOnStatusCode: false})
await expectStatus(response, expected, `GET ${path}`)
return response
}
async function getWallet(context, inkey) {
return jsonRequest(context, 'get', '/api/v1/wallet', {
headers: apiKeyHeaders(inkey)
})
}
async function getPayments(context, inkey, params) {
return jsonRequest(context, 'get', '/api/v1/payments', {
headers: apiKeyHeaders(inkey),
params
})
}
async function createInvoice(context, adminkey, amount, memo) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(adminkey),
data: {out: false, amount, memo}
})
}
async function payInvoice(context, adminkey, bolt11, expected = 201) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(adminkey),
data: {out: true, bolt11},
expected
})
}
async function getPayment(context, inkey, paymentHash) {
return jsonRequest(context, 'get', `/api/v1/payments/${paymentHash}`, {
headers: apiKeyHeaders(inkey)
})
}
async function decodePayment(context, data) {
return jsonRequest(context, 'post', '/api/v1/payments/decode', {
data: {data}
})
}
async function lnurlScan(context, lnurl, key) {
return jsonRequest(
context,
'get',
`/api/v1/lnurlscan/${encodeURIComponent(lnurl)}`,
{
headers: apiKeyHeaders(key)
}
)
}
async function clearMirror() {
const response = await fetch(mirrorUrl('/__mirror__/clear'))
if (response.status !== 204) {
throw new Error(`Expected mirror clear status 204, got ${response.status}`)
}
}
async function getMirrorRequests() {
const response = await fetch(mirrorUrl('/__mirror__/requests'))
if (!response.ok) {
throw new Error(`Cannot fetch mirror requests: ${response.status}`)
}
return response.json()
}
async function pollPayment(
context,
inkey,
paymentHash,
predicate,
timeout = 10_000
) {
let last
await expect
.poll(
async () => {
last = await getPayment(context, inkey, paymentHash)
return predicate(last)
},
{timeout}
)
.toBeTruthy()
return last
}
async function pollWalletBalance(
context,
inkey,
expectedBalance,
timeout = 10_000
) {
await expect
.poll(
async () => {
const wallet = await getWallet(context, inkey)
return wallet.balance
},
{timeout}
)
.toBe(expectedBalance)
}
async function fetchManifest() {
const manifestUrl =
process.env.EXTENSIONS_MANIFEST_URL || DEFAULT_MANIFEST_URL
const response = await fetch(manifestUrl)
if (!response.ok) {
throw new Error(
`Cannot fetch extension manifest ${manifestUrl}: ${response.status}`
)
}
return response.json()
}
async function latestRelease(context, extension) {
const releases = await jsonRequest(
context,
'get',
`/api/v1/extension/${extension}/releases`
)
expect(releases.length).toBeGreaterThan(0)
return releases
.slice()
.sort((a, b) =>
a.version.localeCompare(b.version, undefined, {numeric: true})
)
.at(-1)
}
async function installLatestExtension(context, extension) {
const release = await latestRelease(context, extension)
return jsonRequest(context, 'post', '/api/v1/extension', {
data: {
ext_id: extension,
archive: release.archive,
source_repo: release.source_repo,
version: release.version
}
})
}
function mirrorUrl(path = '') {
const port = process.env.MIRROR_PORT || '8500'
return `http://localhost:${port}${path}`
}
module.exports = {
apiKeyHeaders,
activateExtension,
clearMirror,
createAccount,
createInvoice,
createWallet,
deactivateExtension,
decodePayment,
disableExtension,
enableExtension,
expectStatus,
fetchManifest,
getMirrorRequests,
getPayment,
getPayments,
getWallet,
initServer,
installLatestExtension,
jsonRequest,
latestRelease,
lnurlScan,
loginAdmin,
logout,
mirrorUrl,
pageStatus,
payInvoice,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension
}
+990
View File
@@ -0,0 +1,990 @@
const {test, expect} = require('@playwright/test')
const {
apiKeyHeaders,
clearMirror,
createAccount,
createWallet,
decodePayment,
expectStatus,
fetchManifest,
getPayment,
getPayments,
getWallet,
initServer,
jsonRequest,
latestRelease,
loginAdmin,
lnurlScan,
mirrorUrl,
pageStatus,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension
} = require('./helpers')
const state = {
baseURL: null,
adminContext: null,
adminWallet: null,
adminSession: null,
extensionById: null,
contextFactory: null
}
const contexts = []
const browserContexts = []
async function newContext() {
const context = await state.contextFactory.newContext({
baseURL: state.baseURL,
extraHTTPHeaders: {
Accept: 'application/json, text/plain, */*'
}
})
contexts.push(context)
return context
}
async function newUser(name) {
const context = await newContext()
const account = await createAccount(context, `${name}-${Date.now()}`)
return {context, ...account}
}
async function newBrowserSession(browser, account) {
const uiContext = await browser.newContext({baseURL: state.baseURL})
browserContexts.push(uiContext)
if (account?.userId) {
await textRequest(uiContext.request, 'get', '/')
await jsonRequest(uiContext.request, 'post', '/api/v1/auth/usr', {
data: {usr: account.userId}
})
} else {
await loginAdmin(uiContext.request)
}
const page = await uiContext.newPage()
page.setDefaultTimeout(45_000)
return {uiContext, page}
}
async function newUserWithUi(browser, name) {
const user = await newUser(name)
return {...user, ...(await newBrowserSession(browser, user))}
}
async function getAdminSession(browser) {
if (!state.adminSession) {
state.adminSession = {
...state.adminWallet,
...(await newBrowserSession(browser))
}
}
return state.adminSession
}
function extensionName(extensionId) {
return state.extensionById?.get(extensionId)?.name || extensionId
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function extensionSearchTerms(extensionId) {
return [...new Set([extensionName(extensionId), extensionId].filter(Boolean))]
}
async function refreshExtensionCatalog(context = state.adminContext) {
const extensions = await jsonRequest(context, 'get', '/api/v1/extension')
for (const extension of extensions) {
const id = extension.id || extension.code
if (id) {
state.extensionById.set(id, {
id,
name: extension.name || extension.title || id
})
}
}
}
function responsePath(response) {
return new URL(response.url()).pathname
}
function responseMatches(response, method, path) {
return (
response.request().method() === method && responsePath(response) === path
)
}
async function clickAndCheckResponse(
page,
method,
path,
action,
expected = 200,
timeout = 45_000
) {
const responsePromise = page.waitForResponse(
response => responseMatches(response, method, path),
{timeout}
)
await action()
const response = await responsePromise
await expectStatus(response, expected, `${method} ${path}`)
return response
}
async function clickAndGetJsonResponse(
page,
method,
path,
action,
expected = 200
) {
const response = await clickAndCheckResponse(
page,
method,
path,
action,
expected
)
return responseJson(response)
}
async function clickAndMaybeGetJsonResponse(
page,
method,
path,
action,
expected = 200,
timeout = 10_000
) {
const responsePromise = page
.waitForResponse(response => responseMatches(response, method, path), {
timeout
})
.catch(() => null)
await action()
const response = await responsePromise
if (!response) return null
await expectStatus(response, expected, `${method} ${path}`)
return responseJson(response)
}
async function gotoPage(page, path, expected = 200) {
const response = await page.goto(path)
if (response) {
await expectStatus(response, expected, `GET ${path}`)
}
await page.waitForFunction(() => window.LNbits && window.g)
const credentialsButton = page.getByRole('button', {name: /i understand/i})
if (await credentialsButton.isVisible({timeout: 1000}).catch(() => false)) {
await credentialsButton.click()
}
return response
}
async function gotoWallet(session, walletId = session.walletId) {
await gotoPage(session.page, `/wallet/${walletId}`)
await expect(
session.page.getByRole('button', {name: /receive/i})
).toBeVisible()
}
async function fillQuasarField(scope, label, value) {
const field = scope
.locator('.q-field')
.filter({hasText: label})
.locator('input, textarea')
.first()
await field.fill(String(value))
}
async function createInvoiceViaUi(session, amount, memo) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /receive/i}).click()
const dialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Create Invoice'})
.last()
await fillQuasarField(dialog, 'Amount (sat)', amount)
await fillQuasarField(dialog, 'Memo', memo)
const invoice = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => dialog.getByRole('button', {name: /create invoice/i}).click(),
[200, 201]
)
expect(invoice.bolt11).toBeTruthy()
return invoice
}
async function payInvoiceViaUi(session, bolt11, expected = 201) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /send/i}).click()
const dialog = session.page.locator('.q-dialog').last()
await fillQuasarField(dialog, 'Paste an invoice', bolt11)
await dialog.getByRole('button', {name: /read/i}).click()
await expect(dialog.getByRole('button', {name: /^pay$/i})).toBeVisible()
return clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => dialog.getByRole('button', {name: /^pay$/i}).click(),
expected
)
}
async function payLnurlViaUi(session, lnurl, amountMsat, comment) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /send/i}).click()
const dialog = session.page.locator('.q-dialog').last()
await fillQuasarField(dialog, 'Paste an invoice', lnurl)
await clickAndCheckResponse(session.page, 'POST', '/api/v1/lnurlscan', () =>
dialog.getByRole('button', {name: /read/i}).click()
)
await fillQuasarField(dialog, 'Amount (sat)', amountMsat / 1000)
if (comment) {
await fillQuasarField(dialog, 'Comment (optional)', comment)
}
const payment = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments/lnurl',
() => dialog.getByRole('button', {name: /^send$/i}).click()
)
expect(payment.payment_hash).toBeTruthy()
return payment
}
async function withdrawLnurlViaUi(
session,
lnurl,
amount = 10,
memo = 'withdraw 1',
walletId = session.walletId
) {
await gotoWallet(session, walletId)
await session.page.getByRole('button', {name: /send/i}).click()
const scanDialog = session.page.locator('.q-dialog').last()
await fillQuasarField(scanDialog, 'Paste an invoice', lnurl)
await clickAndCheckResponse(session.page, 'POST', '/api/v1/lnurlscan', () =>
scanDialog.getByRole('button', {name: /read/i}).click()
)
const withdrawDialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Withdraw from'})
.last()
await fillQuasarField(withdrawDialog, 'Amount (sat)', amount)
await fillQuasarField(withdrawDialog, 'Memo', memo)
const payment = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => withdrawDialog.getByRole('button', {name: /withdraw from/i}).click(),
[200, 201]
)
expect(payment.payment_hash).toBeTruthy()
return payment
}
async function openExtensionsPage(session, tab = 'installed') {
const extensionsLoaded = session.page
.waitForResponse(response =>
responseMatches(response, 'GET', '/api/v1/extension')
)
.catch(() => null)
await gotoPage(session.page, '/extensions')
await extensionsLoaded
await session.page.waitForLoadState('networkidle').catch(() => null)
await session.page.getByLabel(/search extensions/i).waitFor()
const tabButton = session.page.getByRole('tab', {
name: new RegExp(`^${tab}$`, 'i')
})
await tabButton.click()
await expect(tabButton).toHaveAttribute('aria-selected', 'true')
}
async function findExtensionCard(session, extensionId, tab = 'installed') {
await openExtensionsPage(session, tab)
const search = session.page.getByLabel(/search extensions/i)
const cards = session.page.locator('.q-card').filter({
visible: true,
has: session.page.getByRole('button', {
name: /manage|enable|disable|open|pay to enable/i
})
})
for (const term of extensionSearchTerms(extensionId)) {
await search.fill(term)
const matchingCard = cards
.filter({hasText: new RegExp(escapeRegExp(term), 'i')})
.first()
if (await matchingCard.isVisible({timeout: 1500}).catch(() => false)) {
return matchingCard
}
if ((await cards.count()) === 1) {
return cards.first()
}
}
throw new Error(`Could not find extension card for ${extensionId}`)
}
async function openExtensionManager(session, extensionId, tab = 'installed') {
const card = await findExtensionCard(session, extensionId, tab)
const releasesResponsePromise = session.page
.waitForResponse(
response =>
responseMatches(
response,
'GET',
`/api/v1/extension/${extensionId}/releases`
),
{timeout: 10_000}
)
.catch(() => null)
await card.getByRole('button', {name: /manage/i}).click()
const releasesResponse = await releasesResponsePromise
if (releasesResponse) {
await expectStatus(
releasesResponse,
200,
`GET /api/v1/extension/${extensionId}/releases`
)
}
const dialog = session.page.locator('.q-dialog').filter({hasText: 'Releases'})
await expect(dialog.last()).toBeVisible()
return dialog.last()
}
async function installExtensionVersionViaUi(session, extensionId, version) {
let dialog
try {
dialog = await openExtensionManager(session, extensionId, 'installed')
} catch {
dialog = await openExtensionManager(session, extensionId, 'all')
}
const versionLabels = [
...new Set([version, version.replace(/^v/i, '')].filter(Boolean))
]
let releaseVersion = null
for (const label of versionLabels) {
const candidate = dialog
.getByText(label, {exact: true})
.filter({visible: true})
.first()
if (await candidate.isVisible({timeout: 1000}).catch(() => false)) {
releaseVersion = candidate
break
}
}
if (!releaseVersion) {
const expandButton = dialog.getByRole('button', {name: /expand/i}).first()
if (!(await expandButton.isVisible({timeout: 1000}).catch(() => false))) {
const alreadyInstalled = await dialog
.getByRole('button', {name: /uninstall/i})
.isVisible({timeout: 500})
.catch(() => false)
await dialog.getByRole('button', {name: /close/i}).last().click()
return alreadyInstalled ? {alreadyInstalled: true} : null
}
await expandButton.click()
for (const label of versionLabels) {
const candidate = dialog
.getByText(label, {exact: true})
.filter({visible: true})
.first()
if (await candidate.isVisible({timeout: 1000}).catch(() => false)) {
releaseVersion = candidate
break
}
}
}
if (!releaseVersion) {
releaseVersion = dialog
.getByText(versionLabels[0], {exact: true})
.filter({visible: true})
.first()
}
await expect(releaseVersion).toBeVisible()
await releaseVersion.click()
const installButton = dialog.getByRole('button', {name: /^install$/i}).first()
if (!(await installButton.isVisible({timeout: 1000}).catch(() => false))) {
const alreadyInstalled = await dialog
.getByRole('button', {name: /uninstall/i})
.isVisible({timeout: 500})
.catch(() => false)
await dialog.getByRole('button', {name: /close/i}).last().click()
return alreadyInstalled ? {alreadyInstalled: true} : null
}
const activatePromise = session.page
.waitForResponse(response =>
responseMatches(
response,
'PUT',
`/api/v1/extension/${extensionId}/activate`
)
)
.catch(() => null)
let installResponsePromise = session.page.waitForResponse(response =>
responseMatches(response, 'POST', '/api/v1/extension')
)
await installButton.click()
const grantButton = session.page.getByRole('button', {
name: /grant and install/i
})
if (await grantButton.isVisible({timeout: 1000}).catch(() => false)) {
installResponsePromise.catch(() => null)
installResponsePromise = session.page.waitForResponse(response =>
responseMatches(response, 'POST', '/api/v1/extension')
)
await grantButton.click()
}
const installResponse = await installResponsePromise
await expectStatus(installResponse, [200, 201], 'POST /api/v1/extension')
const activateResponse = await activatePromise
if (activateResponse) {
await expectStatus(
activateResponse,
200,
`PUT /api/v1/extension/${extensionId}/activate`
)
}
return responseJson(installResponse)
}
async function installLatestExtensionViaUi(session, extensionId) {
const release = await latestRelease(session.uiContext.request, extensionId)
return installExtensionVersionViaUi(session, extensionId, release.version)
}
async function ensureExtensionsInstalledViaUi(session, extensionIds) {
for (const extensionId of extensionIds) {
const installed = await findExtensionCard(session, extensionId, 'installed')
.then(() => true)
.catch(() => false)
if (!installed) {
await installLatestExtensionViaUi(session, extensionId)
}
await setExtensionActiveViaUi(session, extensionId, true)
}
}
async function enableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
const alreadyEnabled =
(await card.getByRole('button', {name: /open/i}).isVisible()) ||
(await card.getByRole('button', {name: /^disable$/i}).isVisible())
if (alreadyEnabled) {
return null
}
const payToEnableButton = card.getByRole('button', {name: /pay to enable/i})
if (await payToEnableButton.isVisible()) {
await payToEnableButton.click()
const dialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Recheck'})
await expect(dialog.last()).toBeVisible()
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => session.page.getByText(/recheck/i).click()
)
}
const enableButton = card.getByRole('button', {name: /^enable$/i})
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => enableButton.click()
)
if (result) return result
const cardAfterEnable = await findExtensionCard(session, extensionId)
await expect(
cardAfterEnable.getByRole('button', {name: /^disable$/i})
).toBeVisible()
return {success: true}
}
async function disableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
if (!(await card.getByRole('button', {name: /^disable$/i}).isVisible())) {
return null
}
const disableButton = card.getByRole('button', {name: /^disable$/i})
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/disable`,
() => disableButton.click()
)
if (result) return result
const cardAfterDisable = await findExtensionCard(session, extensionId)
await expect(
cardAfterDisable.getByRole('button', {name: /^enable$/i})
).toBeVisible()
return {success: true}
}
async function setExtensionActiveViaUi(
session,
extensionId,
active,
expected = 200
) {
const card = await findExtensionCard(session, extensionId)
const action = active ? 'activate' : 'deactivate'
const toggle = card.locator('.q-toggle').first()
const alreadySet = await toggle
.innerText()
.then(text =>
text.toLowerCase().includes(active ? 'activated' : 'deactivated')
)
if (alreadySet && expected === 200) return null
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/${action}`,
() => toggle.click(),
expected
)
if (result) return result
const cardAfterToggle = await findExtensionCard(session, extensionId)
await expect(cardAfterToggle.locator('.q-toggle').first()).toContainText(
active ? 'Activated' : 'Deactivated'
)
return {success: true}
}
async function payToEnableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
await card.getByRole('button', {name: /pay to enable/i}).click()
const dialog = session.page.locator('.q-dialog').filter({hasText: 'Recheck'})
await expect(dialog.last()).toBeVisible()
const invoice = await clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/invoice/enable`,
() =>
dialog
.last()
.getByRole('button', {name: /show qr/i})
.click()
)
await dialog.last().getByRole('button', {name: /close/i}).click()
await payInvoiceViaUi(session, invoice.payment_request || invoice.bolt11)
const cardAfterPayment = await findExtensionCard(session, extensionId)
if (
await cardAfterPayment.getByRole('button', {name: /^enable$/i}).isVisible()
) {
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => cardAfterPayment.getByRole('button', {name: /^enable$/i}).click()
)
}
await cardAfterPayment.getByRole('button', {name: /pay to enable/i}).click()
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => session.page.getByText(/recheck/i).click()
)
}
async function createPayLink(context, account, index, overrides = {}) {
const data = compactObject({
description: `receive payments ${index}`,
min: 11,
comment_chars: 128,
webhook_url: mirrorUrl(),
webhook_headers: '{"h1": "1"}',
webhook_body: '{"b2": 2}',
success_text: 'All goood!',
success_url: 'https://lnbits.com',
max: Number(`${index}1`),
...overrides
})
const link = await jsonRequest(context, 'post', '/lnurlp/api/v1/links', {
headers: apiKeyHeaders(account.adminkey),
data,
expected: 201
})
expect(link.id).toBeTruthy()
expect(link.wallet).toBe(account.walletId)
return link
}
async function createWithdrawLink(context, account, index, overrides = {}) {
const link = await jsonRequest(context, 'post', '/withdraw/api/v1/links', {
headers: apiKeyHeaders(account.adminkey),
data: {
is_unique: false,
use_custom: false,
title: `withdraw ${index}`,
min_withdrawable: 10,
max_withdrawable: index * 10,
uses: 5,
wait_time: 1,
webhook_url: mirrorUrl(),
webhook_headers: '{"h1": "1"}',
webhook_body: '{"b2": 2}',
custom_url: null,
...overrides
},
expected: 201
})
expect(link.id).toBeTruthy()
expect(link.wallet).toBe(account.walletId)
expect(link.title).toBe(`withdraw ${index}`)
return link
}
async function withdrawLnurl(
context,
receiveWallet,
withdrawResponse,
amount = 10,
memo = 'withdraw 1'
) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(receiveWallet.inkey),
data: {
out: false,
amount,
memo,
unit: 'sat',
lnurl_withdraw: withdrawResponse
},
expected: 201
})
}
async function lndhubHeaders(accessToken) {
return {Authorization: `Bearer ${accessToken}`}
}
async function createNip5Domain(context, account, extra = {}) {
const {cost_extra: extraCost = {}, ...extraFields} = extra
return jsonRequest(context, 'post', '/nostrnip5/api/v1/domain', {
headers: apiKeyHeaders(account.adminkey),
data: {
cost_extra: {
max_years: '3',
char_count_cost: [],
rank_cost: [],
...extraCost
},
wallet: account.walletId,
currency: 'USD',
domain: `nostr-${Date.now()}.com`,
cost: '100',
...extraFields
},
expected: 201
})
}
async function createUserNip5Address(context, account, domainId, localPart) {
const address = await jsonRequest(
context,
'post',
`/nostrnip5/api/v1/user/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
config: {relays: ['relay.nostr.com']},
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
local_part: localPart,
domain_id: domainId
},
expected: 201
}
)
expect(address.id).toBeTruthy()
return address
}
async function createNip5Address(
context,
account,
domainId,
localPart,
pubkey
) {
const address = await jsonRequest(
context,
'post',
`/nostrnip5/api/v1/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
config: {relays: ['relay.nostr.com']},
pubkey,
local_part: localPart,
domain_id: domainId
},
expected: 201
}
)
expect(address.id).toBeTruthy()
return address
}
async function activateNip5Address(context, account, domainId, addressId) {
return jsonRequest(
context,
'put',
`/nostrnip5/api/v1/domain/${domainId}/address/${addressId}/activate`,
{
headers: apiKeyHeaders(account.adminkey)
}
)
}
async function getNostrJson(context, domainId, name, expected = 200) {
const url = `/nostrnip5/api/v1/domain/${domainId}/nostr.json`
const response = await context.get(url, {
params: {name},
failOnStatusCode: false
})
await expectStatus(
response,
expected === 404 ? [404, 200] : expected,
`GET ${url}`
)
if (response.status() === 404) return null
const data = await responseJson(response)
if (expected === 404) {
expect(data.names ?? {}).toEqual({})
expect(data.relays ?? {}).toEqual({})
}
return data
}
async function buyNip5Address(context, account, domainId, localPart, options) {
return jsonRequest(
context,
'post',
`/nostrnip5/api/v1/user/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
domain_id: domainId,
local_part: localPart,
pubkey: options.pubkey,
years: options.years,
promo_code: options.promo_code ?? null,
referer: options.referer ?? null,
create_invoice: options.create_invoice ?? false
},
expected: options.expected ?? 201
}
)
}
async function createAuctionRoom(context, owner, data) {
const room = await jsonRequest(
context,
'post',
'/auction_house/api/v1/auction_room',
{
headers: apiKeyHeaders(owner.adminkey),
data,
expected: 201
}
)
expect(room.id).toBeTruthy()
return room
}
async function updateAuctionRoom(context, owner, data) {
const room = await jsonRequest(
context,
'put',
'/auction_house/api/v1/auction_room',
{
headers: apiKeyHeaders(owner.adminkey),
data
}
)
expect(room.id).toBe(data.id)
return room
}
async function createAuctionItem(context, seller, roomId, data) {
const item = await jsonRequest(
context,
'post',
`/auction_house/api/v1/items/${roomId}`,
{
headers: apiKeyHeaders(seller.adminkey),
data,
expected: 201
}
)
expect(item.id).toBeTruthy()
return item
}
async function bidOnItem(context, bidder, itemId, amount, memo) {
const bid = await jsonRequest(
context,
'put',
`/auction_house/api/v1/bids/${itemId}`,
{
headers: apiKeyHeaders(bidder.adminkey),
data: {amount, ln_address: '', memo},
expected: 201
}
)
expect(bid.payment_request).toBeTruthy()
return bid
}
function setupIntegration() {
test.beforeAll(async ({playwright}, testInfo) => {
state.baseURL = testInfo.project.use.baseURL
state.contextFactory = playwright.request
state.adminSession = null
state.adminContext = await newContext()
state.adminWallet = await initServer(state.adminContext)
const manifest = await fetchManifest()
state.extensionById = new Map(
manifest.extensions.map(extension => [extension.id, extension])
)
})
test.afterAll(async () => {
await Promise.all(contexts.map(context => context.dispose()))
await Promise.all(browserContexts.map(context => context.close()))
contexts.splice(0, contexts.length)
browserContexts.splice(0, browserContexts.length)
state.baseURL = null
state.adminContext = null
state.adminWallet = null
state.adminSession = null
state.extensionById = null
state.contextFactory = null
})
}
function compactObject(value) {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined)
)
}
function auctionNip5Webhooks(domainId) {
return {
duration: {days: 7, hours: 0, minutes: 0},
lock_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/lock`,
headers: '',
data: '{\n "transfer_code": "${transfer_code}"\n}'
},
unlock_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/unlock`,
headers: '',
data: '{\n "lock_code": "${lock_code}"\n}'
},
transfer_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/transfer`,
headers: '',
data: '{\n "lock_code": "${lock_code}",\n "new_owner_id": "${new_owner_id}"\n}'
}
}
}
module.exports = {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
clearMirror,
createAccount,
createWallet,
decodePayment,
expectStatus,
fetchManifest,
getPayment,
getPayments,
getWallet,
initServer,
jsonRequest,
latestRelease,
loginAdmin,
lnurlScan,
mirrorUrl,
pageStatus,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension,
newContext,
newUser,
newBrowserSession,
newUserWithUi,
getAdminSession,
extensionName,
refreshExtensionCatalog,
responsePath,
responseMatches,
clickAndCheckResponse,
clickAndGetJsonResponse,
gotoPage,
gotoWallet,
fillQuasarField,
createInvoiceViaUi,
payInvoiceViaUi,
payLnurlViaUi,
withdrawLnurlViaUi,
openExtensionsPage,
findExtensionCard,
openExtensionManager,
installExtensionVersionViaUi,
installLatestExtensionViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
disableExtensionViaUi,
setExtensionActiveViaUi,
payToEnableExtensionViaUi,
createPayLink,
createWithdrawLink,
withdrawLnurl,
lndhubHeaders,
createNip5Domain,
createUserNip5Address,
createNip5Address,
activateNip5Address,
getNostrJson,
buyNip5Address,
createAuctionRoom,
updateAuctionRoom,
createAuctionItem,
bidOnItem,
compactObject,
auctionNip5Webhooks
}
+109
View File
@@ -0,0 +1,109 @@
const fs = require('fs')
const http = require('http')
const path = require('path')
const {spawn} = require('child_process')
const root = path.resolve(__dirname, '..', '..')
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || '5000'
const mirrorPort = process.env.MIRROR_PORT || '8500'
const dataFolder =
process.env.LNBITS_DATA_FOLDER ||
path.join(root, 'tests', 'data', 'integration')
if (process.env.LNBITS_INTEGRATION_RESET_DATA !== 'false') {
fs.rmSync(dataFolder, {recursive: true, force: true})
}
fs.mkdirSync(dataFolder, {recursive: true})
const mirrorRequests = []
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('error', reject)
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
})
}
function parseBody(body) {
if (!body) return null
try {
return JSON.parse(body)
} catch {
return body
}
}
const mirror = http.createServer(async (req, res) => {
const bodyText = await readBody(req)
const body = parseBody(bodyText)
const record = {
method: req.method,
url: req.url,
headers: req.headers,
body,
bodyText,
receivedAt: new Date().toISOString()
}
if (req.url === '/__mirror__/clear') {
mirrorRequests.splice(0, mirrorRequests.length)
res.writeHead(204)
res.end()
return
}
if (req.url === '/__mirror__/requests') {
res.writeHead(200, {'content-type': 'application/json'})
res.end(JSON.stringify(mirrorRequests))
return
}
mirrorRequests.push(record)
res.writeHead(200, {
'content-type': 'application/json',
h1: '1'
})
res.end(JSON.stringify(body ?? record))
})
mirror.listen(Number(mirrorPort), '127.0.0.1', () => {
console.log(`Mirror server listening on http://127.0.0.1:${mirrorPort}`)
})
const env = {
...process.env,
HOST: host,
PORT: port,
LNBITS_DATA_FOLDER: dataFolder,
LNBITS_ADMIN_UI: process.env.LNBITS_ADMIN_UI || 'true',
AUTH_HTTPS_ONLY: process.env.AUTH_HTTPS_ONLY || 'false',
LNBITS_BACKEND_WALLET_CLASS:
process.env.LNBITS_BACKEND_WALLET_CLASS || 'FakeWallet',
LNBITS_EXTENSIONS_DEFAULT_INSTALL:
process.env.LNBITS_EXTENSIONS_DEFAULT_INSTALL ||
'watchonly,satspay,tipjar,tpos,lnurlp,withdraw'
}
const lnbits = spawn('uv', ['run', 'lnbits', '--host', host, '--port', port], {
cwd: root,
env,
stdio: 'inherit'
})
function shutdown(signal) {
mirror.close()
if (!lnbits.killed) {
lnbits.kill(signal)
}
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
lnbits.on('exit', code => {
mirror.close()
process.exit(code ?? 0)
})