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:
parent
1a8049ab0b
commit
440e37c19a
47
.github/workflows/ci.yml
vendored
47
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -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
39
TESTING.md
Normal 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
3344
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
53
src/tests/stringUtils.test.ts
Normal file
53
src/tests/stringUtils.test.ts
Normal 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
10
src/utils/stringUtils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
10
vitest.config.mts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user