Create unit test and GitHub Actions (#294)

* Update CI workflow, dependencies, and add Vitest for testing

Co-authored-by: overtorment <overtorment@gmail.com>

* Remove Vitest UI and related configuration

Co-authored-by: overtorment <overtorment@gmail.com>

* Move string utils tests to dedicated tests directory

Co-authored-by: overtorment <overtorment@gmail.com>

* Update CI workflow to separate lint and test jobs

Co-authored-by: overtorment <overtorment@gmail.com>

* Rename vitest.config.ts to vitest.config.mts

Co-authored-by: overtorment <overtorment@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Overtorment 2025-07-01 16:29:27 +01:00 committed by GitHub
parent 1a8049ab0b
commit 440e37c19a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 3496 additions and 92 deletions

View File

@ -1,37 +1,40 @@
name: Tests
name: CI
on: [pull_request]
jobs:
lint:
runs-on: macos-latest
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@v3
- name: Specify node version
uses: actions/setup-node@v2-beta
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: "npm"
- name: Use npm caches
uses: actions/cache@v2
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
node-version: 18
cache: "npm"
- name: Use node_modules caches
id: cache-nm
uses: actions/cache@v2
with:
path: node_modules
key: ${{ runner.os }}-nm-${{ hashFiles('package-lock.json') }}
- name: Install node_modules
if: steps.cache-nm.outputs.cache-hit != 'true'
run: npm install
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run lint
run: npm run test:run

View File

@ -96,7 +96,6 @@ Set them as env variables or put them into `.env` file in project root dir.
- `GOOGLE_KEY_FILE` - json file with Google key for FCM, in hex
- `GOOGLE_PROJECT_ID` - acquired with the key file
### Getting certificates
- outdated https://dev.to/jakubkoci/react-native-push-notifications-313i

39
TESTING.md Normal file
View File

@ -0,0 +1,39 @@
# Testing
This project uses [Vitest](https://vitest.dev/) for unit testing.
## Running Tests
```bash
# Run tests once
npm run test:run
# Run tests in watch mode
npm test
```
## Test Structure
Tests are located in the `src/tests/` directory with the `.test.ts` suffix.
Current test coverage includes:
- `StringUtils` - Utility functions for shortening addresses and transaction IDs
## GitHub Actions
The CI pipeline runs on every pull request via GitHub Actions with two separate jobs:
### Lint Job
1. Sets up Node.js 18
2. Installs dependencies
3. Runs linting (Prettier + TypeScript compilation)
### Test Job
1. Sets up Node.js 18
2. Installs dependencies
3. Executes the test suite
Both jobs run in parallel for faster feedback. The workflow file is located at `.github/workflows/ci.yml`.

3344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@
"devDependencies": {
"@types/node": "18.7.16",
"openapi-typescript": "^5.4.1",
"prettier": "2.0.5"
"prettier": "2.0.5",
"vitest": "^1.0.0"
},
"dependencies": {
"body-parser": "^1.20.2",
@ -30,6 +31,8 @@
"worker-blockprocessor": "ts-node src/worker-blockprocessor.ts",
"worker-processmempool": "ts-node src/worker-processmempool.ts",
"worker-sender": "ts-node src/worker-sender.ts",
"openapi": "npx openapi-typescript openapi.yaml --additional-properties true --export-type true --output src/openapi/api.ts"
"openapi": "npx openapi-typescript openapi.yaml --additional-properties true --export-type true --output src/openapi/api.ts",
"test": "vitest",
"test:run": "vitest run"
}
}

View File

@ -3,14 +3,15 @@ const fs = require("fs");
const text = fs.readFileSync("all_tokens_unique.csv", { encoding: "utf8" });
const lines = text.split("\n");
console.log('# got', lines.length, 'tokens');
console.log("# got", lines.length, "tokens");
console.log("INSERT INTO send_queue_2 VALUES ");
let fisrt = true;
for (const line of lines.slice(0, 1000000)) {
const [token, os] = line.split("\t");
const sql = (fisrt ? '' : ', ') + `(null, '{"type":5,"token":"${token}","os":"${os}","text":"If you are using Lightning, please read our blog post. The service is sunsetting. Balances should be moved to another service"}', null)`;
const sql =
(fisrt ? "" : ", ") + `(null, '{"type":5,"token":"${token}","os":"${os}","text":"If you are using Lightning, please read our blog post. The service is sunsetting. Balances should be moved to another service"}', null)`;
console.log(sql);
fisrt = false;
}

View File

@ -5,6 +5,7 @@ import { TokenToAddress } from "../entity/TokenToAddress";
import { TokenToHash } from "../entity/TokenToHash";
import { TokenToTxid } from "../entity/TokenToTxid";
import { components } from "../openapi/api";
import { StringUtils } from "../utils/stringUtils";
const jwt = require("jsonwebtoken");
const http2 = require("http2");
require("dotenv").config();
@ -15,10 +16,10 @@ if (!process.env.APNS_P8 || !process.env.APPLE_TEAM_ID || !process.env.APNS_P8_K
}
const keyFileStr = Buffer.from(process.env.GOOGLE_KEY_FILE, "hex").toString("ascii");
require('fs').writeFileSync('/tmp/google_key_file.json', keyFileStr, {encoding: "ascii"});
require("fs").writeFileSync("/tmp/google_key_file.json", keyFileStr, { encoding: "ascii" });
const auth = new GoogleAuth({
keyFile: '/tmp/google_key_file.json',
scopes: 'https://www.googleapis.com/auth/cloud-platform',
keyFile: "/tmp/google_key_file.json",
scopes: "https://www.googleapis.com/auth/cloud-platform",
});
/**
@ -77,16 +78,16 @@ export class GroundControlToMajorTom {
): Promise<void> {
const fcmPayload = {
message: {
token: '',
token: "",
data: {
badge: String(pushNotification.badge),
tag: pushNotification.txid,
},
notification: {
title: "New unconfirmed transaction",
body: "You received new transfer on " + GroundControlToMajorTom.shortenAddress(pushNotification.address),
body: "You received new transfer on " + StringUtils.shortenAddress(pushNotification.address),
},
}
},
};
const apnsPayload = {
@ -94,7 +95,7 @@ export class GroundControlToMajorTom {
badge: pushNotification.badge,
alert: {
title: "New Transaction - Pending",
body: "Received transaction on " + GroundControlToMajorTom.shortenAddress(pushNotification.address),
body: "Received transaction on " + StringUtils.shortenAddress(pushNotification.address),
},
sound: "default",
},
@ -114,7 +115,7 @@ export class GroundControlToMajorTom {
},
notification: {
title: "Transaction - Confirmed",
body: "Your transaction " + GroundControlToMajorTom.shortenTxid(pushNotification.txid) + " has been confirmed",
body: "Your transaction " + StringUtils.shortenTxid(pushNotification.txid) + " has been confirmed",
},
},
};
@ -124,7 +125,7 @@ export class GroundControlToMajorTom {
badge: pushNotification.badge,
alert: {
title: "Transaction - Confirmed",
body: "Your transaction " + GroundControlToMajorTom.shortenTxid(pushNotification.txid) + " has been confirmed",
body: "Your transaction " + StringUtils.shortenTxid(pushNotification.txid) + " has been confirmed",
},
sound: "default",
},
@ -165,16 +166,16 @@ export class GroundControlToMajorTom {
static async pushOnchainAddressWasPaid(dataSource: DataSource, serverKey: string, apnsP8: string, pushNotification: components["schemas"]["PushNotificationOnchainAddressGotPaid"]): Promise<void> {
const fcmPayload = {
message: {
token: '',
token: "",
data: {
badge: String(pushNotification.badge),
tag: pushNotification.txid,
},
notification: {
title: "+" + pushNotification.sat + " sats",
body: "Received on " + GroundControlToMajorTom.shortenAddress(pushNotification.address),
body: "Received on " + StringUtils.shortenAddress(pushNotification.address),
},
}
},
};
const apnsPayload = {
@ -182,7 +183,7 @@ export class GroundControlToMajorTom {
badge: pushNotification.badge,
alert: {
title: "+" + pushNotification.sat + " sats",
body: "Received on " + GroundControlToMajorTom.shortenAddress(pushNotification.address),
body: "Received on " + StringUtils.shortenAddress(pushNotification.address),
},
sound: "default",
},
@ -322,7 +323,7 @@ export class GroundControlToMajorTom {
try {
responseText = await rawResponse.text();
} catch (error) {
console.error("error getting response from FCM", error);
console.error("error getting response from FCM", error);
}
delete fcmPayload["message"]["token"]; // compacting a bit, we dont need token in payload as well
@ -382,19 +383,10 @@ export class GroundControlToMajorTom {
static processApnsResponse(dataSource: DataSource, response, token: string) {
if (response && response.data) {
try {
console.log('parsing', response.data);
console.log("parsing", response.data);
const data = JSON.parse(response.data);
if (data && data.reason && ["Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic"].includes(data.reason)) return GroundControlToMajorTom.killDeadToken(dataSource, token);
} catch (_) {}
}
}
static shortenAddress(address) {
if (address.length < 10) return address;
return address.substring(0, 5) + "...." + address.substring(address.length - 4);
}
static shortenTxid(txid) {
return GroundControlToMajorTom.shortenAddress(txid);
}
}

View File

@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { StringUtils } from "../utils/stringUtils";
describe("StringUtils", () => {
describe("shortenAddress", () => {
it("should shorten long addresses correctly", () => {
const longAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
const result = StringUtils.shortenAddress(longAddress);
expect(result).toBe("bc1qx....0wlh");
});
it("should shorten long transaction IDs correctly", () => {
const longTxid = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456";
const result = StringUtils.shortenAddress(longTxid);
expect(result).toBe("a1b2c....3456");
});
it("should return address unchanged if length is less than 10", () => {
const shortAddress = "short";
const result = StringUtils.shortenAddress(shortAddress);
expect(result).toBe("short");
});
it("should handle exactly 10 character addresses", () => {
const tenCharAddress = "1234567890";
const result = StringUtils.shortenAddress(tenCharAddress);
expect(result).toBe("12345....7890");
});
it("should handle empty strings", () => {
const emptyAddress = "";
const result = StringUtils.shortenAddress(emptyAddress);
expect(result).toBe("");
});
});
describe("shortenTxid", () => {
it("should behave exactly like shortenAddress", () => {
const longTxid = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456";
const resultFromShortenTxid = StringUtils.shortenTxid(longTxid);
const resultFromShortenAddress = StringUtils.shortenAddress(longTxid);
expect(resultFromShortenTxid).toBe(resultFromShortenAddress);
expect(resultFromShortenTxid).toBe("a1b2c....3456");
});
it("should handle short transaction IDs", () => {
const shortTxid = "abc123";
const result = StringUtils.shortenTxid(shortTxid);
expect(result).toBe("abc123");
});
});
});

10
src/utils/stringUtils.ts Normal file
View File

@ -0,0 +1,10 @@
export class StringUtils {
static shortenAddress(address: string): string {
if (address.length < 10) return address;
return address.substring(0, 5) + "...." + address.substring(address.length - 4);
}
static shortenTxid(txid: string): string {
return StringUtils.shortenAddress(txid);
}
}

View File

@ -88,12 +88,7 @@ async function processBlock(blockNum, sendQueueRepository: Repository<SendQueue>
}
// batch insert via a raw query as its faster
await sendQueueRepository
.createQueryBuilder()
.insert()
.into(SendQueue)
.values(entities2save)
.execute();
await sendQueueRepository.createQueryBuilder().insert().into(SendQueue).values(entities2save).execute();
// now, checking if there is a subscription to one of the mined txids:
const query2 = dataSource.getRepository(TokenToTxid).createQueryBuilder().where("txid IN (:...txids)", { txids });
@ -114,14 +109,8 @@ async function processBlock(blockNum, sendQueueRepository: Repository<SendQueue>
});
}
// batch insert via a raw query as its faster
await sendQueueRepository
.createQueryBuilder()
.insert()
.into(SendQueue)
.values(entities2save)
.execute();
await sendQueueRepository.createQueryBuilder().insert().into(SendQueue).values(entities2save).execute();
}
dataSource
@ -156,7 +145,7 @@ dataSource
await processBlock(nextBlockToProcess, sendQueueRepository);
} catch (error) {
console.warn("exception when processing block:", error, "continuing as usuall");
if (error.message.includes('socket hang up')) {
if (error.message.includes("socket hang up")) {
// issue fetching block from bitcoind
console.warn("retrying block number", nextBlockToProcess);
continue; // skip overwriting `LAST_PROCESSED_BLOCK` in `KeyValue` table

View File

@ -51,7 +51,7 @@ async function processMempool() {
if (response.result && response.result.vout) {
for (const output of response.result.vout) {
if (output.scriptPubKey && (output.scriptPubKey.addresses || output.scriptPubKey.address)) {
for (const address of output.scriptPubKey?.addresses ?? (output.scriptPubKey?.address ? [output.scriptPubKey?.address] : []) ) {
for (const address of output.scriptPubKey?.addresses ?? (output.scriptPubKey?.address ? [output.scriptPubKey?.address] : [])) {
addresses.push(address);
processedTxids[response.result.txid] = true;
const payload: components["schemas"]["PushNotificationOnchainAddressGotUnconfirmedTransaction"] = {
@ -125,7 +125,7 @@ dataSource
try {
await processMempool();
} catch (error) {
console.warn('Exception in processMempool():', error);
console.warn("Exception in processMempool():", error);
}
const end = +new Date();
console.log("processing mempool took", (end - start) / 1000, "sec");

View File

@ -33,10 +33,11 @@ dataSource
while (1) {
// getting random record so multiple workers wont fight for the same record to send
const [record] = await sendQueueRepository .createQueryBuilder()
.orderBy('RAND()') // mysql-specific
.limit(1)
.getMany();
const [record] = await sendQueueRepository
.createQueryBuilder()
.orderBy("RAND()") // mysql-specific
.limit(1)
.getMany();
// ^^^ 'order by rand' is suboptimal, but will have to do for now, especially if we are aiming to keep
// queue table near-empty
@ -50,7 +51,7 @@ dataSource
const query = `SELECT GET_LOCK(?, ?) as result`;
const result = await sendQueueRepository.query(query, [`send${record.id}`, 0]);
if (result[0].result !== 1) {
process.env.VERBOSE && console.log('could not acquire lock, skipping record');
process.env.VERBOSE && console.log("could not acquire lock, skipping record");
continue;
}

10
vitest.config.mts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
exclude: ["node_modules", "dist", "build", ".git", ".github"],
},
});