diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ac38add..322ff1f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,6 +3,13 @@ on: push: branches: - main + pull_request: + types: + - opened + - synchronize + - reopened +permissions: + contents: read jobs: publish_docs: runs-on: ubuntu-22.04 @@ -22,6 +29,7 @@ jobs: - name: Deploy docs to Cloudflare uses: cloudflare/wrangler-action@v3 + if: github.ref == 'refs/heads/main' with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/biome.json b/biome.json index 047cd18..9092b79 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["src/**/*.ts"] + "includes": ["src/**/*.ts", "docs/**/*.ts", ".github/workflows/*.yml"] }, "formatter": { "enabled": true, diff --git a/bun.lockb b/bun.lockb index 891680d..fd8e37b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8c64812..fe01a01 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -69,7 +69,9 @@ export default defineConfig({ }, { text: "Guides", - items: [{ text: "Create YNAB Token", link: "/guide/create-ynab-token" }], + items: [ + { text: "Create YNAB Token", link: "/guide/create-ynab-token" }, + ], }, { text: "Connectors", diff --git a/package.json b/package.json index 3bd44f0..271d9d5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/bun": "1.3.0", "@types/retry": "0.12.5", "lefthook": "1.13.6", + "msw": "2.11.5", "pino-pretty": "13.1.2", "vitepress": "^2.0.0-alpha.12", "wrangler": "4.43.0" diff --git a/src/browser/browserAdapter.ts b/src/browser/browserAdapter.ts new file mode 100644 index 0000000..7ded681 --- /dev/null +++ b/src/browser/browserAdapter.ts @@ -0,0 +1,43 @@ +/** + * Abstraction layer for browser automation that makes testing easier. + * + * This provides interfaces that can be implemented by both real browser + * instances (via Puppeteer) and mock implementations for testing. + */ + +/** + * Represents a selector result that can be evaluated + */ +export interface SelectorResult { + evaluate(fn: (el: Element) => string | null): Promise; +} + +/** + * Represents a locator for interacting with elements + */ +export interface Locator { + click(): Promise; + fill(text: string): Promise; +} + +/** + * Represents a browser page with methods for automation + */ +export interface PageAdapter { + goto(url: string): Promise; + type(selector: string, text: string): Promise; + click(selector: string): Promise; + locator(selector: string): Locator; + waitForSelector(selector: string): Promise; + waitForNetworkIdle(): Promise; + url(): string; + close(): Promise; +} + +/** + * Represents a browser instance + */ +export interface BrowserAdapter { + newPage(): Promise; + close(): Promise; +} diff --git a/src/browser.ts b/src/browser/index.ts similarity index 53% rename from src/browser.ts rename to src/browser/index.ts index 2c6ace9..a7c1fe0 100644 --- a/src/browser.ts +++ b/src/browser/index.ts @@ -1,26 +1,19 @@ import puppeteer from "puppeteer"; -import config from "./config.ts"; +import { getConfig } from "../config.ts"; +import type { BrowserAdapter } from "./browserAdapter.ts"; +import { PuppeteerAdapter } from "./puppeteerAdapter.ts"; -export const isBrowserAvailable = async () => { - try { - const browser = await getBrowser(); - await browser.close(); - - return true; - } catch { - return false; - } -}; - -export const getBrowser = async () => { +export const getBrowser = async (): Promise => { + const config = await getConfig(); const endpoint = config.browser?.endpoint; const isProduction = Bun.env.NODE_ENV === "production"; // In non-production environments without an endpoint, launch a headful browser if (!isProduction && !endpoint) { - return puppeteer.launch({ + const browser = await puppeteer.launch({ headless: false, }); + return new PuppeteerAdapter(browser); } // In production or when an endpoint is configured, connect to the endpoint @@ -28,7 +21,8 @@ export const getBrowser = async () => { throw new Error("Browser endpoint is not configured"); } - return puppeteer.connect({ + const browser = await puppeteer.connect({ browserWSEndpoint: endpoint, }); + return new PuppeteerAdapter(browser); }; diff --git a/src/browser/mockBrowser.ts b/src/browser/mockBrowser.ts new file mode 100644 index 0000000..ca8c396 --- /dev/null +++ b/src/browser/mockBrowser.ts @@ -0,0 +1,193 @@ +import type { + BrowserAdapter, + Locator, + PageAdapter, + SelectorResult, +} from "./browserAdapter.ts"; + +/** + * Configuration for a mock page scenario + */ +export interface MockPageScenario { + /** + * Mock values to return when waitForSelector is called. + * Key is the selector, value is the text content to return. + */ + selectorValues?: Record; + + /** + * Mock URLs to return for page.url() calls. + * Updated as goto() is called or can be set explicitly. + */ + currentUrl?: string; + + /** + * Track interactions for verification in tests + */ + interactions?: Array<{ + type: string; + selector?: string; + value?: string; + url?: string; + }>; + + /** + * Whether to throw errors for specific selectors + */ + selectorErrors?: Record; + + /** + * URL transitions to simulate after specific actions + * Allows tests to simulate page navigation after interactions + */ + urlTransitions?: Array<{ + trigger: { + type: string; + selector?: string; + url?: string; + }; + newUrl: string; + }>; +} + +/** + * Mock selector result for testing + */ +class MockSelectorResult implements SelectorResult { + constructor(private value: string) {} + + async evaluate(_fn: (el: Element) => string | null): Promise { + return this.value; + } +} + +/** + * Mock locator for testing + */ +class MockLocator implements Locator { + constructor( + private selector: string, + private scenario: MockPageScenario, + ) {} + + async click(): Promise { + this.scenario.interactions?.push({ + type: "locator.click", + selector: this.selector, + }); + + // Check for URL transitions + const transition = this.scenario.urlTransitions?.find( + (t) => + t.trigger.type === "locator.click" && + t.trigger.selector === this.selector, + ); + if (transition) { + this.scenario.currentUrl = transition.newUrl; + } + } + + async fill(text: string): Promise { + this.scenario.interactions?.push({ + type: "locator.fill", + selector: this.selector, + value: text, + }); + } +} + +/** + * Mock page adapter for testing + */ +class MockPageAdapter implements PageAdapter { + constructor(private scenario: MockPageScenario) {} + + async goto(url: string): Promise { + this.scenario.interactions?.push({ type: "goto", url }); + + // Check for URL transitions + const transition = this.scenario.urlTransitions?.find( + (t) => t.trigger.type === "goto" && t.trigger.url === url, + ); + if (transition) { + this.scenario.currentUrl = transition.newUrl; + } else { + this.scenario.currentUrl = url; + } + } + + async type(selector: string, text: string): Promise { + this.scenario.interactions?.push({ type: "type", selector, value: text }); + } + + async click(selector: string): Promise { + this.scenario.interactions?.push({ type: "click", selector }); + + // Check for URL transitions + const transition = this.scenario.urlTransitions?.find( + (t) => t.trigger.type === "click" && t.trigger.selector === selector, + ); + if (transition) { + this.scenario.currentUrl = transition.newUrl; + } + } + + locator(selector: string): Locator { + return new MockLocator(selector, this.scenario); + } + + async waitForSelector(selector: string): Promise { + // Check if we should throw an error for this selector + if (this.scenario.selectorErrors?.[selector]) { + throw this.scenario.selectorErrors[selector]; + } + + // Return mock value if configured + const value = this.scenario.selectorValues?.[selector]; + if (value !== undefined) { + return new MockSelectorResult(value); + } + + return null; + } + + async waitForNetworkIdle(): Promise { + this.scenario.interactions?.push({ type: "waitForNetworkIdle" }); + } + + url(): string { + return this.scenario.currentUrl || "about:blank"; + } + + async close(): Promise { + this.scenario.interactions?.push({ type: "close" }); + } +} + +/** + * Mock browser adapter for testing + */ +export class MockBrowserAdapter implements BrowserAdapter { + constructor(private scenario: MockPageScenario) {} + + async newPage(): Promise { + return new MockPageAdapter(this.scenario); + } + + async close(): Promise { + this.scenario.interactions?.push({ type: "browser.close" }); + } +} + +/** + * Helper function to create a mock browser for testing + */ +export function createMockBrowser( + scenario: MockPageScenario = {}, +): BrowserAdapter { + // Initialize interactions array if not provided + if (!scenario.interactions) { + scenario.interactions = []; + } + return new MockBrowserAdapter(scenario); +} diff --git a/src/browser/puppeteerAdapter.ts b/src/browser/puppeteerAdapter.ts new file mode 100644 index 0000000..a4e6b77 --- /dev/null +++ b/src/browser/puppeteerAdapter.ts @@ -0,0 +1,96 @@ +import type { + Browser, + ElementHandle, + Page, + Locator as PuppeteerLocatorType, +} from "puppeteer"; +import type { + BrowserAdapter, + Locator, + PageAdapter, + SelectorResult, +} from "./browserAdapter.ts"; + +/** + * Wraps a Puppeteer element handle to provide evaluate functionality + */ +class PuppeteerSelectorResult implements SelectorResult { + constructor(private element: ElementHandle) {} + + async evaluate(fn: (el: Element) => string | null): Promise { + return this.element.evaluate(fn); + } +} + +/** + * Wraps Puppeteer locator functionality + */ +class PuppeteerLocator implements Locator { + constructor(private locator: PuppeteerLocatorType) {} + + async click(): Promise { + await this.locator.click(); + } + + async fill(text: string): Promise { + await this.locator.fill(text); + } +} + +/** + * Wraps a Puppeteer Page to implement PageAdapter interface + */ +class PuppeteerPageAdapter implements PageAdapter { + constructor(private page: Page) {} + + async goto(url: string): Promise { + await this.page.goto(url); + } + + async type(selector: string, text: string): Promise { + await this.page.type(selector, text); + } + + async click(selector: string): Promise { + await this.page.click(selector); + } + + locator(selector: string): Locator { + const locator = this.page.locator(selector); + return new PuppeteerLocator(locator); + } + + async waitForSelector(selector: string): Promise { + const element = await this.page.waitForSelector(selector); + if (!element) return null; + return new PuppeteerSelectorResult(element); + } + + async waitForNetworkIdle(): Promise { + await this.page.waitForNetworkIdle(); + } + + url(): string { + return this.page.url(); + } + + async close(): Promise { + await this.page.close(); + } +} + +/** + * Wraps a Puppeteer Browser to implement BrowserAdapter interface + */ +export class PuppeteerAdapter implements BrowserAdapter { + constructor(private browser: Browser) {} + + async newPage(): Promise { + const page = await this.browser.newPage(); + return new PuppeteerPageAdapter(page); + } + + async close(): Promise { + await this.browser.close(); + } +} diff --git a/src/config.ts b/src/config.ts index 2b7c740..8515ab7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -115,22 +115,35 @@ const schemaConfig = z export { schemaConfig }; +export type Config = z.infer; + const configPath = Bun.env.NODE_ENV === "production" ? "/config.yaml" : path.join(__dirname, "../config.yaml"); -// TODO: ensure this only loads once -const config = await loadConfig({ - schema: schemaConfig, - adapters: yamlAdapter({ - path: configPath, - }), - onError: (e) => { - console.error(`error while loading config: ${fromError(e).message}`); - process.exit(1); - }, - logger, -}); +let cachedConfig: Config | null = null; + +/** + * Get the configuration, loading it if not already loaded. + * The config is cached after the first load. + */ +export const getConfig = async (): Promise => { + if (cachedConfig) { + return cachedConfig; + } -export default config; + cachedConfig = await loadConfig({ + schema: schemaConfig, + adapters: yamlAdapter({ + path: configPath, + }), + onError: (e) => { + console.error(`error while loading config: ${fromError(e).message}`); + process.exit(1); + }, + logger, + }); + + return cachedConfig; +}; diff --git a/src/connectors/index.ts b/src/connectors/index.ts index f83b2ec..0e42cb1 100644 --- a/src/connectors/index.ts +++ b/src/connectors/index.ts @@ -1,16 +1,14 @@ -import type config from "../config.ts"; +import type { Config } from "../config.ts"; import { standardLifePensionConnector } from "./standardLifePension.ts"; import { getTrading212Balance } from "./trading212.ts"; import { getUkStudentLoanBalance } from "./ukStudentLoan.ts"; -type AccountType = (typeof config.accounts)[number]["type"]; +type AccountType = Config["accounts"][number]["type"]; const connectors: { [type in AccountType]: { friendlyName: string; - getBalance: ( - account: (typeof config.accounts)[number], - ) => Promise; + getBalance: (account: Config["accounts"][number]) => Promise; }; } = { trading212: { @@ -64,9 +62,7 @@ type AccountResult = interface Connector { friendlyName: string; - getBalance: ( - account: (typeof config.accounts)[number], - ) => Promise; + getBalance: (account: Config["accounts"][number]) => Promise; } export { connectors }; diff --git a/src/connectors/standardLifePension.test.ts b/src/connectors/standardLifePension.test.ts new file mode 100644 index 0000000..5c8f4a4 --- /dev/null +++ b/src/connectors/standardLifePension.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { + createMockBrowser, + type MockPageScenario, +} from "../browser/mockBrowser.ts"; +import { StandardLifePensionConnector } from "./standardLifePension.ts"; + +describe("Standard Life Pension Connector", () => { + it("should successfully retrieve balance when no 2FA is required", async () => { + // Create a mock browser that simulates a successful login without 2FA + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + ".we_hud-plan-value-amount": "£45,678.90", + }, + interactions: [], + urlTransitions: [ + { + trigger: { type: "click", selector: "#submit" }, + newUrl: + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + // Create connector with mock browser + const connector = new StandardLifePensionConnector(async () => mockBrowser); + + // Test account + const account = { + type: "standard_life_pension" as const, + username: "test@example.com", + password: "testpassword", + policyNumber: "12345", + name: "Test Pension", + ynabAccountId: "test-account-id", + interval: "0 0 * * *", + }; + + const result = await connector.getBalance(account); + + // Verify the result + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(45678.9); + } + + // Verify interactions happened in correct order + expect(mockScenario.interactions).toContainEqual({ + type: "goto", + url: "https://online.standardlife.com/secure/customer-authentication-client/customer/login", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "type", + selector: "#userid", + value: "test@example.com", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "type", + selector: "#password", + value: "testpassword", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "click", + selector: "#submit", + }); + }); + + it("should return error when balance element is not found", async () => { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: {}, + interactions: [], + urlTransitions: [ + { + trigger: { type: "click", selector: "#submit" }, + newUrl: + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const connector = new StandardLifePensionConnector(async () => mockBrowser); + + const account = { + type: "standard_life_pension" as const, + username: "test@example.com", + password: "testpassword", + policyNumber: "12345", + name: "Test Pension", + ynabAccountId: "test-account-id", + interval: "0 0 * * *", + }; + + const result = await connector.getBalance(account); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe("Could not find balance element on page"); + expect(result.canRetry).toBe(true); + } + }); + + it("should throw error for invalid account type", async () => { + const mockBrowser = createMockBrowser({}); + + const connector = new StandardLifePensionConnector(async () => mockBrowser); + + const account = { + type: "trading_212", + trading212ApiKey: "test", + trading212SecretKey: "test", + } as const; + + // @ts-expect-error - Testing invalid account type + await expect(connector.getBalance(account)).rejects.toThrow( + "Invalid account type for Standard Life Pension connector", + ); + }); + + it("should parse various balance formats correctly", async () => { + const testCases = [ + { input: "£45,678.90", expected: 45678.9 }, + { input: "£1,234.56", expected: 1234.56 }, + { input: "£100", expected: 100 }, + { input: "£1,000,000.00", expected: 1000000 }, + ]; + + for (const testCase of testCases) { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + ".we_hud-plan-value-amount": testCase.input, + }, + interactions: [], + urlTransitions: [ + { + trigger: { type: "click", selector: "#submit" }, + newUrl: + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const connector = new StandardLifePensionConnector( + async () => mockBrowser, + ); + + const account = { + type: "standard_life_pension" as const, + username: "test@example.com", + password: "testpassword", + policyNumber: "12345", + name: "Test Pension", + ynabAccountId: "test-account-id", + interval: "0 0 * * *", + }; + + const result = await connector.getBalance(account); + + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(testCase.expected); + } + } + }); +}); diff --git a/src/connectors/standardLifePension.ts b/src/connectors/standardLifePension.ts index abcf0e7..e1d7ef4 100644 --- a/src/connectors/standardLifePension.ts +++ b/src/connectors/standardLifePension.ts @@ -1,9 +1,10 @@ import { await2FACode } from "../2fa.ts"; -import { getBrowser } from "../browser.ts"; -import type config from "../config.ts"; +import { getBrowser } from "../browser"; +import type { BrowserAdapter } from "../browser/browserAdapter.ts"; +import type { Config } from "../config.ts"; import type { AccountResult, Connector } from "./index.ts"; -type AccountType = (typeof config.accounts)[number]; +type AccountType = Config["accounts"][number]; const STANDARD_LIFE_PENSION_AUTH_URL = "https://online.standardlife.com/secure/customer-authentication-client/customer/login"; @@ -18,9 +19,13 @@ const parseBalanceString = (input: string): number => { return parseFloat(m[1]?.replace(/,/g, "") || "0"); }; -class StandardLifePensionConnector implements Connector { +export class StandardLifePensionConnector implements Connector { friendlyName = "Standard Life Pension"; + constructor( + private browserFactory: () => Promise = getBrowser, + ) {} + async getBalance(account: AccountType): Promise { if (account.type !== "standard_life_pension") { throw new Error( @@ -29,7 +34,7 @@ class StandardLifePensionConnector implements Connector { } // get a browser instance - const browser = await getBrowser(); + const browser = await this.browserFactory(); // sign in const page = await browser.newPage(); diff --git a/src/connectors/trading212.mock.ts b/src/connectors/trading212.mock.ts new file mode 100644 index 0000000..e269e3d --- /dev/null +++ b/src/connectors/trading212.mock.ts @@ -0,0 +1,125 @@ +import { type HttpHandler, HttpResponse, http } from "msw"; + +const TRADING212_BASE_URL = "https://live.trading212.com/api/v0"; + +/** + * Trading 212 API mock response types + */ +export interface Trading212AccountCash { + blocked: number; + free: number; + invested: number; + pieCash: number; + ppl: number; + result: number; + total: number; +} + +/** + * Helper function to validate Basic auth header + */ +const validateAuthHeader = ( + authHeader: string | null, +): { valid: boolean; apiKey?: string; apiSecret?: string } => { + if (!authHeader || !authHeader.startsWith("Basic ")) { + return { valid: false }; + } + + try { + const base64Credentials = authHeader.slice(6); + const decoded = Buffer.from(base64Credentials, "base64").toString("utf-8"); + const [apiKey, apiSecret] = decoded.split(":"); + + if (!apiKey || !apiSecret) { + return { valid: false }; + } + + return { valid: true, apiKey, apiSecret }; + } catch { + return { valid: false }; + } +}; + +/** + * Create Trading 212 API mock handlers + */ +export const createTrading212Handlers = ( + config: { + validApiKey?: string; + validApiSecret?: string; + accountCash?: Trading212AccountCash; + returnError?: "unauthorized" | "forbidden" | "rate-limit" | "server-error"; + returnInvalidFormat?: boolean; + } = {}, +): HttpHandler[] => { + const { + validApiKey = "valid-api-key", + validApiSecret = "valid-api-secret", + accountCash = { + blocked: 0, + free: 1000.5, + invested: 5000.25, + pieCash: 0, + ppl: 234.75, + result: 5235, + total: 6235.5, + }, + returnError, + returnInvalidFormat = false, + } = config; + + return [ + // GET /equity/account/cash - Get account cash information + http.get(`${TRADING212_BASE_URL}/equity/account/cash`, ({ request }) => { + const authHeader = request.headers.get("Authorization"); + const auth = validateAuthHeader(authHeader); + + // Handle forced error scenarios + if (returnError === "rate-limit") { + return HttpResponse.json( + { message: "Too Many Requests" }, + { status: 429, statusText: "Too Many Requests" }, + ); + } + + if (returnError === "server-error") { + return HttpResponse.json( + { message: "Internal Server Error" }, + { status: 500, statusText: "Internal Server Error" }, + ); + } + + if (returnError === "forbidden") { + return HttpResponse.json( + { message: "Forbidden" }, + { status: 403, statusText: "Forbidden" }, + ); + } + + // Validate authentication + if ( + !auth.valid || + auth.apiKey !== validApiKey || + auth.apiSecret !== validApiSecret + ) { + return HttpResponse.json( + { message: "Unauthorized" }, + { status: 401, statusText: "Unauthorized" }, + ); + } + + // Return invalid format if requested + if (returnInvalidFormat) { + return HttpResponse.json({ invalid: "data" }); + } + + // Return successful account cash response + return HttpResponse.json(accountCash); + }), + ]; +}; + +/** + * Default Trading 212 handlers for testing + */ +export const trading212Handlers = createTrading212Handlers(); diff --git a/src/connectors/trading212.test.ts b/src/connectors/trading212.test.ts index fc86ee6..7043d53 100644 --- a/src/connectors/trading212.test.ts +++ b/src/connectors/trading212.test.ts @@ -1,7 +1,172 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { setupServer } from "msw/node"; +import { createTrading212Handlers } from "./trading212.mock.ts"; +import { getTrading212Balance } from "./trading212.ts"; -describe("Trading 212", () => { - it("should return an error if API key is invalid", async () => { - expect(true).toBe(true); +describe("Trading 212 Connector", () => { + const server = setupServer(); + + beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + it("should successfully retrieve balance", async () => { + const mockResponse = { + blocked: 0, + free: 1000.5, + invested: 5000.25, + pieCash: 0, + ppl: 234.75, + result: 5235, + total: 6235.5, + }; + + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + accountCash: mockResponse, + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(6235.5); + } + }); + + it("should return error for unauthorized access (401)", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "correct-key", + validApiSecret: "correct-secret", + }), + ); + + const result = await getTrading212Balance("invalid-key", "invalid-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Unauthorized: Check your Trading212 API Key and Secret Key.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should return error for forbidden access (403)", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnError: "forbidden", + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Forbidden: Check your Trading212 API Key scopes.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should return error for rate limit exceeded (429)", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnError: "rate-limit", + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Rate limit exceeded: Too many requests to Trading212 API.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should return error for other HTTP errors", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnError: "server-error", + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Error fetching Trading212 balance: 500 Internal Server Error", + ); + expect(result.canRetry).toBe(true); + } + }); + + it("should return error for invalid response format", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "test-api-key", + validApiSecret: "test-secret", + returnInvalidFormat: true, + }), + ); + + const result = await getTrading212Balance("test-api-key", "test-secret"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Unexpected response format from Trading212 API.", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should correctly encode API credentials in Authorization header", async () => { + server.use( + ...createTrading212Handlers({ + validApiKey: "myApiKey", + validApiSecret: "mySecret", + accountCash: { + blocked: 0, + free: 0, + invested: 0, + pieCash: 0, + ppl: 0, + result: 0, + total: 100, + }, + }), + ); + + const result = await getTrading212Balance("myApiKey", "mySecret"); + + // If the credentials were correctly encoded, the request should succeed + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(100); + } }); }); diff --git a/src/connectors/ukStudentLoan.test.ts b/src/connectors/ukStudentLoan.test.ts new file mode 100644 index 0000000..ca1acec --- /dev/null +++ b/src/connectors/ukStudentLoan.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "bun:test"; +import { + createMockBrowser, + type MockPageScenario, +} from "../browser/mockBrowser.ts"; +import { + getUkStudentLoanBalance, + parseBalanceString, +} from "./ukStudentLoan.ts"; + +describe("UK Student Loan Connector", () => { + it("should successfully retrieve balance", async () => { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + "#balanceId_1": "£45,678.90", + }, + interactions: [], + urlTransitions: [ + { + trigger: { + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + newUrl: + "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + { + trigger: { type: "locator.click", selector: "text/Start now" }, + newUrl: "https://www.gov.uk/manage-student-loan/select-option", + }, + { + trigger: { type: "locator.click", selector: "text/Continue" }, + newUrl: "https://www.gov.uk/manage-student-loan/login", + }, + { + trigger: { type: "locator.click", selector: "text/Login to account" }, + newUrl: "https://www.gov.uk/manage-student-loan/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const result = await getUkStudentLoanBalance( + "test@example.com", + "testpassword", + "testsecret", + async () => mockBrowser, + ); + + // Verify the result - balance should be negative (it's a loan) + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(-45678.9); + } + + // Verify interactions happened in correct order + expect(mockScenario.interactions).toContainEqual({ + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.click", + selector: "text/Start now", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.click", + selector: "#textForSignIn1", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.fill", + selector: "input#userId", + value: "test@example.com", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.fill", + selector: "input#password", + value: "testpassword", + }); + expect(mockScenario.interactions).toContainEqual({ + type: "locator.fill", + selector: "input#secretAnswer", + value: "testsecret", + }); + }); + + it("should return error when balance element is not found", async () => { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: {}, + interactions: [], + urlTransitions: [ + { + trigger: { + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + newUrl: + "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + { + trigger: { type: "locator.click", selector: "text/Start now" }, + newUrl: "https://www.gov.uk/manage-student-loan/select-option", + }, + { + trigger: { type: "locator.click", selector: "text/Continue" }, + newUrl: "https://www.gov.uk/manage-student-loan/login", + }, + { + trigger: { type: "locator.click", selector: "text/Login to account" }, + newUrl: "https://www.gov.uk/manage-student-loan/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const result = await getUkStudentLoanBalance( + "test@example.com", + "testpassword", + "testsecret", + async () => mockBrowser, + ); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toBe( + "Could not find balance element on UK Student Loan page", + ); + expect(result.canRetry).toBe(false); + } + }); + + it("should parse various balance formats correctly", async () => { + const testCases = [ + { input: "£45,678.90", expected: -45678.9 }, + { input: "£1,234.56", expected: -1234.56 }, + { input: "£100", expected: -100 }, + { input: "£1,000,000.00", expected: -1000000 }, + ]; + + for (const testCase of testCases) { + const mockScenario: MockPageScenario = { + currentUrl: "about:blank", + selectorValues: { + "#balanceId_1": testCase.input, + }, + interactions: [], + urlTransitions: [ + { + trigger: { + type: "goto", + url: "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + newUrl: + "https://www.gov.uk/sign-in-to-manage-your-student-loan-balance", + }, + { + trigger: { type: "locator.click", selector: "text/Start now" }, + newUrl: "https://www.gov.uk/manage-student-loan/select-option", + }, + { + trigger: { type: "locator.click", selector: "text/Continue" }, + newUrl: "https://www.gov.uk/manage-student-loan/login", + }, + { + trigger: { + type: "locator.click", + selector: "text/Login to account", + }, + newUrl: "https://www.gov.uk/manage-student-loan/dashboard", + }, + ], + }; + + const mockBrowser = createMockBrowser(mockScenario); + + const result = await getUkStudentLoanBalance( + "test@example.com", + "testpassword", + "testsecret", + async () => mockBrowser, + ); + + expect("balance" in result).toBe(true); + if ("balance" in result) { + expect(result.balance).toBe(testCase.expected); + } + } + }); + + it("should handle parseBalanceString edge cases", () => { + expect(parseBalanceString("£45,678.90")).toBe(45678.9); + expect(parseBalanceString("£1,234.56")).toBe(1234.56); + expect(parseBalanceString("£100")).toBe(100); + expect(parseBalanceString("invalid")).toBe(0); + expect(parseBalanceString("")).toBe(0); + }); +}); diff --git a/src/connectors/ukStudentLoan.ts b/src/connectors/ukStudentLoan.ts index d02ff6c..a087d73 100644 --- a/src/connectors/ukStudentLoan.ts +++ b/src/connectors/ukStudentLoan.ts @@ -1,4 +1,5 @@ -import { getBrowser, isBrowserAvailable } from "../browser.ts"; +import { getBrowser } from "../browser"; +import type { BrowserAdapter } from "../browser/browserAdapter.ts"; import type { AccountResult } from "./index.ts"; const parseBalanceString = (input: string): number => { @@ -11,17 +12,9 @@ const getUkStudentLoanBalance = async ( slcEmail: string, slcPassword: string, slcSecretAnswer: string, + browserFactory: () => Promise = getBrowser, ): Promise => { - const browserAvailable = await isBrowserAvailable(); - if (!browserAvailable) { - return { - error: - "Browser is not available. Please check your browser configuration.", - canRetry: false, - }; - } - - const browser = await getBrowser(); + const browser = await browserFactory(); const page = await browser.newPage(); diff --git a/src/index.ts b/src/index.ts index 1f5c769..a0ee719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,8 @@ if (command === "export-schema") { logger.info("Welcome to ynab-connect"); // load config only when needed -const config = (await import("./config.ts")).default; +const { getConfig } = await import("./config.ts"); +const config = await getConfig(); // check YNAB budget exists const budgetExists = await ensureBudgetExists(config.ynab.budgetId); @@ -58,7 +59,9 @@ if (command === "run") { } // start 2FA server -start2FAServer(config.server?.port || 4030); +if (config.server) { + start2FAServer(config.server.port); +} // schedule jobs for each account const jobs: Map = new Map(); @@ -83,10 +86,6 @@ for (const account of config.accounts) { }, `Scheduled job successfully`, ); - - if (Bun.env.NODE_ENV !== "production") { - await task.execute(); - } } // schedule summary job diff --git a/src/runtime.ts b/src/runtime.ts index d97b665..f017e7d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -18,14 +18,23 @@ const getBalanceWithRetry = async ( return new Promise((resolve) => { operation.attempt(async (currentAttempt) => { - const result = await connector.getBalance(account); + let result: AccountResult; + + try { + result = await connector.getBalance(account); + } catch (e) { + result = { + canRetry: true, + error: (e as Error).message, + }; + } if ("balance" in result) { return resolve(result); } if (!result.canRetry) { - logger.info( + logger.error( { account: account.name, type: account.type, @@ -78,7 +87,7 @@ const runSyncJob = async (account: Account) => { return; } - logger.info( + logger.debug( { type: account.type, balance: result.balance, @@ -111,6 +120,8 @@ const runSyncJob = async (account: Account) => { { type: account.type, balance: result.balance, + accountId: account.ynabAccountId, + accountName: account.name, }, `Adjusted balance in YNAB successfully`, ); diff --git a/src/ynab.ts b/src/ynab.ts index 73f92c4..b12ad30 100644 --- a/src/ynab.ts +++ b/src/ynab.ts @@ -1,15 +1,25 @@ import * as ynab from "ynab"; -import config from "./config.ts"; +import { getConfig } from "./config.ts"; import logger from "./logger.ts"; const YNAB_MEMO = "Automated balance adjustment created by ynab-connect"; const YNAB_PAYEE = "Balance Adjustment"; -const ynabAPI = new ynab.API(config.ynab.accessToken); +let ynabAPI: ynab.API | null = null; + +const getYnabAPI = async () => { + if (ynabAPI) { + return ynabAPI; + } + const config = await getConfig(); + ynabAPI = new ynab.API(config.ynab.accessToken); + return ynabAPI; +}; const ensureBudgetExists = async (budgetId: string) => { + const api = await getYnabAPI(); try { - await ynabAPI.budgets.getBudgetById(budgetId); + await api.budgets.getBudgetById(budgetId); } catch (_e) { return false; } @@ -18,9 +28,11 @@ const ensureBudgetExists = async (budgetId: string) => { }; const getAccountBalance = async (accountId: string) => { + const config = await getConfig(); + const api = await getYnabAPI(); const budgetId = config.ynab.budgetId; - const accountResponse = await ynabAPI.accounts.getAccountById( + const accountResponse = await api.accounts.getAccountById( budgetId, accountId, ); @@ -38,6 +50,8 @@ const adjustBalance = async ( date?: Date, log = logger, ) => { + const config = await getConfig(); + const api = await getYnabAPI(); const budgetId = config.ynab.budgetId; const balanceDate = date ?? new Date(); @@ -54,7 +68,7 @@ const adjustBalance = async ( } if (balanceDelta === 0) { - log.info( + log.debug( { accountId, amount, date: dateToYnabFormat(balanceDate) }, `No adjustment needed.`, ); @@ -62,19 +76,18 @@ const adjustBalance = async ( } // check if there's already a transaction with the same memo and date - const transactionsResponse = - await ynabAPI.transactions.getTransactionsByAccount( - budgetId, - accountId, - dateToYnabFormat(balanceDate), - ); + const transactionsResponse = await api.transactions.getTransactionsByAccount( + budgetId, + accountId, + dateToYnabFormat(balanceDate), + ); const existingTransaction = transactionsResponse.data.transactions.find( (t) => t.memo === YNAB_MEMO && t.date === dateToYnabFormat(balanceDate), ); if (existingTransaction) { - log.info( + log.debug( { accountId, transactionId: existingTransaction.id, @@ -84,20 +97,16 @@ const adjustBalance = async ( `An adjustment transaction already exists, updating that transaction instead of creating a new one.`, ); - await ynabAPI.transactions.updateTransaction( - budgetId, - existingTransaction.id, - { - transaction: { - amount: balanceDelta + existingTransaction.amount, - }, + await api.transactions.updateTransaction(budgetId, existingTransaction.id, { + transaction: { + amount: balanceDelta + existingTransaction.amount, }, - ); + }); return; } - await ynabAPI.transactions.createTransaction(budgetId, { + await api.transactions.createTransaction(budgetId, { transaction: { account_id: accountId, cleared: "reconciled",