Compare commits
1 Commits
master
...
cursor/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd6157757 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 18
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 18
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@ -2,11 +2,11 @@ openapi: 3.0.0
|
||||
info:
|
||||
title: GroundControl push server API
|
||||
description: Push notifications server for BlueWallet
|
||||
version: 0.0.14
|
||||
version: 0.0.13
|
||||
servers:
|
||||
- url: http://localhost:3001
|
||||
- url: https://groundcontrol-bluewallet-stg.herokuapp.com
|
||||
- url: https://groundcontrol-bluewallet.herokuapp.com
|
||||
- url: https://groundcontrol.bluewallet.io/
|
||||
paths:
|
||||
/lightningInvoiceGotSettled:
|
||||
post:
|
||||
|
||||
5644
package-lock.json
generated
5644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "groundcontrol",
|
||||
"version": "3.1.3",
|
||||
"version": "3.0.1",
|
||||
"description": "GroundControl push server API",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node": "18.7.16",
|
||||
"openapi-typescript": "^5.4.1",
|
||||
"prettier": "2.0.5",
|
||||
"vitest": "^3.2.6"
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.5",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.2",
|
||||
"express": "^4.22.2",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^6.6.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"helmet": "^5.1.0",
|
||||
@ -20,9 +20,9 @@
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"mysql2": "^3.9.8",
|
||||
"node-fetch": "^3.3.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typeorm": "^1.0.0",
|
||||
"reflect-metadata": "^0.1.10",
|
||||
"ts-node": "10.9.1",
|
||||
"typeorm": "0.3.14",
|
||||
"typescript": "4.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
export const ADDRESS_IGNORE_LIST = [
|
||||
"1NXNHZr6Pbzi3VStcgaxwEhspTWNXQ3Q4G",
|
||||
"bc1qee7hk4a3k3km7j8hwclm0pkl76dhhgxay5nevu",
|
||||
"bc1qclyfsxuu8vcwq38yygs5zrskwacq8sjlyvk9mx",
|
||||
"bc1qltxjty7xfrnkzlrhmxpcekknr3uncne8sht7rn",
|
||||
"bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej",
|
||||
"bc1qw8wrek2m7nlqldll66ajnwr9mh64syvkt67zlu",
|
||||
"1111111111111111111114oLvT2",
|
||||
"1BrasiLb2KMbdtuhb1chAVnS2FvcNGfV9J",
|
||||
"1GQdrgqAbkeEPUef1UpiTc4X1mUHMcyuGW",
|
||||
"1KHwtS5mn7NMUm7Ls7Y1XwxLqMriLdaGbX",
|
||||
"bc1q7cyrfmck2ffu2ud3rn5l5a8yv6f0chkp0zpemf",
|
||||
"bc1qwfgdjyy95aay2686fn74h6a4nu9eev6np7q4fn204dkj3274frlqrskvx0",
|
||||
"bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
|
||||
"bc1qrnn4wfhgz2e0etek66sh3n9l6k99alxk044mhr",
|
||||
"13nCMaHDNRGM29UfMMkhUQjHkVYY1ZyrpU",
|
||||
"bc1qel7tps3wu6zqztaanczvt76hffwh7k06jd8r9xh2v3ztpa5ty5dsz358ys",
|
||||
"bc1qyzxdu4px4jy8gwhcj82zpv7qzhvc0fvumgnh0r",
|
||||
"bc1qyemk24czaa6a2nr89nrz775ewvptxg7yfe750u",
|
||||
"bc1qq904ynep5mvwpjxdlyecgeupg22dm8am6cfvgq",
|
||||
"37biYvTEcBVMoR1NGkPTGvHUuLTrzcLpiv",
|
||||
"bc1qq9tk3uhcx58y5qvmzs50nhs49m0pdkmrfpkzs4",
|
||||
"1Kr6QSydW9bFQG1mXiPNNu6WpJGmUa9i1g",
|
||||
"3DGxAYYUA61WrrdbBac8Ra9eA9peAQwTJF",
|
||||
"bc1qmgj3w0aw5455y9s4zfhts2kxm4qstwyjx5f907",
|
||||
"bc1qgrxsrmrhsapvh9addyx6sh8j4rw0sn9xtur9uq",
|
||||
"bc1qp3f7vnmuj4pjxpfvkvf7yznac9h9r5arlv4fpv",
|
||||
"bc1qnsupj8eqya02nm8v6tmk93zslu2e2z8chlmcej",
|
||||
"bc1qt5m8xeclsja4lkfvl2nvmrt6z9vg60sd8w2kc6",
|
||||
"bc1qsatlphjcgvzlt9xhsgn0dnjus5jgwg83dr05c6",
|
||||
"bc1quq29mutxkgxmjfdr7ayj3zd9ad0ld5mrhh89l2",
|
||||
"bc1qe9nagya0tvfhvymt8sejwedlukwq4a094h6ht9",
|
||||
"1GrwDkr33gT6LuumniYjKEGjTLhsL5kmqC",
|
||||
"33WSGLeVoEpuZDjB54HKZ1y5YsERELoVNq",
|
||||
"3A8n8rwMnHnt2BqnjW4R73eZCMcUDTpYvv",
|
||||
"bc1qns9f7yfx3ry9lj6yz7c9er0vwa0ye2eklpzqfw",
|
||||
"38XnPvu9PmonFU9WouPXUjYbW91wa5MerL",
|
||||
"1Bo8hs81QwnR6A3oFBXcWNZXgtwpfgByb3",
|
||||
"37Z6neB2wDC3hsPDHLy2n2kFahNNU3eos8",
|
||||
"1CK6KHY6MHgYvmRQ4PAafKYDrg1ejbH1cE",
|
||||
"36XWTfSYJJz3WSNPZVZ3q3aa5eFuJHR9nu",
|
||||
"bc1qc8ee9860cdnkyej0ag5hf49pcx7uvz89lkwpr9",
|
||||
"bc1q7ug4w4as2sefar89q057hnmxkakp58a25535ttlmurn6cncs8tms4e7gp2",
|
||||
"3JodN7GmkHdPgKj9G7HCkn9NDLhrcWCjVN",
|
||||
"bc1qujepl0k5n0ga2e86yskvxa6auehpf6dlf84dx0",
|
||||
"bc1qg9lgqyukp2rup5x4frrz7hhw7988k5q26luakm",
|
||||
"3JSdUu1ivm3rqMvuCTAdAj6Dc2hdVhHiEe",
|
||||
"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
"bc1qr35hws365juz5rtlsjtvmulu97957kqvr3zpw3",
|
||||
"bc1qzlcvh2k3xs2jyzvya7xmx8l2ylpt259txy7wnd",
|
||||
"bc1qprdf80adfz7aekh5nejjfrp3jksc8r929svpxk",
|
||||
"35iMHbUZeTssxBodiHwEEkb32jpBfVueEL",
|
||||
"3QiETomgUhPu573ZvhXbdofq7y5ocNS1ie",
|
||||
"3HcEUguUZ4vyyMAPWDPUDjLqz882jXwMfV",
|
||||
"3J7cUjBZxvGRCwFBz3q23zAsnhFfZrDSSU",
|
||||
"bc1qamgjuxaywqls56h7rg7afga3m6rgqwfkew688k",
|
||||
"1G47mSr3oANXMafVrR8UC4pzV7FEAzo3r9",
|
||||
"17StnGroPUsNXBq4AVJQ1fqGftoFZh3zva",
|
||||
"1FWQiwK27EnGXb6BiBMRLJvunJQZZPMcGd",
|
||||
"16r7U7GqbVPeKukgfd3mUN9LCkuoKbfpXM",
|
||||
"bc1q22hp7n28whk5h94z93vm05hfx2zxs8ca9gglk7",
|
||||
"bc1q0wu0tqp2u3rtunjl0h0rsl9pvf86acy6sep63st0lp7lgg67ykzqeq89pn",
|
||||
"bc1qryhgpmfv03qjhhp2dj8nw8g4ewg08jzmgy3cyx",
|
||||
"bc1quhruqrghgcca950rvhtrg7cpd7u8k6svpzgzmrjy8xyukacl5lkq0r8l2d",
|
||||
"bc1q0s6rca52mjumq8fjuz4j6ka5h9jhq57esyd40r",
|
||||
"bc1qjguxrqjx9qnpmklzymdr0y6mqxrth9d3swzdpr",
|
||||
"bc1qr4dl5wa7kl8yu792dceg9z5knl2gkn220lk7a9",
|
||||
"1Dy8gSw3Zbpatr4bGn7zR4phNX18XuTD56",
|
||||
"bc1qcdqj2smprre85c78d942wx5tauw5n7uw92r7wr",
|
||||
"bc1qmhq4sgtchfgh6ul75x3rsuegt55mef0zx3ehm2",
|
||||
"bc1qws8yeyfxzykuq7tevwwxezyv3ad99emlyy9uls",
|
||||
"bc1q42kvqt0e3f27qhd2ucnprarl5ywpuj7tu0h9v2",
|
||||
"bc1q5k9tyr30xhmvmnj2z2cx0psprz44ksnmpuw7q8",
|
||||
"bc1qfpeps3wcmzk422hvm5jeq5lelnqlzznjwyfy69",
|
||||
"bc1qfy4ck4xpu3vg2226ew9jssly6xvc8w5xhjkzxz",
|
||||
"bc1q9yn6zdkjjlh0z5y6sqpdvwq7pwkeh5r0ka28ad",
|
||||
"bc1qpsys7sfk5u7ue3lffwzszzvffhtku78kr0vva4",
|
||||
"bc1q0qfzuge7vr5s2xkczrjkccmxemlyyn8mhx298v",
|
||||
"bc1pz454nh8mdcq093zfcc4qe2hw5ejha98k3e6n9apqkhemqv9w5kuqh2xcst",
|
||||
"bc1qynygs8d3ju9cpum9pepmh94qk57tf67paka78g",
|
||||
"bc1q3zcdunpmqgn8enyxa3smu7fwrfvya35dz3uvjy",
|
||||
"bc1qlxdhlndfr0uumw6as0g3w8s3n0k2drkzs4f9g5",
|
||||
"bc1q4vxcxw7mpg9dcryqu0kav8awrn7qk5e6wgs3hg",
|
||||
"3KMmeqPeQcngyTehdfSwsGqvxfU7J7qtc8",
|
||||
];
|
||||
@ -9,7 +9,6 @@ import { PushLog } from "../entity/PushLog";
|
||||
import { KeyValue } from "../entity/KeyValue";
|
||||
import dataSource from "../data-source";
|
||||
import { paths, components } from "../openapi/api";
|
||||
import { ADDRESS_IGNORE_LIST } from "../address-ignore-list";
|
||||
require("dotenv").config();
|
||||
const pck = require("../../package.json");
|
||||
if (!process.env.JAWSDB_MARIA_URL || !process.env.GOOGLE_KEY_FILE || !process.env.APNS_P8 || !process.env.APNS_TOPIC || !process.env.APPLE_TEAM_ID || !process.env.APNS_P8_KID || !process.env.GOOGLE_PROJECT_ID) {
|
||||
@ -19,24 +18,143 @@ if (!process.env.JAWSDB_MARIA_URL || !process.env.GOOGLE_KEY_FILE || !process.en
|
||||
|
||||
const LAST_PROCESSED_BLOCK = "LAST_PROCESSED_BLOCK";
|
||||
|
||||
const ADDRESS_IGNORE_LIST = [
|
||||
"1NXNHZr6Pbzi3VStcgaxwEhspTWNXQ3Q4G",
|
||||
"bc1qee7hk4a3k3km7j8hwclm0pkl76dhhgxay5nevu",
|
||||
"bc1qclyfsxuu8vcwq38yygs5zrskwacq8sjlyvk9mx",
|
||||
"bc1qltxjty7xfrnkzlrhmxpcekknr3uncne8sht7rn",
|
||||
"bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej",
|
||||
"bc1qw8wrek2m7nlqldll66ajnwr9mh64syvkt67zlu",
|
||||
"1111111111111111111114oLvT2",
|
||||
"1BrasiLb2KMbdtuhb1chAVnS2FvcNGfV9J",
|
||||
"1GQdrgqAbkeEPUef1UpiTc4X1mUHMcyuGW",
|
||||
"1KHwtS5mn7NMUm7Ls7Y1XwxLqMriLdaGbX",
|
||||
"bc1q7cyrfmck2ffu2ud3rn5l5a8yv6f0chkp0zpemf",
|
||||
"bc1qwfgdjyy95aay2686fn74h6a4nu9eev6np7q4fn204dkj3274frlqrskvx0",
|
||||
"bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h",
|
||||
"bc1qrnn4wfhgz2e0etek66sh3n9l6k99alxk044mhr",
|
||||
"13nCMaHDNRGM29UfMMkhUQjHkVYY1ZyrpU",
|
||||
"bc1qel7tps3wu6zqztaanczvt76hffwh7k06jd8r9xh2v3ztpa5ty5dsz358ys",
|
||||
"bc1qyzxdu4px4jy8gwhcj82zpv7qzhvc0fvumgnh0r",
|
||||
"bc1qyemk24czaa6a2nr89nrz775ewvptxg7yfe750u",
|
||||
"bc1qq904ynep5mvwpjxdlyecgeupg22dm8am6cfvgq",
|
||||
"37biYvTEcBVMoR1NGkPTGvHUuLTrzcLpiv",
|
||||
"bc1qq9tk3uhcx58y5qvmzs50nhs49m0pdkmrfpkzs4",
|
||||
"1Kr6QSydW9bFQG1mXiPNNu6WpJGmUa9i1g",
|
||||
"3DGxAYYUA61WrrdbBac8Ra9eA9peAQwTJF",
|
||||
"bc1qmgj3w0aw5455y9s4zfhts2kxm4qstwyjx5f907",
|
||||
"bc1qgrxsrmrhsapvh9addyx6sh8j4rw0sn9xtur9uq",
|
||||
"bc1qp3f7vnmuj4pjxpfvkvf7yznac9h9r5arlv4fpv",
|
||||
"bc1qnsupj8eqya02nm8v6tmk93zslu2e2z8chlmcej",
|
||||
"bc1qt5m8xeclsja4lkfvl2nvmrt6z9vg60sd8w2kc6",
|
||||
"bc1qsatlphjcgvzlt9xhsgn0dnjus5jgwg83dr05c6",
|
||||
"bc1quq29mutxkgxmjfdr7ayj3zd9ad0ld5mrhh89l2",
|
||||
"bc1qe9nagya0tvfhvymt8sejwedlukwq4a094h6ht9",
|
||||
"1GrwDkr33gT6LuumniYjKEGjTLhsL5kmqC",
|
||||
"33WSGLeVoEpuZDjB54HKZ1y5YsERELoVNq",
|
||||
"3A8n8rwMnHnt2BqnjW4R73eZCMcUDTpYvv",
|
||||
"bc1qns9f7yfx3ry9lj6yz7c9er0vwa0ye2eklpzqfw",
|
||||
"38XnPvu9PmonFU9WouPXUjYbW91wa5MerL",
|
||||
"1Bo8hs81QwnR6A3oFBXcWNZXgtwpfgByb3",
|
||||
"37Z6neB2wDC3hsPDHLy2n2kFahNNU3eos8",
|
||||
"1CK6KHY6MHgYvmRQ4PAafKYDrg1ejbH1cE",
|
||||
"36XWTfSYJJz3WSNPZVZ3q3aa5eFuJHR9nu",
|
||||
"bc1qc8ee9860cdnkyej0ag5hf49pcx7uvz89lkwpr9",
|
||||
"bc1q7ug4w4as2sefar89q057hnmxkakp58a25535ttlmurn6cncs8tms4e7gp2",
|
||||
"3JodN7GmkHdPgKj9G7HCkn9NDLhrcWCjVN",
|
||||
"bc1qujepl0k5n0ga2e86yskvxa6auehpf6dlf84dx0",
|
||||
"bc1qg9lgqyukp2rup5x4frrz7hhw7988k5q26luakm",
|
||||
"3JSdUu1ivm3rqMvuCTAdAj6Dc2hdVhHiEe",
|
||||
"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
"bc1qr35hws365juz5rtlsjtvmulu97957kqvr3zpw3",
|
||||
"bc1qzlcvh2k3xs2jyzvya7xmx8l2ylpt259txy7wnd",
|
||||
"bc1qprdf80adfz7aekh5nejjfrp3jksc8r929svpxk",
|
||||
"35iMHbUZeTssxBodiHwEEkb32jpBfVueEL",
|
||||
"3QiETomgUhPu573ZvhXbdofq7y5ocNS1ie",
|
||||
"3HcEUguUZ4vyyMAPWDPUDjLqz882jXwMfV",
|
||||
"3J7cUjBZxvGRCwFBz3q23zAsnhFfZrDSSU",
|
||||
"bc1qamgjuxaywqls56h7rg7afga3m6rgqwfkew688k",
|
||||
"1G47mSr3oANXMafVrR8UC4pzV7FEAzo3r9",
|
||||
"17StnGroPUsNXBq4AVJQ1fqGftoFZh3zva",
|
||||
"1FWQiwK27EnGXb6BiBMRLJvunJQZZPMcGd",
|
||||
"16r7U7GqbVPeKukgfd3mUN9LCkuoKbfpXM",
|
||||
"bc1q22hp7n28whk5h94z93vm05hfx2zxs8ca9gglk7",
|
||||
"bc1q0wu0tqp2u3rtunjl0h0rsl9pvf86acy6sep63st0lp7lgg67ykzqeq89pn",
|
||||
"bc1qryhgpmfv03qjhhp2dj8nw8g4ewg08jzmgy3cyx",
|
||||
"bc1quhruqrghgcca950rvhtrg7cpd7u8k6svpzgzmrjy8xyukacl5lkq0r8l2d",
|
||||
"bc1q0s6rca52mjumq8fjuz4j6ka5h9jhq57esyd40r",
|
||||
"bc1qjguxrqjx9qnpmklzymdr0y6mqxrth9d3swzdpr",
|
||||
"bc1qr4dl5wa7kl8yu792dceg9z5knl2gkn220lk7a9",
|
||||
"1Dy8gSw3Zbpatr4bGn7zR4phNX18XuTD56",
|
||||
"bc1qcdqj2smprre85c78d942wx5tauw5n7uw92r7wr",
|
||||
"bc1qmhq4sgtchfgh6ul75x3rsuegt55mef0zx3ehm2",
|
||||
"bc1qws8yeyfxzykuq7tevwwxezyv3ad99emlyy9uls",
|
||||
"bc1q42kvqt0e3f27qhd2ucnprarl5ywpuj7tu0h9v2",
|
||||
"bc1q5k9tyr30xhmvmnj2z2cx0psprz44ksnmpuw7q8",
|
||||
];
|
||||
|
||||
let connection: DataSource;
|
||||
|
||||
const pushLogPurge = () => {
|
||||
console.log("purging PushLog...");
|
||||
let today = new Date();
|
||||
connection
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(PushLog)
|
||||
.where("created <= :currentDate", { currentDate: new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000) })
|
||||
.execute()
|
||||
.then(() => console.log("PushLog purged ok"))
|
||||
.catch((error) => console.log("error purging PushLog:", error));
|
||||
};
|
||||
|
||||
const purgeOldTxidSubscriptions = () => {
|
||||
console.log("purging TokenToTxid...");
|
||||
let today = new Date();
|
||||
connection
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(TokenToTxid)
|
||||
.where("created <= :currentDate", { currentDate: new Date(today.getTime() - 3 * 30 * 24 * 60 * 60 * 1000) }) // 3 mo
|
||||
.execute()
|
||||
.then(() => console.log("TokenToTxid purged ok"))
|
||||
.catch((error) => console.log("error purging TokenToTxid:", error));
|
||||
};
|
||||
|
||||
const purgeIgnoredAddressesSubscriptions = () => {
|
||||
console.log("Purging addresses subscriptions...");
|
||||
connection
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(TokenToAddress)
|
||||
.where("address IN (:...id)", { id: ADDRESS_IGNORE_LIST })
|
||||
.execute()
|
||||
.then(() => console.log("Addresses subscriptions purged ok"))
|
||||
.catch((error) => console.log("error purging addresses subscriptions:", error));
|
||||
};
|
||||
|
||||
dataSource.initialize().then((c) => {
|
||||
console.log("db connected");
|
||||
connection = c;
|
||||
purgeIgnoredAddressesSubscriptions();
|
||||
pushLogPurge();
|
||||
purgeOldTxidSubscriptions();
|
||||
setInterval(pushLogPurge, 3600 * 1000);
|
||||
});
|
||||
|
||||
export class GroundController {
|
||||
private _tokenToAddressRepository;
|
||||
private _tokenToHashRepository;
|
||||
private _tokenToTxidRepository;
|
||||
private _tokenConfigurationRepository;
|
||||
private _sendQueueRepository;
|
||||
private _connection: DataSource;
|
||||
|
||||
constructor(connection: DataSource) {
|
||||
this._connection = connection;
|
||||
}
|
||||
|
||||
get tokenToAddressRepository() {
|
||||
if (this._tokenToAddressRepository) {
|
||||
return this._tokenToAddressRepository;
|
||||
}
|
||||
|
||||
this._tokenToAddressRepository = this._connection.getRepository(TokenToAddress);
|
||||
this._tokenToAddressRepository = connection.getRepository(TokenToAddress);
|
||||
return this._tokenToAddressRepository;
|
||||
}
|
||||
|
||||
@ -44,7 +162,7 @@ export class GroundController {
|
||||
if (this._tokenToHashRepository) {
|
||||
return this._tokenToHashRepository;
|
||||
}
|
||||
this._tokenToHashRepository = this._connection.getRepository(TokenToHash);
|
||||
this._tokenToHashRepository = connection.getRepository(TokenToHash);
|
||||
return this._tokenToHashRepository;
|
||||
}
|
||||
|
||||
@ -53,7 +171,7 @@ export class GroundController {
|
||||
return this._tokenToTxidRepository;
|
||||
}
|
||||
|
||||
this._tokenToTxidRepository = this._connection.getRepository(TokenToTxid);
|
||||
this._tokenToTxidRepository = connection.getRepository(TokenToTxid);
|
||||
return this._tokenToTxidRepository;
|
||||
}
|
||||
|
||||
@ -62,7 +180,7 @@ export class GroundController {
|
||||
return this._tokenConfigurationRepository;
|
||||
}
|
||||
|
||||
this._tokenConfigurationRepository = this._connection.getRepository(TokenConfiguration);
|
||||
this._tokenConfigurationRepository = connection.getRepository(TokenConfiguration);
|
||||
return this._tokenConfigurationRepository;
|
||||
}
|
||||
|
||||
@ -71,7 +189,7 @@ export class GroundController {
|
||||
return this._sendQueueRepository;
|
||||
}
|
||||
|
||||
this._sendQueueRepository = this._connection.getRepository(SendQueue);
|
||||
this._sendQueueRepository = connection.getRepository(SendQueue);
|
||||
return this._sendQueueRepository;
|
||||
}
|
||||
|
||||
@ -200,14 +318,15 @@ export class GroundController {
|
||||
async lightningInvoiceGotSettled(request: Request, response: Response, next: NextFunction) {
|
||||
const body: paths["/lightningInvoiceGotSettled"]["post"]["requestBody"]["content"]["application/json"] = request.body;
|
||||
|
||||
if (!body.hash) {
|
||||
response.status(500).send("preimage hash is not provided");
|
||||
const hashShouldBe = require("crypto").createHash("sha256").update(Buffer.from(body.preimage, "hex")).digest("hex");
|
||||
if (hashShouldBe !== body.hash) {
|
||||
response.status(500).send("preimage doesnt match hash");
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenToHashAll = await this.tokenToHashRepository.find({
|
||||
where: {
|
||||
hash: body.hash,
|
||||
hash: hashShouldBe,
|
||||
},
|
||||
});
|
||||
for (const tokenToHash of tokenToHashAll) {
|
||||
@ -219,7 +338,7 @@ export class GroundController {
|
||||
level: "transactions",
|
||||
os: tokenToHash.os === "android" ? "android" : "ios", //hacky
|
||||
token: tokenToHash.token,
|
||||
hash: body.hash,
|
||||
hash: hashShouldBe,
|
||||
memo: body.memo,
|
||||
};
|
||||
|
||||
@ -232,13 +351,13 @@ export class GroundController {
|
||||
}
|
||||
|
||||
async ping(request: Request, response: Response, next: NextFunction) {
|
||||
const keyValueRepository = this._connection.getRepository(KeyValue);
|
||||
const sendQueueRepository = this._connection.getRepository(SendQueue);
|
||||
const keyValueRepository = connection.getRepository(KeyValue);
|
||||
const sendQueueRepository = connection.getRepository(SendQueue);
|
||||
const keyVal = await keyValueRepository.findOneBy({ key: LAST_PROCESSED_BLOCK });
|
||||
const send_queue_size = await sendQueueRepository.count();
|
||||
|
||||
const ts = new Date(+new Date() - 1000 * 3600 * 24).toISOString();
|
||||
const sent_24h = await this._connection.createQueryBuilder(PushLog, "PushLog").where("PushLog.created >= :ts", { ts }).getCount();
|
||||
const sent_24h = await connection.createQueryBuilder(PushLog, "PushLog").where("PushLog.created >= :ts", { ts }).getCount();
|
||||
|
||||
const serverInfo: paths["/ping"]["get"]["responses"]["200"]["content"]["application/json"] = {
|
||||
name: pck.name,
|
||||
|
||||
102
src/index.ts
102
src/index.ts
@ -4,11 +4,6 @@ import * as bodyParser from "body-parser";
|
||||
import { Request, Response } from "express";
|
||||
import { Routes } from "./routes";
|
||||
import dataSource from "./data-source";
|
||||
import { PushLog } from "./entity/PushLog";
|
||||
import { TokenToTxid } from "./entity/TokenToTxid";
|
||||
import { TokenToAddress } from "./entity/TokenToAddress";
|
||||
import { DataSource } from "typeorm";
|
||||
import { ADDRESS_IGNORE_LIST } from "./address-ignore-list";
|
||||
require("dotenv").config();
|
||||
const helmet = require("helmet");
|
||||
const cors = require("cors");
|
||||
@ -17,104 +12,11 @@ if (!process.env.JAWSDB_MARIA_URL || !process.env.GOOGLE_KEY_FILE || !process.en
|
||||
process.exit();
|
||||
}
|
||||
|
||||
let connection: DataSource;
|
||||
|
||||
const pushLogPurge = () => {
|
||||
console.log("purging PushLog...");
|
||||
let today = new Date();
|
||||
connection
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(PushLog)
|
||||
.where("created <= :currentDate", { currentDate: new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000) })
|
||||
.execute()
|
||||
.then(() => console.log("PushLog purged ok"))
|
||||
.catch((error) => console.log("error purging PushLog:", error));
|
||||
};
|
||||
|
||||
const purgeOldTxidSubscriptions = () => {
|
||||
console.log("purging TokenToTxid...");
|
||||
let today = new Date();
|
||||
connection
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(TokenToTxid)
|
||||
.where("created <= :currentDate", { currentDate: new Date(today.getTime() - 3 * 30 * 24 * 60 * 60 * 1000) }) // 3 mo
|
||||
.execute()
|
||||
.then(() => console.log("TokenToTxid purged ok"))
|
||||
.catch((error) => console.log("error purging TokenToTxid:", error));
|
||||
};
|
||||
|
||||
const purgeIgnoredAddressesSubscriptions = () => {
|
||||
console.log("Purging addresses subscriptions...");
|
||||
connection
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(TokenToAddress)
|
||||
.where("address IN (:...id)", { id: ADDRESS_IGNORE_LIST })
|
||||
.execute()
|
||||
.then(() => console.log("Addresses subscriptions purged ok"))
|
||||
.catch((error) => console.log("error purging addresses subscriptions:", error));
|
||||
};
|
||||
|
||||
const killSleepingMySQLProcesses = () => {
|
||||
console.log("Checking for sleeping MySQL processes...");
|
||||
|
||||
// Query to find processes sleeping for too long
|
||||
const query = `
|
||||
SELECT id, user, host, db, command, time, state, info
|
||||
FROM information_schema.processlist
|
||||
WHERE command = 'Sleep' AND time > 100 AND id != CONNECTION_ID()
|
||||
`;
|
||||
|
||||
connection
|
||||
.query(query)
|
||||
.then((sleepingProcesses: any[]) => {
|
||||
if (sleepingProcesses.length > 0) {
|
||||
console.log(`Found ${sleepingProcesses.length} old sleeping processes`);
|
||||
|
||||
// Kill each sleeping process
|
||||
const killPromises = sleepingProcesses.map((process) => {
|
||||
console.log(`Killing process ID ${process.id} (user: ${process.user}, host: ${process.host}, sleeping for ${process.time}s)`);
|
||||
return connection
|
||||
.query(`KILL ${process.id}`)
|
||||
.then(() => console.log(`Successfully killed process ${process.id}`))
|
||||
.catch((error) => console.log(`Error killing process ${process.id}:`, error.message));
|
||||
});
|
||||
|
||||
return Promise.all(killPromises);
|
||||
} else {
|
||||
console.log("No old sleeping processes found");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Error checking sleeping processes:", error.message);
|
||||
});
|
||||
};
|
||||
|
||||
dataSource
|
||||
.initialize()
|
||||
.then(async (c) => {
|
||||
console.log("db connected");
|
||||
|
||||
connection = c;
|
||||
purgeIgnoredAddressesSubscriptions();
|
||||
pushLogPurge();
|
||||
purgeOldTxidSubscriptions();
|
||||
killSleepingMySQLProcesses();
|
||||
setInterval(pushLogPurge, 3600 * 1000);
|
||||
setInterval(killSleepingMySQLProcesses, 100 * 1000);
|
||||
|
||||
.then(async (connection) => {
|
||||
// create express app
|
||||
const app = express();
|
||||
// some misconfigured clients have a trailing slash in their saved base url, producing
|
||||
// requests like `//getTokenConfiguration` which express treats as 404. strip the leading slash.
|
||||
app.use((req, _res, next) => {
|
||||
if (req.url.startsWith("//")) {
|
||||
req.url = req.url.slice(1);
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(bodyParser.json());
|
||||
app.use(cors());
|
||||
app.use(helmet.hidePoweredBy());
|
||||
@ -123,7 +25,7 @@ dataSource
|
||||
// register express routes from defined application routes
|
||||
Routes.forEach((route) => {
|
||||
(app as any)[route.method](route.route, (req: Request, res: Response, next: Function) => {
|
||||
const result = new (route.controller as any)(c)[route.action](req, res, next);
|
||||
const result = new (route.controller as any)()[route.action](req, res, next);
|
||||
if (result instanceof Promise) {
|
||||
result.then((result) => (result !== null && result !== undefined ? res.send(result) : undefined));
|
||||
} else if (result !== null && result !== undefined) {
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LruCache } from "./lru-cache";
|
||||
|
||||
describe("LruCache", () => {
|
||||
it("evicts the oldest txid when the cache exceeds its maximum size", () => {
|
||||
const cache = new LruCache(2);
|
||||
|
||||
cache.add("tx-1");
|
||||
cache.add("tx-2");
|
||||
cache.add("tx-3");
|
||||
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.has("tx-1")).toBe(false);
|
||||
expect(cache.has("tx-2")).toBe(true);
|
||||
expect(cache.has("tx-3")).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes recently seen txids before evicting", () => {
|
||||
const cache = new LruCache(2);
|
||||
|
||||
cache.add("tx-1");
|
||||
cache.add("tx-2");
|
||||
expect(cache.has("tx-1")).toBe(true);
|
||||
cache.add("tx-3");
|
||||
|
||||
expect(cache.has("tx-1")).toBe(true);
|
||||
expect(cache.has("tx-2")).toBe(false);
|
||||
expect(cache.has("tx-3")).toBe(true);
|
||||
});
|
||||
|
||||
it("evicts in least-recently-used order across many interleaved adds and reads", () => {
|
||||
const cache = new LruCache(3);
|
||||
|
||||
cache.add("a");
|
||||
cache.add("b");
|
||||
cache.add("c");
|
||||
|
||||
cache.has("a"); // refresh a -> order: b, c, a
|
||||
cache.add("d"); // evict oldest (b)
|
||||
expect(cache.has("b")).toBe(false);
|
||||
|
||||
// order now: c, a, d
|
||||
cache.add("e"); // evict oldest (c)
|
||||
expect(cache.has("c")).toBe(false);
|
||||
expect(cache.has("a")).toBe(true);
|
||||
expect(cache.has("d")).toBe(true);
|
||||
expect(cache.has("e")).toBe(true);
|
||||
expect(cache.size).toBe(3);
|
||||
});
|
||||
|
||||
it("stays bounded and correct under heavy churn", () => {
|
||||
const max = 1000;
|
||||
const cache = new LruCache(max);
|
||||
for (let i = 0; i < 50000; i++) cache.add("tx-" + i);
|
||||
|
||||
expect(cache.size).toBe(max);
|
||||
expect(cache.has("tx-0")).toBe(false);
|
||||
expect(cache.has("tx-49999")).toBe(true);
|
||||
expect(cache.has("tx-49000")).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,46 +0,0 @@
|
||||
export class LruCache {
|
||||
private readonly entries = new Map<string, true>();
|
||||
// Persistent forward iterator used for eviction. Creating a fresh
|
||||
// `entries.keys().next()` on every eviction is O(n) in the number of
|
||||
// tombstones left at the head of the Map by prior deletes, which degrades
|
||||
// to ~O(n^2) under churn. Advancing a single long-lived iterator instead
|
||||
// keeps eviction amortized O(1) and is the only thing that scaled in the
|
||||
// mempool-sized benchmark (~270ms vs ~20s for 300k evictions).
|
||||
private evictionCursor = this.entries.keys();
|
||||
|
||||
constructor(private readonly maxSize: number) {
|
||||
if (maxSize < 1) throw new Error("maxSize must be greater than zero");
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
has(key: string) {
|
||||
if (!this.entries.has(key)) return false;
|
||||
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
add(key: string) {
|
||||
if (!key) return;
|
||||
|
||||
if (this.entries.has(key)) this.entries.delete(key);
|
||||
this.entries.set(key, true);
|
||||
|
||||
while (this.entries.size > this.maxSize) this.evictOldest();
|
||||
}
|
||||
|
||||
private evictOldest() {
|
||||
let next = this.evictionCursor.next();
|
||||
if (next.done) {
|
||||
// Cursor caught up to the tail (or was created on an empty map); restart
|
||||
// it from the current oldest entry.
|
||||
this.evictionCursor = this.entries.keys();
|
||||
next = this.evictionCursor.next();
|
||||
}
|
||||
if (!next.done) this.entries.delete(next.value);
|
||||
}
|
||||
}
|
||||
@ -1,638 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { DataSource } from "typeorm";
|
||||
import { components } from "../openapi/api";
|
||||
|
||||
// Mock all TypeORM entities to avoid decorator issues
|
||||
vi.mock("../entity/PushLog", () => ({
|
||||
PushLog: class PushLog {},
|
||||
}));
|
||||
vi.mock("../entity/TokenToAddress", () => ({
|
||||
TokenToAddress: class TokenToAddress {},
|
||||
}));
|
||||
vi.mock("../entity/TokenToHash", () => ({
|
||||
TokenToHash: class TokenToHash {},
|
||||
}));
|
||||
vi.mock("../entity/TokenToTxid", () => ({
|
||||
TokenToTxid: class TokenToTxid {},
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("google-auth-library");
|
||||
vi.mock("jsonwebtoken");
|
||||
vi.mock("http2");
|
||||
vi.mock("dotenv", () => ({
|
||||
config: vi.fn(),
|
||||
}));
|
||||
vi.mock("fs", () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock environment variables
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe("GroundControlToMajorTom", () => {
|
||||
let mockDataSource: DataSource;
|
||||
let mockRepository: any;
|
||||
let mockQueryBuilder: any;
|
||||
let GroundControlToMajorTom: any;
|
||||
let PushLog: any;
|
||||
let TokenToAddress: any;
|
||||
let TokenToHash: any;
|
||||
let TokenToTxid: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up environment variables
|
||||
process.env.APNS_P8 = "6d6f636b5f6170706c655f6b6579";
|
||||
process.env.APPLE_TEAM_ID = "MOCK_TEAM_ID";
|
||||
process.env.APNS_P8_KID = "MOCK_KEY_ID";
|
||||
process.env.GOOGLE_KEY_FILE = "6d6f636b5f676f6f676c655f6b6579";
|
||||
process.env.GOOGLE_PROJECT_ID = "mock-project-id";
|
||||
process.env.APNS_TOPIC = "com.mock.app";
|
||||
|
||||
// Mock QueryBuilder
|
||||
mockQueryBuilder = {
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
execute: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// Mock Repository
|
||||
mockRepository = {
|
||||
save: vi.fn().mockResolvedValue({}),
|
||||
createQueryBuilder: vi.fn().mockReturnValue(mockQueryBuilder),
|
||||
};
|
||||
|
||||
// Mock DataSource
|
||||
mockDataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(mockRepository),
|
||||
} as any;
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Dynamically import the class after setting up environment
|
||||
const groundControlModule = await import("../class/GroundControlToMajorTom");
|
||||
GroundControlToMajorTom = groundControlModule.GroundControlToMajorTom;
|
||||
|
||||
const entityModules = await Promise.all([import("../entity/PushLog"), import("../entity/TokenToAddress"), import("../entity/TokenToHash"), import("../entity/TokenToTxid")]);
|
||||
|
||||
[PushLog, TokenToAddress, TokenToHash, TokenToTxid] = entityModules.map((m) => Object.values(m)[0]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Restore original environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe("getGoogleCredentials", () => {
|
||||
it("should return access token from Google Auth", async () => {
|
||||
const mockToken = "mock-access-token";
|
||||
const mockClient = {
|
||||
getAccessToken: vi.fn().mockResolvedValue({ token: mockToken }),
|
||||
};
|
||||
|
||||
// Mock the auth object that's created at module level - we need to spy on the actual instance
|
||||
const mockAuth = {
|
||||
getClient: vi.fn().mockResolvedValue(mockClient),
|
||||
};
|
||||
|
||||
// Since auth is already created when the module loads, we need to spy on it
|
||||
const authSpy = vi.spyOn(GroundControlToMajorTom as any, "getGoogleCredentials").mockImplementation(async () => {
|
||||
const client = await mockAuth.getClient();
|
||||
const accessTokenResponse = await client.getAccessToken();
|
||||
return accessTokenResponse.token;
|
||||
});
|
||||
|
||||
const result = await GroundControlToMajorTom.getGoogleCredentials();
|
||||
|
||||
expect(result).toBe(mockToken);
|
||||
expect(authSpy).toHaveBeenCalled();
|
||||
authSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getApnsJwtToken", () => {
|
||||
it("should return cached JWT token if still valid", () => {
|
||||
const mockToken = "cached-jwt-token";
|
||||
// Set static properties directly
|
||||
(GroundControlToMajorTom as any)._jwtToken = mockToken;
|
||||
(GroundControlToMajorTom as any)._jwtTokenMicroTimestamp = Date.now();
|
||||
|
||||
const result = GroundControlToMajorTom.getApnsJwtToken();
|
||||
|
||||
expect(result).toBe(mockToken);
|
||||
});
|
||||
|
||||
it("should generate new JWT token if cache is expired", async () => {
|
||||
const mockNewToken = "new-jwt-token";
|
||||
|
||||
// Set expired timestamp
|
||||
(GroundControlToMajorTom as any)._jwtTokenMicroTimestamp = Date.now() - 1900 * 1000;
|
||||
|
||||
// Mock the entire getApnsJwtToken method to test the caching logic
|
||||
const originalMethod = GroundControlToMajorTom.getApnsJwtToken;
|
||||
let callCount = 0;
|
||||
GroundControlToMajorTom.getApnsJwtToken = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First call should generate new token since cache is expired
|
||||
return mockNewToken;
|
||||
}
|
||||
return mockNewToken;
|
||||
});
|
||||
|
||||
const result = GroundControlToMajorTom.getApnsJwtToken();
|
||||
|
||||
expect(result).toBe(mockNewToken);
|
||||
expect(GroundControlToMajorTom.getApnsJwtToken).toHaveBeenCalled();
|
||||
|
||||
// Restore original method
|
||||
GroundControlToMajorTom.getApnsJwtToken = originalMethod;
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushOnchainAddressGotUnconfirmedTransaction", () => {
|
||||
const mockPushNotification: components["schemas"]["PushNotificationOnchainAddressGotUnconfirmedTransaction"] = {
|
||||
type: 3,
|
||||
token: "mock-token",
|
||||
os: "android",
|
||||
badge: 5,
|
||||
level: "transactions",
|
||||
sat: 50000,
|
||||
address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
||||
txid: "abc123def456789",
|
||||
};
|
||||
|
||||
it("should call FCM for Android devices", async () => {
|
||||
const mockFcmPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToFcm").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushOnchainAddressGotUnconfirmedTransaction(mockDataSource, "server-key", "apns-p8", mockPushNotification);
|
||||
|
||||
expect(mockFcmPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"server-key",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
badge: "5",
|
||||
tag: "abc123def456789",
|
||||
}),
|
||||
notification: expect.objectContaining({
|
||||
title: "New unconfirmed transaction",
|
||||
body: expect.stringContaining("bc1qx....0wlh"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
mockPushNotification
|
||||
);
|
||||
});
|
||||
|
||||
it("should call APNS for iOS devices", async () => {
|
||||
const iosPushNotification = { ...mockPushNotification, os: "ios" as const };
|
||||
const mockApnsPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToApns").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushOnchainAddressGotUnconfirmedTransaction(mockDataSource, "server-key", "apns-p8", iosPushNotification);
|
||||
|
||||
expect(mockApnsPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"apns-p8",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
aps: expect.objectContaining({
|
||||
badge: 5,
|
||||
alert: expect.objectContaining({
|
||||
title: "New Transaction - Pending",
|
||||
body: expect.stringContaining("bc1qx....0wlh"),
|
||||
}),
|
||||
sound: "default",
|
||||
}),
|
||||
}),
|
||||
iosPushNotification,
|
||||
"abc123def456789"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushOnchainTxidGotConfirmed", () => {
|
||||
const mockPushNotification: components["schemas"]["PushNotificationTxidGotConfirmed"] = {
|
||||
type: 4,
|
||||
token: "mock-token",
|
||||
os: "android",
|
||||
badge: 3,
|
||||
level: "transactions",
|
||||
txid: "abc123def456789",
|
||||
};
|
||||
|
||||
it("should call FCM for Android devices", async () => {
|
||||
const mockFcmPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToFcm").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushOnchainTxidGotConfirmed(mockDataSource, "server-key", "apns-p8", mockPushNotification);
|
||||
|
||||
expect(mockFcmPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"server-key",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
badge: "3",
|
||||
tag: "abc123def456789",
|
||||
}),
|
||||
notification: expect.objectContaining({
|
||||
title: "Transaction - Confirmed",
|
||||
body: expect.stringContaining("abc12....6789"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
mockPushNotification
|
||||
);
|
||||
});
|
||||
|
||||
it("should call APNS for iOS devices", async () => {
|
||||
const iosPushNotification = { ...mockPushNotification, os: "ios" as const };
|
||||
const mockApnsPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToApns").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushOnchainTxidGotConfirmed(mockDataSource, "server-key", "apns-p8", iosPushNotification);
|
||||
|
||||
expect(mockApnsPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"apns-p8",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
aps: expect.objectContaining({
|
||||
badge: 3,
|
||||
alert: expect.objectContaining({
|
||||
title: "Transaction - Confirmed",
|
||||
body: expect.stringContaining("abc12....6789"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
iosPushNotification,
|
||||
"abc123def456789"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushMessage", () => {
|
||||
const mockPushNotification: components["schemas"]["PushNotificationMessage"] = {
|
||||
type: 5,
|
||||
token: "mock-token",
|
||||
os: "android",
|
||||
badge: 1,
|
||||
level: "transactions",
|
||||
text: "Hello, this is a test message!",
|
||||
txid: "optional-txid",
|
||||
};
|
||||
|
||||
it("should call FCM for Android devices", async () => {
|
||||
const mockFcmPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToFcm").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushMessage(mockDataSource, "server-key", "apns-p8", mockPushNotification);
|
||||
|
||||
expect(mockFcmPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"server-key",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
data: {},
|
||||
notification: expect.objectContaining({
|
||||
title: "Message",
|
||||
body: "Hello, this is a test message!",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
mockPushNotification
|
||||
);
|
||||
});
|
||||
|
||||
it("should call APNS for iOS devices", async () => {
|
||||
const iosPushNotification = { ...mockPushNotification, os: "ios" as const };
|
||||
const mockApnsPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToApns").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushMessage(mockDataSource, "server-key", "apns-p8", iosPushNotification);
|
||||
|
||||
expect(mockApnsPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"apns-p8",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
aps: expect.objectContaining({
|
||||
badge: 1,
|
||||
alert: expect.objectContaining({
|
||||
title: "Message",
|
||||
body: "Hello, this is a test message!",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
iosPushNotification,
|
||||
"optional-txid"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushOnchainAddressWasPaid", () => {
|
||||
const mockPushNotification: components["schemas"]["PushNotificationOnchainAddressGotPaid"] = {
|
||||
type: 2,
|
||||
token: "mock-token",
|
||||
os: "android",
|
||||
badge: 2,
|
||||
level: "transactions",
|
||||
sat: 100000,
|
||||
address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
||||
txid: "abc123def456789",
|
||||
};
|
||||
|
||||
it("should call FCM for Android devices", async () => {
|
||||
const mockFcmPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToFcm").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushOnchainAddressWasPaid(mockDataSource, "server-key", "apns-p8", mockPushNotification);
|
||||
|
||||
expect(mockFcmPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"server-key",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
badge: "2",
|
||||
tag: "abc123def456789",
|
||||
}),
|
||||
notification: expect.objectContaining({
|
||||
title: "+100000 sats",
|
||||
body: expect.stringContaining("bc1qx....0wlh"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
mockPushNotification
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushLightningInvoicePaid", () => {
|
||||
const mockPushNotification: components["schemas"]["PushNotificationLightningInvoicePaid"] = {
|
||||
type: 1,
|
||||
token: "mock-token",
|
||||
os: "android",
|
||||
badge: 4,
|
||||
level: "transactions",
|
||||
sat: 25000,
|
||||
hash: "abcdef123456789",
|
||||
memo: "Payment for services",
|
||||
};
|
||||
|
||||
it("should call FCM for Android devices with memo", async () => {
|
||||
const mockFcmPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToFcm").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushLightningInvoicePaid(mockDataSource, "server-key", "apns-p8", mockPushNotification);
|
||||
|
||||
expect(mockFcmPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"server-key",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
badge: "4",
|
||||
tag: "abcdef123456789",
|
||||
}),
|
||||
notification: expect.objectContaining({
|
||||
title: "+25000 sats",
|
||||
body: "Paid: Payment for services",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
mockPushNotification
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing memo gracefully", async () => {
|
||||
const notificationWithoutMemo = { ...mockPushNotification, memo: undefined };
|
||||
const mockFcmPush = vi.spyOn(GroundControlToMajorTom as any, "_pushToFcm").mockResolvedValue(undefined);
|
||||
|
||||
await GroundControlToMajorTom.pushLightningInvoicePaid(mockDataSource, "server-key", "apns-p8", notificationWithoutMemo);
|
||||
|
||||
expect(mockFcmPush).toHaveBeenCalledWith(
|
||||
mockDataSource,
|
||||
"server-key",
|
||||
"mock-token",
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
notification: expect.objectContaining({
|
||||
body: "Paid: your invoice",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
notificationWithoutMemo
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("killDeadToken", () => {
|
||||
it("should delete token from all related repositories", async () => {
|
||||
const mockToken = "dead-token";
|
||||
|
||||
await GroundControlToMajorTom.killDeadToken(mockDataSource, mockToken);
|
||||
|
||||
expect(mockDataSource.getRepository).toHaveBeenCalledWith(TokenToAddress);
|
||||
expect(mockDataSource.getRepository).toHaveBeenCalledWith(TokenToTxid);
|
||||
expect(mockDataSource.getRepository).toHaveBeenCalledWith(TokenToHash);
|
||||
expect(mockQueryBuilder.delete).toHaveBeenCalledTimes(3);
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith("token = :token", { token: mockToken });
|
||||
expect(mockQueryBuilder.execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processFcmResponse", () => {
|
||||
it("should return true for successful response", () => {
|
||||
const successResponse = JSON.stringify({ name: "projects/mock-project/messages/123" });
|
||||
|
||||
const result = GroundControlToMajorTom.processFcmResponse(mockDataSource, successResponse, "token");
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should kill dead token on 404 error and return false", async () => {
|
||||
const errorResponse = JSON.stringify({ error: { code: 404 } });
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
const result = GroundControlToMajorTom.processFcmResponse(mockDataSource, errorResponse, "dead-token");
|
||||
|
||||
expect(mockKillDeadToken).toHaveBeenCalledWith(mockDataSource, "dead-token");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should kill dead token on UNREGISTERED error and return false", async () => {
|
||||
const errorResponse = JSON.stringify({
|
||||
error: {
|
||||
details: [{ errorCode: "UNREGISTERED" }],
|
||||
},
|
||||
});
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
const result = GroundControlToMajorTom.processFcmResponse(mockDataSource, errorResponse, "unregistered-token");
|
||||
|
||||
expect(mockKillDeadToken).toHaveBeenCalledWith(mockDataSource, "unregistered-token");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for invalid JSON response", () => {
|
||||
const invalidResponse = "invalid json";
|
||||
|
||||
const result = GroundControlToMajorTom.processFcmResponse(mockDataSource, invalidResponse, "token");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for response without name field", () => {
|
||||
const responseWithoutName = JSON.stringify({ someOtherField: "value" });
|
||||
|
||||
const result = GroundControlToMajorTom.processFcmResponse(mockDataSource, responseWithoutName, "token");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processApnsResponse", () => {
|
||||
it("should kill dead token for Unregistered reason", async () => {
|
||||
const response = {
|
||||
data: JSON.stringify({ reason: "Unregistered" }),
|
||||
};
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
GroundControlToMajorTom.processApnsResponse(mockDataSource, response, "unregistered-token");
|
||||
|
||||
expect(mockKillDeadToken).toHaveBeenCalledWith(mockDataSource, "unregistered-token");
|
||||
});
|
||||
|
||||
it("should kill dead token for BadDeviceToken reason", async () => {
|
||||
const response = {
|
||||
data: JSON.stringify({ reason: "BadDeviceToken" }),
|
||||
};
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
GroundControlToMajorTom.processApnsResponse(mockDataSource, response, "bad-token");
|
||||
|
||||
expect(mockKillDeadToken).toHaveBeenCalledWith(mockDataSource, "bad-token");
|
||||
});
|
||||
|
||||
it("should kill dead token for DeviceTokenNotForTopic reason", async () => {
|
||||
const response = {
|
||||
data: JSON.stringify({ reason: "DeviceTokenNotForTopic" }),
|
||||
};
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
GroundControlToMajorTom.processApnsResponse(mockDataSource, response, "wrong-topic-token");
|
||||
|
||||
expect(mockKillDeadToken).toHaveBeenCalledWith(mockDataSource, "wrong-topic-token");
|
||||
});
|
||||
|
||||
it("should not kill token for other reasons", async () => {
|
||||
const response = {
|
||||
data: JSON.stringify({ reason: "PayloadTooLarge" }),
|
||||
};
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
GroundControlToMajorTom.processApnsResponse(mockDataSource, response, "valid-token");
|
||||
|
||||
expect(mockKillDeadToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle invalid JSON gracefully", async () => {
|
||||
const response = {
|
||||
data: "invalid json",
|
||||
};
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
GroundControlToMajorTom.processApnsResponse(mockDataSource, response, "valid-token");
|
||||
|
||||
expect(mockKillDeadToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle response without data", async () => {
|
||||
const response = {};
|
||||
const mockKillDeadToken = vi.spyOn(GroundControlToMajorTom, "killDeadToken").mockResolvedValue(undefined);
|
||||
|
||||
GroundControlToMajorTom.processApnsResponse(mockDataSource, response, "valid-token");
|
||||
|
||||
expect(mockKillDeadToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("_pushToFcm", () => {
|
||||
it("should send push notification to FCM successfully", async () => {
|
||||
const mockResponse = {
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ name: "projects/mock/messages/123" })),
|
||||
};
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
||||
|
||||
const mockProcessFcmResponse = vi.spyOn(GroundControlToMajorTom, "processFcmResponse").mockReturnValue(true);
|
||||
|
||||
const fcmPayload = {
|
||||
message: {
|
||||
token: "",
|
||||
data: { badge: "1" },
|
||||
notification: { title: "Test", body: "Test message" },
|
||||
},
|
||||
};
|
||||
|
||||
const pushNotification: components["schemas"]["PushNotificationBase"] = {
|
||||
type: 5,
|
||||
token: "test-token",
|
||||
os: "android",
|
||||
badge: 1,
|
||||
level: "transactions",
|
||||
};
|
||||
|
||||
await (GroundControlToMajorTom as any)._pushToFcm(mockDataSource, "bearer-token", "test-token", fcmPayload, pushNotification);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`https://fcm.googleapis.com/v1/projects/${process.env.GOOGLE_PROJECT_ID}/messages:send`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer bearer-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: expect.stringContaining("test-token"),
|
||||
})
|
||||
);
|
||||
expect(mockProcessFcmResponse).toHaveBeenCalled();
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: "test-token",
|
||||
os: "android",
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle FCM network errors gracefully", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const fcmPayload = {
|
||||
message: {
|
||||
token: "",
|
||||
data: { badge: "1" },
|
||||
notification: { title: "Test", body: "Test message" },
|
||||
},
|
||||
};
|
||||
|
||||
const pushNotification: components["schemas"]["PushNotificationBase"] = {
|
||||
type: 5,
|
||||
token: "test-token",
|
||||
os: "android",
|
||||
badge: 1,
|
||||
level: "transactions",
|
||||
};
|
||||
|
||||
// The method should reject when fetch fails
|
||||
await expect((GroundControlToMajorTom as any)._pushToFcm(mockDataSource, "bearer-token", "test-token", fcmPayload, pushNotification)).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,510 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { DataSource } from "typeorm";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
// Mock TypeORM entities
|
||||
vi.mock("../entity/TokenToAddress", () => ({
|
||||
TokenToAddress: class TokenToAddress {},
|
||||
}));
|
||||
vi.mock("../entity/TokenToHash", () => ({
|
||||
TokenToHash: class TokenToHash {},
|
||||
}));
|
||||
vi.mock("../entity/TokenToTxid", () => ({
|
||||
TokenToTxid: class TokenToTxid {},
|
||||
}));
|
||||
vi.mock("../entity/TokenConfiguration", () => ({
|
||||
TokenConfiguration: class TokenConfiguration {
|
||||
id!: number;
|
||||
token!: string;
|
||||
os!: string;
|
||||
level_all: boolean = true;
|
||||
level_transactions: boolean = true;
|
||||
level_price: boolean = true;
|
||||
level_news: boolean = true;
|
||||
level_tips: boolean = true;
|
||||
lang: string = "en";
|
||||
app_version: string = "1.0.0";
|
||||
created!: Date;
|
||||
last_online: Date = new Date();
|
||||
},
|
||||
}));
|
||||
vi.mock("../entity/SendQueue", () => ({
|
||||
SendQueue: class SendQueue {},
|
||||
}));
|
||||
vi.mock("../entity/PushLog", () => ({
|
||||
PushLog: class PushLog {},
|
||||
}));
|
||||
vi.mock("../entity/KeyValue", () => ({
|
||||
KeyValue: class KeyValue {},
|
||||
}));
|
||||
|
||||
// Mock data-source to prevent initialization
|
||||
const mockConnection = {
|
||||
getRepository: vi.fn().mockReturnValue({}),
|
||||
createQueryBuilder: vi.fn().mockReturnValue({
|
||||
delete: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
execute: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
query: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
vi.mock("../data-source", () => ({
|
||||
default: {
|
||||
initialize: vi.fn().mockResolvedValue(mockConnection),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock crypto module using a factory function to ensure fresh instances
|
||||
vi.mock("crypto", () => ({
|
||||
createHash: vi.fn().mockReturnValue({
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn().mockReturnValue("6c60f404f8167a38fc70eaf8c17cd92e60f96e3f9dd9b6b5d3b9b5d5c5b5a5a5"),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock dotenv
|
||||
vi.mock("dotenv", () => ({
|
||||
config: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock require to prevent package.json access during module initialization
|
||||
vi.mock("../../package.json", () => ({
|
||||
name: "groundcontrol",
|
||||
description: "GroundControl push server API",
|
||||
version: "3.0.1",
|
||||
}));
|
||||
|
||||
// Mock global functions to prevent module initialization issues
|
||||
global.setInterval = vi.fn() as any;
|
||||
global.console = {
|
||||
...console,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe("GroundController", () => {
|
||||
let mockDataSource: DataSource;
|
||||
let mockRepository: any;
|
||||
let mockQueryBuilder: any;
|
||||
let groundController: any;
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockNext: NextFunction;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up require mock to return the mocked crypto module
|
||||
(global as any).require = vi.fn().mockImplementation((module: string) => {
|
||||
if (module === "crypto") {
|
||||
return {
|
||||
createHash: vi.fn().mockReturnValue({
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn().mockReturnValue("6c60f404f8167a38fc70eaf8c17cd92e60f96e3f9dd9b6b5d3b9b5d5c5b5a5a5"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const originalRequire = require;
|
||||
return originalRequire(module);
|
||||
});
|
||||
|
||||
// Set up environment variables
|
||||
process.env.JAWSDB_MARIA_URL = "mock-db-url";
|
||||
process.env.GOOGLE_KEY_FILE = "mock-google-key";
|
||||
process.env.APNS_P8 = "mock-apns-p8";
|
||||
process.env.APNS_TOPIC = "com.mock.app";
|
||||
process.env.APPLE_TEAM_ID = "MOCK_TEAM_ID";
|
||||
process.env.APNS_P8_KID = "MOCK_KEY_ID";
|
||||
process.env.GOOGLE_PROJECT_ID = "mock-project-id";
|
||||
|
||||
// Mock QueryBuilder
|
||||
mockQueryBuilder = {
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
execute: vi.fn().mockResolvedValue({}),
|
||||
getCount: vi.fn().mockResolvedValue(10),
|
||||
};
|
||||
|
||||
// Mock Repository
|
||||
mockRepository = {
|
||||
save: vi.fn().mockResolvedValue({}),
|
||||
find: vi.fn().mockResolvedValue([]),
|
||||
findOneBy: vi.fn().mockResolvedValue(null),
|
||||
remove: vi.fn().mockResolvedValue({}),
|
||||
count: vi.fn().mockResolvedValue(5),
|
||||
createQueryBuilder: vi.fn().mockReturnValue(mockQueryBuilder),
|
||||
};
|
||||
|
||||
// Mock DataSource
|
||||
mockDataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(mockRepository),
|
||||
createQueryBuilder: vi.fn().mockReturnValue(mockQueryBuilder),
|
||||
query: vi.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
|
||||
// Mock Express objects
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
};
|
||||
mockNext = vi.fn();
|
||||
|
||||
// Dynamically import GroundController after setting up environment
|
||||
const { GroundController } = await import("../controller/GroundController");
|
||||
groundController = new GroundController((mockConnection as unknown) as DataSource);
|
||||
|
||||
// Mock the connection property by directly setting repositories
|
||||
(groundController as any)._tokenToAddressRepository = mockRepository;
|
||||
(groundController as any)._tokenToHashRepository = mockRepository;
|
||||
(groundController as any)._tokenToTxidRepository = mockRepository;
|
||||
(groundController as any)._tokenConfigurationRepository = mockRepository;
|
||||
(groundController as any)._sendQueueRepository = mockRepository;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe("Repository getters", () => {
|
||||
it("should return tokenToAddressRepository", () => {
|
||||
const repo = groundController.tokenToAddressRepository;
|
||||
expect(repo).toBeDefined();
|
||||
expect(repo).toBe(mockRepository);
|
||||
});
|
||||
|
||||
it("should return tokenToHashRepository", () => {
|
||||
const repo = groundController.tokenToHashRepository;
|
||||
expect(repo).toBeDefined();
|
||||
expect(repo).toBe(mockRepository);
|
||||
});
|
||||
|
||||
it("should return tokenToTxidRepository", () => {
|
||||
const repo = groundController.tokenToTxidRepository;
|
||||
expect(repo).toBeDefined();
|
||||
expect(repo).toBe(mockRepository);
|
||||
});
|
||||
|
||||
it("should return tokenConfigurationRepository", () => {
|
||||
const repo = groundController.tokenConfigurationRepository;
|
||||
expect(repo).toBeDefined();
|
||||
expect(repo).toBe(mockRepository);
|
||||
});
|
||||
|
||||
it("should return sendQueueRepository", () => {
|
||||
const repo = groundController.sendQueueRepository;
|
||||
expect(repo).toBeDefined();
|
||||
expect(repo).toBe(mockRepository);
|
||||
});
|
||||
});
|
||||
|
||||
describe("majorTomToGroundControl", () => {
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
body: {
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
addresses: ["bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"],
|
||||
hashes: ["hash123"],
|
||||
txids: ["txid123"],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("should save addresses, hashes, and txids successfully", async () => {
|
||||
await groundController.majorTomToGroundControl(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledTimes(3);
|
||||
expect(mockRepository.save).toHaveBeenCalledWith({
|
||||
address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
});
|
||||
expect(mockRepository.save).toHaveBeenCalledWith({
|
||||
hash: "hash123",
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
});
|
||||
expect(mockRepository.save).toHaveBeenCalledWith({
|
||||
txid: "txid123",
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
});
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(201);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("should handle missing token", async () => {
|
||||
mockRequest.body.token = undefined;
|
||||
|
||||
await groundController.majorTomToGroundControl(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith("token not provided");
|
||||
});
|
||||
|
||||
it("should handle missing os", async () => {
|
||||
mockRequest.body.os = undefined;
|
||||
|
||||
await groundController.majorTomToGroundControl(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith("token not provided");
|
||||
});
|
||||
|
||||
it("should skip ignored addresses", async () => {
|
||||
mockRequest.body.addresses = ["1NXNHZr6Pbzi3VStcgaxwEhspTWNXQ3Q4G"]; // This is in the ignore list
|
||||
|
||||
await groundController.majorTomToGroundControl(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledTimes(2); // Only hash and txid, not address
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
|
||||
it("should handle empty arrays gracefully", async () => {
|
||||
mockRequest.body.addresses = [];
|
||||
mockRequest.body.hashes = [];
|
||||
mockRequest.body.txids = [];
|
||||
|
||||
await groundController.majorTomToGroundControl(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
|
||||
it("should handle missing arrays by defaulting to empty arrays", async () => {
|
||||
mockRequest.body.addresses = undefined;
|
||||
mockRequest.body.hashes = undefined;
|
||||
mockRequest.body.txids = undefined;
|
||||
|
||||
await groundController.majorTomToGroundControl(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribe", () => {
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
body: {
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
addresses: ["bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"],
|
||||
hashes: ["hash123"],
|
||||
txids: ["txid123"],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("should remove addresses, hashes, and txids successfully", async () => {
|
||||
const mockAddressRecord = { id: 1, address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" };
|
||||
const mockHashRecord = { id: 2, hash: "hash123" };
|
||||
const mockTxidRecord = { id: 3, txid: "txid123" };
|
||||
|
||||
mockRepository.findOneBy.mockResolvedValueOnce(mockAddressRecord).mockResolvedValueOnce(mockHashRecord).mockResolvedValueOnce(mockTxidRecord);
|
||||
|
||||
await groundController.unsubscribe(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.findOneBy).toHaveBeenCalledTimes(3);
|
||||
expect(mockRepository.remove).toHaveBeenCalledTimes(3);
|
||||
expect(mockRepository.remove).toHaveBeenCalledWith(mockAddressRecord);
|
||||
expect(mockRepository.remove).toHaveBeenCalledWith(mockHashRecord);
|
||||
expect(mockRepository.remove).toHaveBeenCalledWith(mockTxidRecord);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
|
||||
it("should handle missing token", async () => {
|
||||
mockRequest.body.token = undefined;
|
||||
|
||||
await groundController.unsubscribe(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith("token not provided");
|
||||
});
|
||||
|
||||
it("should handle records not found gracefully", async () => {
|
||||
mockRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await groundController.unsubscribe(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.findOneBy).toHaveBeenCalledTimes(3);
|
||||
expect(mockRepository.remove).toHaveBeenCalledTimes(3);
|
||||
expect(mockRepository.remove).toHaveBeenCalledWith(null);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lightningInvoiceGotSettled", () => {
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
body: {
|
||||
preimage: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
hash: "6c60f404f8167a38fc70eaf8c17cd92e60f96e3f9dd9b6b5d3b9b5d5c5b5a5a5", // This matches our mock digest output
|
||||
amt_paid_sat: 1000,
|
||||
memo: "Test payment",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("should process lightning invoice settlement successfully", async () => {
|
||||
// Test the method structure and basic validation
|
||||
expect(typeof groundController.lightningInvoiceGotSettled).toBe("function");
|
||||
expect(groundController.lightningInvoiceGotSettled.length).toBe(3); // request, response, next parameters
|
||||
|
||||
// Test that the method validates the hash correctly by expecting it to call response.send
|
||||
await groundController.lightningInvoiceGotSettled(mockRequest, mockResponse, mockNext);
|
||||
|
||||
// Either successful processing (status 200) or hash validation failure (status 500)
|
||||
expect(mockResponse.status).toHaveBeenCalled();
|
||||
expect(mockResponse.send).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ping", () => {
|
||||
it("should test ping method structure", async () => {
|
||||
// The ping method uses a global connection variable that's difficult to mock
|
||||
// For now, we'll test that the method exists and has the right structure
|
||||
expect(typeof groundController.ping).toBe("function");
|
||||
expect(groundController.ping.length).toBe(3); // request, response, next parameters
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTokenConfiguration", () => {
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
body: {
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
level_all: false,
|
||||
level_transactions: true,
|
||||
level_price: false,
|
||||
level_news: true,
|
||||
level_tips: false,
|
||||
lang: "es",
|
||||
app_version: "2.0.0",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("should update existing token configuration", async () => {
|
||||
const existingConfig = {
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
level_all: true,
|
||||
level_transactions: false,
|
||||
level_price: true,
|
||||
level_news: false,
|
||||
level_tips: true,
|
||||
lang: "en",
|
||||
app_version: "1.0.0",
|
||||
};
|
||||
mockRepository.findOneBy.mockResolvedValue(existingConfig);
|
||||
|
||||
await groundController.setTokenConfiguration(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.findOneBy).toHaveBeenCalledWith({
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
});
|
||||
expect(existingConfig.level_all).toBe(false);
|
||||
expect(existingConfig.level_transactions).toBe(true);
|
||||
expect(existingConfig.lang).toBe("es");
|
||||
expect(existingConfig.app_version).toBe("2.0.0");
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(existingConfig);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("should create new token configuration if not found", async () => {
|
||||
mockRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await groundController.setTokenConfiguration(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalled();
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueue", () => {
|
||||
it("should enqueue notification data", async () => {
|
||||
mockRequest = {
|
||||
body: {
|
||||
type: 1,
|
||||
token: "test-token",
|
||||
message: "Test notification",
|
||||
},
|
||||
};
|
||||
|
||||
await groundController.enqueue(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledWith({
|
||||
data: JSON.stringify(mockRequest.body),
|
||||
});
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTokenConfiguration", () => {
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
body: {
|
||||
token: "test-token",
|
||||
os: "ios",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("should return existing token configuration", async () => {
|
||||
const existingConfig = {
|
||||
level_all: true,
|
||||
level_transactions: false,
|
||||
level_price: true,
|
||||
level_news: false,
|
||||
level_tips: true,
|
||||
lang: "es",
|
||||
app_version: "2.0.0",
|
||||
};
|
||||
mockRepository.findOneBy.mockResolvedValue(existingConfig);
|
||||
|
||||
const result = await groundController.getTokenConfiguration(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(result).toEqual({
|
||||
level_all: true,
|
||||
level_transactions: false,
|
||||
level_price: true,
|
||||
level_news: false,
|
||||
level_tips: true,
|
||||
lang: "es",
|
||||
app_version: "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create and return new token configuration if not found", async () => {
|
||||
mockRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
const result = await groundController.getTokenConfiguration(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
level_all: true,
|
||||
level_transactions: true,
|
||||
level_price: true,
|
||||
level_news: true,
|
||||
level_tips: true,
|
||||
lang: "en",
|
||||
app_version: "1.0.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
536
src/tests/pushNotificationUtils.test.ts
Normal file
536
src/tests/pushNotificationUtils.test.ts
Normal file
@ -0,0 +1,536 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { StringUtils } from "../utils/stringUtils";
|
||||
|
||||
// Mock environment variables for testing
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
APNS_P8: "2d2d2d2d2d424547494e205052495641544520534d454420454d454d454d454d20504f494e54452d2d2d2d2d0a4d484943416741472d412b6742414d42",
|
||||
APPLE_TEAM_ID: "ABCD123456",
|
||||
APNS_P8_KID: "ABC123DEF4",
|
||||
GOOGLE_KEY_FILE: "7b2274797065223a22736572766963655f6163636f756e74222c2270726f6a6563745f6964223a2274657374227d",
|
||||
GOOGLE_PROJECT_ID: "test-project-123",
|
||||
APNS_TOPIC: "com.test.app",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Push Notification System", () => {
|
||||
describe("StringUtils", () => {
|
||||
describe("shortenAddress", () => {
|
||||
it("should shorten Bitcoin addresses correctly", () => {
|
||||
const longAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
|
||||
const result = StringUtils.shortenAddress(longAddress);
|
||||
expect(result).toBe("bc1qx....0wlh");
|
||||
});
|
||||
|
||||
it("should shorten Lightning addresses correctly", () => {
|
||||
const lightningAddress = "1MNH5eZ1AFZGhBg5FjNt35H7YfZE1AW8Zf";
|
||||
const result = StringUtils.shortenAddress(lightningAddress);
|
||||
expect(result).toBe("1MNH5....W8Zf");
|
||||
});
|
||||
|
||||
it("should handle bech32 addresses", () => {
|
||||
const bech32Address = "bc1qrnn4wfhgz2e0etek66sh3n9l6k99alxk044mhr";
|
||||
const result = StringUtils.shortenAddress(bech32Address);
|
||||
expect(result).toBe("bc1qr....4mhr");
|
||||
});
|
||||
|
||||
it("should handle legacy addresses", () => {
|
||||
const legacyAddress = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
|
||||
const result = StringUtils.shortenAddress(legacyAddress);
|
||||
expect(result).toBe("1A1zP....vfNa");
|
||||
});
|
||||
|
||||
it("should handle Taproot addresses", () => {
|
||||
const taprootAddress = "bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5sspknykm";
|
||||
const result = StringUtils.shortenAddress(taprootAddress);
|
||||
expect(result).toBe("bc1pm....nykm");
|
||||
});
|
||||
|
||||
it("should return unchanged for very short addresses", () => {
|
||||
const shortAddress = "short";
|
||||
const result = StringUtils.shortenAddress(shortAddress);
|
||||
expect(result).toBe("short");
|
||||
});
|
||||
|
||||
it("should handle exactly 10 character strings", () => {
|
||||
const tenCharAddress = "1234567890";
|
||||
const result = StringUtils.shortenAddress(tenCharAddress);
|
||||
expect(result).toBe("12345....7890");
|
||||
});
|
||||
|
||||
it("should handle empty strings gracefully", () => {
|
||||
const emptyAddress = "";
|
||||
const result = StringUtils.shortenAddress(emptyAddress);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should handle null and undefined safely", () => {
|
||||
// The current implementation doesn't handle null/undefined, so we expect errors
|
||||
expect(() => StringUtils.shortenAddress(null as any)).toThrow();
|
||||
expect(() => StringUtils.shortenAddress(undefined as any)).toThrow();
|
||||
});
|
||||
|
||||
it("should handle special characters in addresses", () => {
|
||||
const specialAddress = "abc!@#$%^&*()def1234567890";
|
||||
const result = StringUtils.shortenAddress(specialAddress);
|
||||
expect(result).toBe("abc!@....7890");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenTxid", () => {
|
||||
it("should shorten transaction IDs correctly", () => {
|
||||
const longTxid = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456";
|
||||
const result = StringUtils.shortenTxid(longTxid);
|
||||
expect(result).toBe("a1b2c....3456");
|
||||
});
|
||||
|
||||
it("should match shortenAddress behavior exactly", () => {
|
||||
const txid = "f2ca1bb6c7e907d06dafe4687cf0c76f0b8c33d6c7e907d06dafe4687cf0c76f";
|
||||
const addressResult = StringUtils.shortenAddress(txid);
|
||||
const txidResult = StringUtils.shortenTxid(txid);
|
||||
expect(addressResult).toBe(txidResult);
|
||||
});
|
||||
|
||||
it("should handle Lightning invoice payment hashes", () => {
|
||||
const hash = "abcdef123456789012345678901234567890abcdef123456789012345678901234";
|
||||
const result = StringUtils.shortenTxid(hash);
|
||||
expect(result).toBe("abcde....1234");
|
||||
});
|
||||
|
||||
it("should handle shorter transaction IDs", () => {
|
||||
const shortTxid = "abc123";
|
||||
const result = StringUtils.shortenTxid(shortTxid);
|
||||
expect(result).toBe("abc123");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Push Notification Payload Generation", () => {
|
||||
let mockFcmPayload: any;
|
||||
let mockApnsPayload: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFcmPayload = {
|
||||
message: {
|
||||
token: "",
|
||||
data: {},
|
||||
notification: {},
|
||||
},
|
||||
};
|
||||
|
||||
mockApnsPayload = {
|
||||
aps: {
|
||||
badge: 0,
|
||||
alert: {},
|
||||
sound: "default",
|
||||
},
|
||||
data: {},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Bitcoin Transaction Notifications", () => {
|
||||
it("should create correct unconfirmed transaction payload", () => {
|
||||
const address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
|
||||
const txid = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456";
|
||||
const badge = 5;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
mockFcmPayload.message.data.tag = txid;
|
||||
mockFcmPayload.message.notification.title = "New unconfirmed transaction";
|
||||
mockFcmPayload.message.notification.body = "You received new transfer on " + StringUtils.shortenAddress(address);
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe("New unconfirmed transaction");
|
||||
expect(mockFcmPayload.message.notification.body).toBe("You received new transfer on bc1qx....0wlh");
|
||||
expect(mockFcmPayload.message.data.badge).toBe("5");
|
||||
expect(mockFcmPayload.message.data.tag).toBe(txid);
|
||||
});
|
||||
|
||||
it("should create correct confirmed payment payload", () => {
|
||||
const address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
|
||||
const txid = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456";
|
||||
const satAmount = 50000;
|
||||
const badge = 3;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
mockFcmPayload.message.data.tag = txid;
|
||||
mockFcmPayload.message.notification.title = "+" + satAmount + " sats";
|
||||
mockFcmPayload.message.notification.body = "Received on " + StringUtils.shortenAddress(address);
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe("+50000 sats");
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Received on bc1qx....0wlh");
|
||||
expect(mockFcmPayload.message.data.badge).toBe("3");
|
||||
});
|
||||
|
||||
it("should create correct transaction confirmation payload", () => {
|
||||
const txid = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456";
|
||||
const badge = 2;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
mockFcmPayload.message.data.tag = txid;
|
||||
mockFcmPayload.message.notification.title = "Transaction - Confirmed";
|
||||
mockFcmPayload.message.notification.body = "Your transaction " + StringUtils.shortenTxid(txid) + " has been confirmed";
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe("Transaction - Confirmed");
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Your transaction a1b2c....3456 has been confirmed");
|
||||
expect(mockFcmPayload.message.data.tag).toBe(txid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lightning Network Notifications", () => {
|
||||
it("should create correct lightning invoice paid payload with memo", () => {
|
||||
const satAmount = 1000;
|
||||
const memo = "Coffee payment";
|
||||
const hash = "abcdef123456789012345678901234567890abcdef123456789012345678901234";
|
||||
const badge = 1;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
mockFcmPayload.message.data.tag = hash;
|
||||
mockFcmPayload.message.notification.title = "+" + satAmount + " sats";
|
||||
mockFcmPayload.message.notification.body = "Paid: " + (memo || "your invoice");
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe("+1000 sats");
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Paid: Coffee payment");
|
||||
expect(mockFcmPayload.message.data.tag).toBe(hash);
|
||||
});
|
||||
|
||||
it("should handle missing memo gracefully", () => {
|
||||
const satAmount = 1000;
|
||||
const memo = undefined;
|
||||
const badge = 1;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
mockFcmPayload.message.notification.title = "+" + satAmount + " sats";
|
||||
mockFcmPayload.message.notification.body = "Paid: " + (memo || "your invoice");
|
||||
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Paid: your invoice");
|
||||
});
|
||||
|
||||
it("should handle empty memo correctly", () => {
|
||||
const satAmount = 1000;
|
||||
const memo = "";
|
||||
const badge = 1;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
mockFcmPayload.message.notification.title = "+" + satAmount + " sats";
|
||||
mockFcmPayload.message.notification.body = "Paid: " + (memo || "your invoice");
|
||||
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Paid: your invoice");
|
||||
});
|
||||
|
||||
it("should handle large lightning payments", () => {
|
||||
const satAmount = 100000000; // 1 BTC in sats
|
||||
const memo = "Large payment";
|
||||
const badge = 1;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.notification.title = "+" + satAmount + " sats";
|
||||
mockFcmPayload.message.notification.body = "Paid: " + memo;
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe("+100000000 sats");
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Paid: Large payment");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Generic Message Notifications", () => {
|
||||
it("should create simple message notification", () => {
|
||||
const text = "Welcome to GroundControl!";
|
||||
const badge = 2;
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.data = {};
|
||||
mockFcmPayload.message.notification.title = "Message";
|
||||
mockFcmPayload.message.notification.body = text;
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe("Message");
|
||||
expect(mockFcmPayload.message.notification.body).toBe("Welcome to GroundControl!");
|
||||
});
|
||||
|
||||
it("should handle long messages", () => {
|
||||
const longText = "This is a very long message that might be truncated depending on the push notification service limits. It contains important information that users need to see.";
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.notification.title = "Message";
|
||||
mockFcmPayload.message.notification.body = longText;
|
||||
|
||||
expect(mockFcmPayload.message.notification.body).toBe(longText);
|
||||
expect(mockFcmPayload.message.notification.body.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("should handle special characters in messages", () => {
|
||||
const specialText = "Message with émojis 🚀 and special chars: &<>\"'";
|
||||
|
||||
// Simulate FCM payload creation
|
||||
mockFcmPayload.message.notification.title = "Message";
|
||||
mockFcmPayload.message.notification.body = specialText;
|
||||
|
||||
expect(mockFcmPayload.message.notification.body).toBe(specialText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("APNS vs FCM Payload Differences", () => {
|
||||
it("should format badges differently for FCM vs APNS", () => {
|
||||
const badge = 5;
|
||||
|
||||
// FCM uses string badges
|
||||
mockFcmPayload.message.data.badge = String(badge);
|
||||
|
||||
// APNS uses numeric badges
|
||||
mockApnsPayload.aps.badge = badge;
|
||||
|
||||
expect(mockFcmPayload.message.data.badge).toBe("5");
|
||||
expect(mockApnsPayload.aps.badge).toBe(5);
|
||||
expect(typeof mockFcmPayload.message.data.badge).toBe("string");
|
||||
expect(typeof mockApnsPayload.aps.badge).toBe("number");
|
||||
});
|
||||
|
||||
it("should structure alert content differently", () => {
|
||||
const title = "New Payment";
|
||||
const body = "You received 1000 sats";
|
||||
|
||||
// FCM structure
|
||||
mockFcmPayload.message.notification = { title, body };
|
||||
|
||||
// APNS structure
|
||||
mockApnsPayload.aps.alert = { title, body };
|
||||
|
||||
expect(mockFcmPayload.message.notification.title).toBe(title);
|
||||
expect(mockFcmPayload.message.notification.body).toBe(body);
|
||||
expect(mockApnsPayload.aps.alert.title).toBe(title);
|
||||
expect(mockApnsPayload.aps.alert.body).toBe(body);
|
||||
});
|
||||
|
||||
it("should include default sound for APNS", () => {
|
||||
expect(mockApnsPayload.aps.sound).toBe("default");
|
||||
expect(mockFcmPayload.message.sound).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Response Processing Logic", () => {
|
||||
describe("FCM Response Processing", () => {
|
||||
it("should identify successful FCM responses", () => {
|
||||
const successfulResponses = [
|
||||
'{"name": "projects/test/messages/123"}',
|
||||
'{"name": "projects/test/messages/456", "messageId": "abc"}',
|
||||
];
|
||||
|
||||
successfulResponses.forEach(response => {
|
||||
const parsed = JSON.parse(response);
|
||||
const isSuccess = !!parsed.name;
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should identify token-killing FCM errors", () => {
|
||||
const tokenKillingErrors = [
|
||||
'{"error": {"code": 404, "message": "Not found"}}',
|
||||
'{"error": {"details": [{"errorCode": "UNREGISTERED"}]}}',
|
||||
];
|
||||
|
||||
tokenKillingErrors.forEach(errorResponse => {
|
||||
const parsed = JSON.parse(errorResponse);
|
||||
const shouldKillToken =
|
||||
(parsed.error?.code === 404) ||
|
||||
(Array.isArray(parsed.error?.details) &&
|
||||
parsed.error.details.some((d: any) => d.errorCode === "UNREGISTERED"));
|
||||
|
||||
expect(shouldKillToken).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle non-token-killing FCM errors", () => {
|
||||
const nonKillingErrors = [
|
||||
'{"error": {"code": 500, "message": "Internal error"}}',
|
||||
'{"error": {"code": 429, "message": "Rate limited"}}',
|
||||
'{"error": {"code": 400, "message": "Invalid request"}}',
|
||||
];
|
||||
|
||||
nonKillingErrors.forEach(errorResponse => {
|
||||
const parsed = JSON.parse(errorResponse);
|
||||
const shouldKillToken = parsed.error?.code === 404;
|
||||
expect(shouldKillToken).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed JSON responses", () => {
|
||||
const malformedResponses = [
|
||||
'{"invalid": json}',
|
||||
'not json at all',
|
||||
'',
|
||||
'{incomplete',
|
||||
];
|
||||
|
||||
malformedResponses.forEach(response => {
|
||||
let isValidJson = true;
|
||||
try {
|
||||
JSON.parse(response);
|
||||
} catch {
|
||||
isValidJson = false;
|
||||
}
|
||||
expect(isValidJson).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("APNS Response Processing", () => {
|
||||
it("should identify token-killing APNS reasons", () => {
|
||||
const tokenKillingReasons = [
|
||||
"Unregistered",
|
||||
"BadDeviceToken",
|
||||
"DeviceTokenNotForTopic"
|
||||
];
|
||||
|
||||
tokenKillingReasons.forEach(reason => {
|
||||
const response = { data: JSON.stringify({ reason }), ":status": 400 };
|
||||
const parsed = JSON.parse(response.data);
|
||||
const shouldKillToken = ["Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic"].includes(parsed.reason);
|
||||
expect(shouldKillToken).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle non-token-killing APNS reasons", () => {
|
||||
const nonKillingReasons = [
|
||||
"PayloadTooLarge",
|
||||
"BadCertificate",
|
||||
"BadPath",
|
||||
"BadCertificateEnvironment"
|
||||
];
|
||||
|
||||
nonKillingReasons.forEach(reason => {
|
||||
const response = { data: JSON.stringify({ reason }), ":status": 400 };
|
||||
const parsed = JSON.parse(response.data);
|
||||
const shouldKillToken = ["Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic"].includes(parsed.reason);
|
||||
expect(shouldKillToken).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle successful APNS responses", () => {
|
||||
const successfulResponse = { ":status": 200 };
|
||||
expect(successfulResponse[":status"]).toBe(200);
|
||||
});
|
||||
|
||||
it("should handle APNS responses without data", () => {
|
||||
const responseWithoutData: any = { ":status": 400 };
|
||||
expect(responseWithoutData.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Validation and Edge Cases", () => {
|
||||
describe("Address Validation", () => {
|
||||
it("should handle various Bitcoin address formats", () => {
|
||||
const addresses = [
|
||||
"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", // Legacy
|
||||
"3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", // P2SH
|
||||
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", // Bech32
|
||||
"bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5sspknykm", // Taproot
|
||||
];
|
||||
|
||||
addresses.forEach(address => {
|
||||
const shortened = StringUtils.shortenAddress(address);
|
||||
expect(shortened).toContain("....");
|
||||
expect(shortened.length).toBeLessThan(address.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invalid addresses gracefully", () => {
|
||||
const invalidAddresses = [
|
||||
"invalid_address",
|
||||
"bc1invalid",
|
||||
"1invalid",
|
||||
"3invalid",
|
||||
];
|
||||
|
||||
invalidAddresses.forEach(address => {
|
||||
// StringUtils should still work even with invalid addresses
|
||||
expect(() => StringUtils.shortenAddress(address)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Satoshi Amount Handling", () => {
|
||||
it("should handle various satoshi amounts", () => {
|
||||
const amounts = [
|
||||
1, // 1 sat
|
||||
100, // 100 sats
|
||||
1000, // 1k sats
|
||||
100000, // 100k sats
|
||||
100000000, // 1 BTC
|
||||
2100000000000000, // Max Bitcoin supply
|
||||
];
|
||||
|
||||
amounts.forEach(amount => {
|
||||
const title = "+" + amount + " sats";
|
||||
expect(title).toMatch(/^\+\d+ sats$/);
|
||||
expect(parseInt(title.replace(/^\+(\d+) sats$/, '$1'))).toBe(amount);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle zero and negative amounts", () => {
|
||||
const zeroAmount = 0;
|
||||
const title = "+" + zeroAmount + " sats";
|
||||
expect(title).toBe("+0 sats");
|
||||
|
||||
// Negative amounts shouldn't happen in practice, but let's be safe
|
||||
const negativeAmount = -100;
|
||||
const negativeTitle = "+" + negativeAmount + " sats";
|
||||
expect(negativeTitle).toBe("+-100 sats");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memo and Text Handling", () => {
|
||||
it("should handle various memo formats", () => {
|
||||
const memos = [
|
||||
"Simple memo",
|
||||
"Memo with émojis 🚀⚡",
|
||||
"Memo with special chars: &<>\"'",
|
||||
"Very long memo that goes on and on and might be truncated by some services",
|
||||
"",
|
||||
undefined,
|
||||
null,
|
||||
];
|
||||
|
||||
memos.forEach(memo => {
|
||||
const body = "Paid: " + (memo || "your invoice");
|
||||
if (memo) {
|
||||
expect(body).toBe(`Paid: ${memo}`);
|
||||
} else {
|
||||
expect(body).toBe("Paid: your invoice");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle unicode and special characters", () => {
|
||||
const specialTexts = [
|
||||
"Bitcoin ₿",
|
||||
"Lightning ⚡",
|
||||
"Japanese: こんにちは",
|
||||
"Emoji: 🚀💰⚡₿",
|
||||
"HTML: <script>alert('test')</script>",
|
||||
"SQL: '; DROP TABLE users; --",
|
||||
];
|
||||
|
||||
specialTexts.forEach(text => {
|
||||
// Text should be preserved as-is
|
||||
const notification = { body: text };
|
||||
expect(notification.body).toBe(text);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -117,7 +117,6 @@ dataSource
|
||||
.initialize()
|
||||
.then(async (connection) => {
|
||||
// start worker
|
||||
console.log("db connected");
|
||||
console.log("running groundcontrol worker-blockprocessor");
|
||||
console.log(require("fs").readFileSync("./bowie.txt").toString("ascii"));
|
||||
|
||||
|
||||
@ -4,14 +4,13 @@ import { TokenToAddress } from "./entity/TokenToAddress";
|
||||
import { SendQueue } from "./entity/SendQueue";
|
||||
import dataSource from "./data-source";
|
||||
import { components } from "./openapi/api";
|
||||
import { LruCache } from "./lru-cache";
|
||||
require("dotenv").config();
|
||||
const url = require("url");
|
||||
let jayson = require("jayson/promise");
|
||||
let rpc = url.parse(process.env.BITCOIN_RPC);
|
||||
let client = jayson.client.http(rpc);
|
||||
|
||||
const processedTxids = new LruCache(250000);
|
||||
let processedTxids = {};
|
||||
if (!process.env.BITCOIN_RPC) {
|
||||
console.error("not all env variables set");
|
||||
process.exit();
|
||||
@ -30,7 +29,7 @@ process
|
||||
let sendQueueRepository: Repository<SendQueue>;
|
||||
|
||||
async function processMempool() {
|
||||
process.env.VERBOSE && console.log("cached txids=", processedTxids.size);
|
||||
process.env.VERBOSE && console.log("cached txids=", Object.keys(processedTxids).length);
|
||||
const responseGetrawmempool = await client.request("getrawmempool", []);
|
||||
process.env.VERBOSE && console.log(responseGetrawmempool.result.length, "txs in mempool");
|
||||
|
||||
@ -43,7 +42,7 @@ async function processMempool() {
|
||||
for (const txid of responseGetrawmempool.result) {
|
||||
countTxidsProcessed++;
|
||||
if (!txid) continue;
|
||||
if (!processedTxids.has(txid)) rpcBatch.push(client.request("getrawtransaction", [txid, true], undefined, false));
|
||||
if (!processedTxids[txid]) rpcBatch.push(client.request("getrawtransaction", [txid, true], undefined, false));
|
||||
if (rpcBatch.length >= batchSize || countTxidsProcessed === responseGetrawmempool.result.length) {
|
||||
const startBatch = +new Date();
|
||||
// got enough txids lets batch fetch them from bitcoind rpc
|
||||
@ -54,7 +53,7 @@ async function processMempool() {
|
||||
if (output.scriptPubKey && (output.scriptPubKey.addresses || output.scriptPubKey.address)) {
|
||||
for (const address of output.scriptPubKey?.addresses ?? (output.scriptPubKey?.address ? [output.scriptPubKey?.address] : [])) {
|
||||
addresses.push(address);
|
||||
processedTxids.add(response.result.txid);
|
||||
processedTxids[response.result.txid] = true;
|
||||
const payload: components["schemas"]["PushNotificationOnchainAddressGotUnconfirmedTransaction"] = {
|
||||
address,
|
||||
txid: response.result.txid,
|
||||
@ -116,7 +115,6 @@ dataSource
|
||||
.initialize()
|
||||
.then(async (connection) => {
|
||||
// start worker
|
||||
console.log("db connected");
|
||||
console.log("running groundcontrol worker-processmempool");
|
||||
console.log(require("fs").readFileSync("./bowie.txt").toString("ascii"));
|
||||
|
||||
|
||||
@ -25,7 +25,6 @@ dataSource
|
||||
.initialize()
|
||||
.then(async (connection) => {
|
||||
// start worker
|
||||
console.log("db connected");
|
||||
console.log("running groundcontrol worker-sender");
|
||||
console.log(require("fs").readFileSync("./bowie.txt").toString("ascii"));
|
||||
|
||||
|
||||
@ -6,5 +6,6 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
exclude: ["node_modules", "dist", "build", ".git", ".github"],
|
||||
setupFiles: ["reflect-metadata"],
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user