diff --git a/CLAUDE.md b/CLAUDE.md index 5491b2d..d1b4823 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -nforce8 is a Node.js REST API wrapper for Salesforce, a modernized fork of the original `nforce` library. It is promise-based only (no callback support). Used with NodeRED and other Node.js applications. Requires Node.js >22.0. +nforce8 is a Node.js REST API wrapper for Salesforce, a modernized fork of the original `nforce` library. It is promise-based only (no callback support). Used with NodeRED and other Node.js applications. Requires Node.js >=22.4.0 (stable built-in `WebSocket`; experimental in 22.0–22.3). ## Commands @@ -24,7 +24,7 @@ There is no build step — this is a plain Node.js module with no transpilation. ### Supporting Modules -- **`lib/fdcstream.js`** — Faye-based Streaming API client (EventEmitter). `Subscription` and `Client` classes with replay and auto-reconnection support. +- **`lib/fdcstream.js`** — CometD-based Streaming API client (EventEmitter). `Subscription` and `Client` classes with replay and auto-reconnection support. - **`lib/optionhelper.js`** — Builds API request options (URIs, headers, multipart, gzip). - **`lib/multipart.js`** — Multipart form-data builder for file uploads (ContentVersion, Attachment). - **`lib/util.js`** — Type checking, response validation, OAuth validation, ID extraction. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..325945b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Code of Conduct + +The **nforce8** project adheres to the [Contributor Covenant](https://www.contributor-covenant.org/) Code of Conduct. Everyone who participates—whether reporting issues, submitting patches, reviewing code, or discussing the project—is expected to follow it. + +## Our pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces (including GitHub issues, pull requests, discussions, and related channels), and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **[stephan@wissel.net](mailto:stephan@wissel.net)**. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community impact:** Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence:** A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community impact:** A violation through a single incident or series of actions. + +**Consequence:** A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary ban + +**Community impact:** A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence:** A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent ban + +**Community impact:** Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +For answers to common questions about this code of conduct, see the [FAQ](https://www.contributor-covenant.org/faq). [Translations](https://www.contributor-covenant.org/translations) are available on the Contributor Covenant site. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fe84619 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ + +# Contributing to nforce8 + +Thank you for your interest in contributing to nforce8! All contributions are welcome, whether they are bug reports, feature suggestions, documentation improvements, or code changes. + +## Code of Conduct + +This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold it. Please report unacceptable behavior to [stephan@wissel.net](mailto:stephan@wissel.net). + +## Reporting Bugs + +Before opening a new issue, please check [existing issues](https://github.com/Stwissel/nforce8/issues) to avoid duplicates. + +When filing a bug report, include: + +- **Node.js version** (`node --version`) +- **nforce8 version** (`npm ls nforce8`) +- **Steps to reproduce** the issue +- **Expected behavior** vs **actual behavior** +- A **minimal reproduction case** if possible +- Any relevant error messages or stack traces + +## Suggesting Features + +Open a [GitHub Issue](https://github.com/Stwissel/nforce8/issues) describing: + +- The **use case** and why it matters +- How it would work from the caller's perspective +- Any alternatives you have considered + +For larger changes, please discuss the approach in an issue before submitting a pull request. + +## Development Setup + +### Prerequisites + +- **Node.js >= 22.0** (uses built-in `fetch` and `WebSocket`) + +### Getting Started + +```bash +# Fork the repository on GitHub, then: +git clone https://github.com//nforce8.git +cd nforce8 +npm install +``` + +### Running Tests + +Tests run against a local mock Salesforce API server -- no live org credentials are needed. + +```bash +# Full test suite with coverage +npm test + +# Single test file +npx mocha test/.js +``` + +### Linting + +```bash +npm run lint +``` + +## Submitting Pull Requests + +1. **Fork** the repository and create a feature branch from `main` +2. **Keep PRs focused** -- one concern per pull request +3. **Include tests** for new functionality and bug fixes +4. **Ensure all tests pass** (`npm test`) +5. **Ensure lint passes** (`npm run lint`) +6. **Update documentation** if your change affects the public API +7. **Reference related issues** in the PR description (e.g., "Fixes #42") + +### Commit Messages + +This project follows [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + +[optional body] +``` + +Common types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` + +Examples: + +``` +feat(streaming): add platform event batch subscribe +fix(auth): handle expired refresh token in single-user mode +docs: update streaming API guide +test(crud): add upsert external ID coverage +refactor(api): extract blob retrieval factory +``` + +## Coding Standards + +- **ESLint** enforces the project style -- run `npm run lint` before committing +- **Single quotes** for strings +- **CommonJS** modules (`require` / `module.exports`) +- **Promise-based** patterns only -- no callbacks +- **No build step** -- plain Node.js, no transpilation +- **API version format** must be fully-qualified strings (e.g., `'v62.0'`) + +## Testing Guidelines + +- All new features and bug fixes need tests +- Tests use **Mocha** + **should.js** assertions +- Tests run against mock servers in `test/mock/` -- do not require a live Salesforce org +- Aim for meaningful coverage of both success and error paths + +## License + +By contributing to nforce8, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index 30c653d..ea8acea 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,36 @@ # nforce8 :: node.js salesforce REST API wrapper -This libary is based on a fork of Kevin O'Hara's brilliant -[nforce](https://github.com/kevinohara80/nforce) library. You might want to refer to the original! - -## Code and build +A promise-based Node.js REST API wrapper for Salesforce, a modernized fork of Kevin O'Hara's [nforce](https://github.com/kevinohara80/nforce) library. [![Codacy Badge](https://api.codacy.com/project/badge/Grade/719bc9f8685247fc8fdac704e596ee67)](https://www.codacy.com/app/Stwissel/nforce8?utm_source=github.com&utm_medium=referral&utm_content=Stwissel/nforce8&utm_campaign=Badge_Grade) [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/719bc9f8685247fc8fdac704e596ee67)](https://www.codacy.com/app/Stwissel/nforce8?utm_source=github.com&utm_medium=referral&utm_content=Stwissel/nforce8&utm_campaign=Badge_Coverage) [![npm version](https://badge.fury.io/js/nforce8.svg)](https://badge.fury.io/js/nforce8) [![Known Vulnerabilities](https://snyk.io/test/github/Stwissel/nforce8/badge.svg?targetFile=package.json)](https://snyk.io/test/github/Stwissel/nforce8?targetFile=package.json) -[![Snyk package health](https://snyk.io/advisor/npm-package/nforce8/badge.svg)](https://snyk.io/advisor/npm-package/nforce8) +[![Snyk security (npm package)](https://snyk.io/test/npm/nforce8/badge.svg)](https://security.snyk.io/package/npm/nforce8) [![Coverage Status](https://coveralls.io/repos/github/Stwissel/nforce8/badge.svg?branch=master)](https://coveralls.io/github/Stwissel/nforce8?branch=master) +[![CI](https://github.com/Stwissel/nforce8/actions/workflows/codecheck.yml/badge.svg)](https://github.com/Stwissel/nforce8/actions/workflows/codecheck.yml) -## Rationale - -I'm maintaining the [NodeRED](https://nodered.org/) modules for Salesforce: [node-red-contrib-salesforce](https://www.npmjs.com/package/node-red-contrib-salesforce). The nodes needed a more recent library version and a few patches to get it to work, so I was too much tempted and forked the library. - -## Original Documentation - -Read it [here](https://www.npmjs.com/package/nforce) - -## Updated documentation +## Requirements -Evolving documentation on [github.io](https://stwissel.github.io/nforce8) - -## Important differences - -- Version numbers, if provided, **must** be full qualified strings like `v42.0`, short numbers or string are no longer accepted. These will fail ~42 42.0 '42'~ -- nforce8 only works with promises, no callback support -- Subscriptions to events need the full path, option of type gets ignored - -## Change Log - -Overview documentation on [changes between versions](https://stwissel.github.io/nforce8/Changelog.html) +- **Node.js >= 22.4.0** — uses built-in `fetch` and a stable built-in `WebSocket` (Node’s global `WebSocket` was experimental in 22.0–22.3) ## Features -- Promised based API -- Intelligent sObjects (inherited from [nforce](https://www.npmjs.com/package/nforce)) -- Streaming support using [Faye](https://www.npmjs.com/package/faye) for any Salesforce topic including Change Data Capture -- Authentication helper methods (OAuth) -- Multi-user design with single user mode (inherited from [nforce](https://www.npmjs.com/package/nforce)) +- Promise-based API (no callback support) +- Intelligent sObjects with field change tracking +- CRUD operations (insert, update, upsert, delete, getRecord) +- SOQL queries with automatic pagination (`fetchAll`) +- SOSL search +- Streaming API support (PushTopics, Platform Events, Change Data Capture) + - Built-in CometD/Bayeux client with long-polling and WebSocket transports + - Replay ID support for event replay + - No external streaming dependencies +- Binary content retrieval (Attachments, Documents, ContentVersions) +- Apex REST endpoint support +- OAuth authentication (authorization code, username/password, SAML assertion) +- Automatic token refresh on session expiration +- Single-user and multi-user modes +- Plugin system for extending the Connection prototype ## Installation @@ -48,104 +38,146 @@ Overview documentation on [changes between versions](https://stwissel.github.io/ npm install nforce8 ``` -## Usage +## Quick Start -### Create a client connection +### Create a Connection ```js const nforce = require('nforce8'); -const org = nforce8.createConnection({ - clientId: 'CLIENT_ID_OAUTH', - clientSecret: 'CLIENT_SECRET_OAUTH', - redirectUri: 'https://yourapp.herokuapp.com/oauth/_callback', // could be http://localhost:3000/ instead - apiVersion: 'v45.0', // optional, defaults to current salesforce API version - environment: 'production', // optional, salesforce 'sandbox' or 'production', production default - mode: 'multi' // optional, 'single' or 'multi' user mode, multi default +const org = nforce.createConnection({ + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + redirectUri: 'http://localhost:3000/oauth/_callback', + apiVersion: 'v62.0', // optional, defaults to current API version + environment: 'production', // optional, 'production' or 'sandbox' + mode: 'multi' // optional, 'single' or 'multi' user mode }); ``` -### Authenticate using OAuth +### Authenticate ```js -// Multi-User -let oauth; -const creds = {username: 'john.doe@noreply.com', password: 'secretjohn'}; -org.authenticate(creds) - .then(result => oauth = result;) - .catch(/* handle failure here */); - -// Single user mode -const creds = {username: 'john.doe@noreply.com', password: 'secretjohn'}; -org.authenticate(creds) - .then(result => console.log(org.oauth.access_token);) - .catch(/* handle failure here */); - +// Multi-user mode +const oauth = await org.authenticate({ + username: 'user@example.com', + password: 'password' +}); -### Use the object factory to create records +// Single-user mode — OAuth is cached on the connection +await org.authenticate({ + username: 'user@example.com', + password: 'password' +}); +``` -Sample based on original nforce. In single user mode the oauth argument can be omitted since it is cached in the connection object +### CRUD Operations ```js - +// Insert const acc = nforce.createSObject('Account'); acc.set('Name', 'ACME Corporation'); acc.set('Phone', '800-555-2345'); -acc.set('SLA__c', 'Platinum'); -const payload = { sobject: acc, oauth: oauth }; +const result = await org.insert({ sobject: acc, oauth }); +console.log('Created:', result.id); -org.insert(payload) -.then(result => console.log(JSON.stringify(result))) -.catch(err => console.log(err)); +// Query +const resp = await org.query({ + query: 'SELECT Id, Name FROM Account WHERE Name = \'ACME Corporation\' LIMIT 1', + oauth +}); + +// Update (only changed fields are sent) +const record = resp.records[0]; +record.set('Name', 'ACME Coyote'); +record.set('Industry', 'Explosives'); +await org.update({ sobject: record, oauth }); +// Delete +await org.delete({ sobject: record, oauth }); ``` -### Query and update +### Streaming API -Querying and updating records is super easy. **nforce** wraps API-queried records in a special object. The object caches field updates that you make to the record and allows you to pass the record directly into the update method without having to scrub out the unchanged fields. In the example below, only the Name and Industry fields will be sent in the update call despite the fact that the query returned other fields such as BillingCity and CreatedDate. +Subscribe to PushTopics, Platform Events, or Change Data Capture events: ```js +const oauth = await org.authenticate(creds); +const client = org.createStreamClient(); +const sub = client.subscribe({ topic: '/data/ChangeEvents' }); + +sub.on('data', (event) => console.log(event)); +sub.on('connect', () => console.log('Subscribed')); +sub.on('error', (err) => { + console.error(err); + client.disconnect(); +}); +``` -const q = { query :'SELECT Id, Name, CreatedDate, BillingCity FROM Account WHERE Name = "ACME Corporation" LIMIT 1'}; - -org.query(q) - .then(resp => { - if (resp.records) { - const acc = resp.records[0]; - acc.set('Name','ACME Coyote'); - acc.set('Industry','Explosives'); - const payload = {sobject: acc, oauth: oauth}; - org.update(payload) - .then(result => console.log('It worked')); - } - }) - .catch(err => console.log(err)); +**Replay support** — resume from a specific event replay ID: +```js +const sub = client.subscribe({ + topic: '/event/MyPlatformEvent__e', + replayId: -2 // -1 = new only, -2 = all available +}); ``` -### Streaming API Support +### Apex REST + +```js +const result = await org.apexRest({ + uri: 'MyCustomEndpoint', + method: 'POST', + body: { key: 'value' }, + oauth +}); +``` -You need to specify the full topic starting with a slash +### Binary Content ```js +// Retrieve attachment, document, or content version binary data +const buffer = await org.getBinaryContent({ + sobject: attachmentRecord, + oauth +}); +``` + +## API Version Format + +API versions **must** be fully-qualified strings like `'v62.0'`. Bare numbers (`42`, `42.0`) and short strings (`'v42'`) are rejected. -const creds = {username: 'john.doe@noreply.com', password: 'secretjohn'}; - -org.authenticate(creds) - .then(oauth => { - const client = org.createStreamClient(); - const topic = {topic: '/data/ChangeEvents'}; - const cdc = client.subscribe(topic); - cdc.on('error' err => { - console.log('subscription error'); - console.log(err); - client.disconnect(); - }); - cdc.on('data', data => console.log(data)); - }) - .catch(err => console.log(err)); +## Single vs Multi User Mode +- **Multi-User Mode** (default): pass `oauth` with each API call +- **Single-User Mode**: OAuth is cached on the connection after `authenticate()`, no need to pass it + +## Important Differences from nforce + +- Promise-only API, no callback support +- API version must be fully-qualified (`'v45.0'`, not `42` or `'42'`) +- Streaming subscriptions require the full topic path (e.g. `/topic/MyTopic`) +- Requires Node.js >= 22.4.0 (stable built-in `WebSocket`; experimental in 22.0–22.3) +- Built-in CometD client replaces the faye dependency + +## Documentation + +- [Changelog](https://stwissel.github.io/nforce8/Changelog.html) +- [Streaming API guide](docs/streamingApi.md) +- [Original nforce documentation](https://www.npmjs.com/package/nforce) (for inherited API details) + +## Development + +```bash +# Run tests +npm test + +# Lint +npm run lint ``` -Read the [nforce documentation](https://www.npmjs.com/package/nforce) for more details \ No newline at end of file +## License + +See [LICENSE](LICENSE) file. diff --git a/code-refactoring-report.md b/code-refactoring-report.md deleted file mode 100644 index 9b9380a..0000000 --- a/code-refactoring-report.md +++ /dev/null @@ -1,1073 +0,0 @@ -# Code Refactoring Report - -## Project: nforce8 — Node.js REST API Wrapper for Salesforce - -**Report Date**: 2026-03-28 -**Analyst**: Refactoring Expert (Claude Sonnet 4.6) -**Source Report**: code-smell-detector-report.md -**Total Recommendations**: 22 - ---- - -## Executive Summary - -The nforce8 codebase is in good structural shape following a prior refactoring campaign that decomposed a monolithic `index.js` into domain modules. The 30 remaining code smells cluster into three actionable themes: - -1. **Public surface pollution**: Private implementation helpers are mixed onto the Connection prototype alongside the public API, creating an invisible coupling surface for any external consumer. -2. **Pervasive opts-bag mutation**: A single mutable plain object accumulates properties across every architectural layer, making individual functions impossible to test or reason about in isolation. -3. **Scattered duplication**: Multiple blocks of 4–6 lines of identical code are repeated across related functions; each is a low-risk, high-clarity refactoring opportunity. - -The recommended roadmap is organized into three phases: Phase 1 (quick wins, zero architectural risk), Phase 2 (design improvements, moderate risk), and Phase 3 (architectural uplift, coordinated effort required). Each recommendation below maps directly to one or more of the 66 canonical refactoring techniques. - ---- - -## Recommendation Index - -| # | Recommendation | Technique | Phase | Impact | Complexity | Risk | -|---|---|---|---|---|---|---| -| R01 | Remove dead `opts._refreshResult` write | **Remove Dead Code** | 1 | M | L | L | -| R02 | Remove commented-out credential block in integration test | **Remove Dead Code** | 1 | L | L | L | -| R03 | Fix fallacious test description (`#getUrl`) | **Rename Method** (test desc) | 1 | L | L | L | -| R04 | Eliminate duplicate `package.json` read in `index.js` | **Inline Temp** | 1 | L | L | L | -| R05 | Add `err.type = 'empty-response'` to `emptyResponse()` | **Introduce Assertion** / symmetry fix | 1 | M | L | L | -| R06 | Extract `buildSignal()` helper to remove duplicated timeout/AbortSignal setup | **Extract Method** | 1 | M | L | L | -| R07 | Extract `applyBody()` to unify duplicated multipart/JSON body logic | **Extract Method** | 2 | M | L | L | -| R08 | Extract `_resolveEndpoint()` to unify three environment-conditional endpoint functions | **Extract Method** | 2 | M | L | L | -| R09 | Inline `_resolveOAuth` — replace with `Promise.resolve()` directly | **Inline Method** | 2 | M | L | L | -| R10 | Add fail-fast guard for single-mode missing OAuth | **Introduce Assertion** / **Replace Exception with Test** | 2 | H | L | L | -| R11 | Extract `makeOrg()` test helper to eliminate repeated connection boilerplate | **Extract Method** | 2 | M | L | L | -| R12 | Replace magic strings `'sandbox'` / `'single'` with named constants | **Replace Magic Number with Symbolic Constant** | 2 | M | L | L | -| R13 | Add `err.type` to `emptyResponse` and align error factory API | **Introduce Parameter Object** (align API) | 2 | L | L | L | -| R14 | Rename `d` parameter to `input` in `_getOpts` | **Rename Method** (parameter) | 2 | L | L | L | -| R15 | Rename `getBody` API method to `getBinaryContent` | **Rename Method** | 2 | M | M | M | -| R16 | Fix spacing inconsistencies via `eslint --fix` | Style tooling | 1 | L | L | L | -| R17 | Move multipart form-building into `Record` (`toMultipartForm`) | **Move Method** / **Hide Delegate** | 3 | H | M | M | -| R18 | Separate private helpers from `module.exports` in `auth.js`, `api.js`, `http.js` | **Hide Method** / **Extract Interface** | 3 | H | H | H | -| R19 | Sub-divide `lib/api.js` into domain modules | **Extract Class** (module) | 3 | H | H | H | -| R20 | Introduce typed request value objects to replace the opts bag | **Introduce Parameter Object** / **Replace Data Value with Object** | 3 | H | H | H | -| R21 | Separate retry state from the opts bag in `_apiRequest` | **Split Temporary Variable** / **Remove Assignments to Parameters** | 3 | M | M | M | -| R22 | Standardize on ES6 class syntax for `Connection` and `Record` | **Extract Superclass** pattern | 3 | M | H | M | - ---- - -## Phase 1 — Quick Wins - -### R01: Remove Dead `opts._refreshResult` Write - -**Smell**: Dead Code (Issue #9 in detector report) -**Technique**: Remove Dead Code -**File**: `lib/http.js`, line 177 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: `opts._refreshResult = res` is assigned in the auto-refresh retry path but is never read anywhere in the codebase (confirmed by global search). The assignment is a dead write. - -**Before**: -```js -return this.autoRefreshToken(opts).then((res) => { - opts._refreshResult = res; // dead write — never consumed - opts._retryCount = 1; - return this._apiRequest(opts); -}); -``` - -**After**: -```js -return this.autoRefreshToken(opts).then(() => { - opts._retryCount = 1; - return this._apiRequest(opts); -}); -``` - -**Steps**: -1. Delete line 177 (`opts._refreshResult = res;`). -2. Change the arrow function parameter from `res` to `_` or remove it (change to `() =>`). -3. Run `npm test` to verify no regression. - -**Note**: `opts._retryCount` is addressed as a separate concern in R21. - ---- - -### R02: Remove Commented-Out Credential Block in Integration Test - -**Smell**: Dead Code (Issue #20) -**Technique**: Remove Dead Code -**File**: `test/integration.js`, lines 56–67 and the `TODO` on line 18 -**Risk**: Low | **Complexity**: Low | **Impact**: Low - -**Problem**: A commented-out object literal with placeholder credentials was left in the integration test. The `TODO: fix the creds` comment on line 18 is the corresponding hanging marker. - -**Steps**: -1. Delete `test/integration.js` lines 56–67 (the `/* let x = { ... } */` block). -2. Delete the `// TODO: fix the creds` comment on line 18. -3. Run `npm test` to confirm no change in test output. - ---- - -### R03: Fix Fallacious Test Description - -**Smell**: Fallacious Comment (Issue #19) -**Technique**: Rename Method (applied to test description string) -**File**: `test/record.js`, line 202 -**Risk**: Low | **Complexity**: Low | **Impact**: Low - -**Before**: -```js -describe('#getUrl', function () { - it('should let me get the id', function () { // wrong — tests getUrl, not getId - acc.getUrl().should.equal('http://www.salesforce.com'); - }); -}); -``` - -**After**: -```js -describe('#getUrl', function () { - it('should let me get the url', function () { - acc.getUrl().should.equal('http://www.salesforce.com'); - }); -}); -``` - ---- - -### R04: Eliminate Duplicate `package.json` Read in `index.js` - -**Smell**: Duplicate Code (Issue #7) -**Technique**: Inline Temp -**Files**: `index.js` line 68; `lib/constants.js` line 15 -**Risk**: Low | **Complexity**: Low | **Impact**: Low - -**Problem**: Both `lib/constants.js` and `index.js` independently `require('../package.json').sfdx.api`. `index.js` already imports `CONST` from `lib/constants`, which already exposes this value as `CONST.API`. - -**Before** (`index.js` lines 67–68): -```js -const version = require('./package.json').version; -const API_VERSION = require('./package.json').sfdx.api; -``` - -**After**: -```js -const version = require('./package.json').version; -const API_VERSION = CONST.API; -``` - -**Steps**: -1. In `index.js` line 68, replace `require('./package.json').sfdx.api` with `CONST.API`. -2. Verify `CONST` is already imported at the top of `index.js` (it is, line 5). -3. Run `npm test`. - ---- - -### R05: Add `err.type` to `emptyResponse()` for API Symmetry - -**Smell**: Incomplete Error Factory (Issue #29) -**Technique**: Introduce Assertion (align error factory API) -**File**: `lib/errors.js`, lines 9–11 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: `invalidJson()` sets `err.type = 'invalid-json'`, enabling programmatic error discrimination. `emptyResponse()` lacks a corresponding `type`, making the API asymmetric. A caller cannot catch empty-response errors by type. - -**Before**: -```js -const emptyResponse = () => { - return new Error('Unexpected empty response'); -}; -``` - -**After**: -```js -const emptyResponse = () => { - const err = new Error('Unexpected empty response'); - err.type = 'empty-response'; - return err; -}; -``` - -**Steps**: -1. Edit `lib/errors.js` as shown above. -2. Update `test/errors.js` to assert `err.type === 'empty-response'` on empty-response error (currently this assertion is absent; add it for consistency with the `invalidJson` test). -3. Run `npm test`. - ---- - -### R06: Extract `buildSignal()` to Eliminate Duplicated Timeout/AbortSignal Setup - -**Smell**: Duplicate Code (Issue #4) -**Technique**: Extract Method -**File**: `lib/http.js`, lines 100–106 and 139–145 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: An identical 6-line block for merging an `AbortSignal.timeout` with an optional existing signal appears in both `_apiAuthRequest` and `_apiRequest`. - -**Duplicated block** (appears twice): -```js -if (this.timeout) { - const timeoutSignal = AbortSignal.timeout(this.timeout); - opts.signal = - opts.signal !== undefined - ? AbortSignal.any([timeoutSignal, opts.signal]) - : timeoutSignal; -} -``` - -**Extracted helper**: -```js -/** - * Build an AbortSignal that fires after `timeout` ms, optionally - * combining it with a caller-supplied signal. - * @param {AbortSignal|undefined} existingSignal - * @param {number|undefined} timeout milliseconds; falsy = no timeout - * @returns {AbortSignal|undefined} - */ -function buildSignal(existingSignal, timeout) { - if (!timeout) return existingSignal; - const timeoutSignal = AbortSignal.timeout(timeout); - return existingSignal !== undefined - ? AbortSignal.any([timeoutSignal, existingSignal]) - : timeoutSignal; -} -``` - -**After** (both call sites): -```js -// In _apiAuthRequest: -opts.signal = buildSignal(opts.signal, this.timeout); - -// In _apiRequest: -ropts.signal = buildSignal(ropts.signal, this.timeout); -``` - -**Steps**: -1. Add `buildSignal` as a module-level function at the top of `lib/http.js`. -2. Replace both 6-line timeout blocks with one-line calls to `buildSignal`. -3. Run `npm test` to verify identical behaviour. - ---- - -### R16: Fix Spacing Inconsistencies via ESLint - -**Smell**: Inconsistent Style (Issue #11) -**Technique**: Substitute Algorithm (tooling-assisted) -**File**: `lib/api.js`, lines 150, 163–164, 177–179, 188–189, 454 -**Risk**: Low | **Complexity**: Low | **Impact**: Low - -**Problem**: Several assignments omit the required space before the `=` operator: `const type =opts.sobject.getType()`. - -**Steps**: -1. Run `npx eslint --fix lib/api.js`. -2. Verify the corrected assignments in the affected lines. -3. Run `npm test`. - ---- - -## Phase 2 — Design Improvements - -### R07: Extract `applyBody()` to Unify Duplicated Multipart/JSON Body Logic - -**Smell**: Duplicate Code (Issue #5) -**Technique**: Extract Method, Parameterize Method -**File**: `lib/api.js`, lines 153–157 and 167–171 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: The `insert` and `update` functions each contain an identical 5-line conditional that selects multipart vs JSON serialization. The only difference is the payload-extraction function: `toPayload()` for insert, `toChangedPayload()` for update. - -**Duplicated pattern**: -```js -if (CONST.MULTIPART_TYPES.includes(type)) { - opts.multipart = multipart(opts); -} else { - opts.body = JSON.stringify(opts.sobject.toPayload()); // differs -} -``` - -**Extracted helper**: -```js -/** - * Attach either a multipart form or a JSON body to opts, based on the SObject type. - * @param {object} opts - Request options bag (mutated in-place). - * @param {string} type - Lowercased SObject type string. - * @param {Function} payloadFn - Zero-argument function that returns the payload object. - */ -function applyBody(opts, type, payloadFn) { - if (CONST.MULTIPART_TYPES.includes(type)) { - opts.multipart = multipart(opts); - } else { - opts.body = JSON.stringify(payloadFn()); - } -} -``` - -**After**: -```js -const insert = function (data) { - const opts = this._getOpts(data); - if (!opts.sobject) throw new Error('insert requires opts.sobject'); - const type = opts.sobject.getType(); - opts.resource = sobjectPath(type); - opts.method = 'POST'; - applyBody(opts, type, () => opts.sobject.toPayload()); - return this._apiRequest(opts); -}; - -const update = function (data) { - const opts = this._getOpts(data); - const type = opts.sobject.getType(); - const id = opts.sobject.getId(); - opts.resource = sobjectPath(type, id); - opts.method = 'PATCH'; - applyBody(opts, type, () => opts.sobject.toChangedPayload()); - return this._apiRequest(opts); -}; -``` - -**Steps**: -1. Add `applyBody` as a module-level function inside `lib/api.js`. -2. Replace both 5-line conditional blocks in `insert` and `update` with `applyBody(...)` calls. -3. Run `npm test`. - ---- - -### R08: Extract `_resolveEndpoint()` to Unify Environment-Conditional Endpoint Selection - -**Smell**: Duplicate Code (Issue #6) -**Technique**: Extract Method, Consolidate Conditional Expression -**File**: `lib/auth.js`, lines 37–48 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: Three functions apply the identical sandbox-vs-production conditional to different URL properties: - -```js -const _authEndpoint = function (opts = {}) { - if (opts.authEndpoint) return opts.authEndpoint; - return this.environment === 'sandbox' ? this.testAuthEndpoint : this.authEndpoint; -}; -const _loginEndpoint = function () { - return this.environment === 'sandbox' ? this.testLoginUri : this.loginUri; -}; -const _revokeEndpoint = function () { - return this.environment === 'sandbox' ? this.testRevokeUri : this.revokeUri; -}; -``` - -**Extracted helper** (module-private, not exported): -```js -// Pure function — no 'this' dependency, safe to test standalone. -function resolveEndpoint(environment, prod, test) { - return environment === 'sandbox' ? test : prod; -} -``` - -**After**: -```js -const _authEndpoint = function (opts = {}) { - if (opts.authEndpoint) return opts.authEndpoint; - return resolveEndpoint(this.environment, this.authEndpoint, this.testAuthEndpoint); -}; - -const _loginEndpoint = function () { - return resolveEndpoint(this.environment, this.loginUri, this.testLoginUri); -}; - -const _revokeEndpoint = function () { - return resolveEndpoint(this.environment, this.revokeUri, this.testRevokeUri); -}; -``` - -**Steps**: -1. Add `resolveEndpoint` as a module-level function (not exported) at the top of `lib/auth.js`. -2. Update the three endpoint functions as shown. -3. Run `npm test`. The public observable behaviour of `getAuthUri`, `authenticate`, `refreshToken`, and `revokeToken` must be unchanged. - ---- - -### R09: Inline `_resolveOAuth` — Replace with `Promise.resolve()` Directly - -**Smell**: Lazy Element (Issue #13) -**Technique**: Inline Method -**File**: `lib/auth.js`, lines 120–122; `test/connection.js`, lines 426–443 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: `_resolveOAuth` is a trivially thin wrapper around `Promise.resolve` that adds no behaviour, documentation, or abstraction value. It is exported publicly, creating an unnecessary coupling point. - -```js -const _resolveOAuth = function (newOauth) { - return Promise.resolve(newOauth); // only ever does this -}; -``` - -**Steps**: -1. In `lib/auth.js` `authenticate()`, replace `return this._resolveOAuth(newOauth)` with `return Promise.resolve(newOauth)`. -2. Remove `_resolveOAuth` from the function definition and from `module.exports`. -3. In `test/connection.js`, find the test block exercising `org._resolveOAuth` (lines 426–443). Replace the assertion with an equivalent test using `authenticate()` or `Promise.resolve(...)` directly — the test should verify that after authentication the OAuth object resolves correctly, not that a private method exists. -4. Run `npm test`. - -**Risk Mitigation**: Any external code that calls `org._resolveOAuth` will break; however, because it has always been a private helper (leading underscore convention), breaking this is semantically correct and the change is warranted. - ---- - -### R10: Add Fail-Fast Guard for Single-Mode Missing OAuth - -**Smell**: Missing Fail-Fast Guard (Issue #26) -**Technique**: Introduce Assertion, Replace Exception with Test -**File**: `lib/api.js`, lines 18–21 (`_getOpts`) -**Risk**: Low | **Complexity**: Low | **Impact**: High - -**Problem**: When `mode === 'single'` and `authenticate()` has never been called, `this.oauth` is `undefined`. `_getOpts` silently sets `data.oauth = undefined`, which propagates through the stack until `optionhelper.js` crashes with a cryptic `TypeError: Cannot read properties of undefined (reading 'instance_url')`. - -**Before**: -```js -if (this.mode === 'single' && !data.oauth) { - data.oauth = this.oauth; // silently injects undefined if not authenticated -} -``` - -**After**: -```js -if (this.mode === 'single' && !data.oauth) { - if (!this.oauth) { - throw new Error( - 'Connection is in single-user mode but no OAuth token has been set. ' + - 'Call authenticate() first.' - ); - } - data.oauth = this.oauth; -} -``` - -**Steps**: -1. Apply the change above in `lib/api.js`. -2. Add a test in `test/connection.js` that creates a single-mode connection without calling `authenticate()`, then calls any API method, and asserts that the descriptive error is thrown. -3. Run `npm test`. - ---- - -### R11: Extract `makeOrg()` Test Helper in `test/connection.js` - -**Smell**: Required Setup Code Duplication (Issue #16) -**Technique**: Extract Method -**File**: `test/connection.js`, lines 8–168 -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: The same `nforce.createConnection({...})` call with `FAKE_CLIENT_ID` and `FAKE_REDIRECT_URI` is inlined approximately 17 times. Any option defaults change requires editing 17 locations. - -**Extracted helper**: -```js -function makeOrg(overrides = {}) { - return nforce.createConnection(Object.assign({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_ID, - redirectUri: FAKE_REDIRECT_URI, - environment: 'production' - }, overrides)); -} -``` - -**Before** (repeated): -```js -let org = nforce.createConnection({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_ID, - redirectUri: FAKE_REDIRECT_URI, - environment: 'production' -}); -``` - -**After**: -```js -let org = makeOrg(); -// Or, for variant: -let org = makeOrg({ environment: 'sandbox' }); -``` - -**Steps**: -1. Add `makeOrg` function after the constant declarations at the top of `test/connection.js`. -2. Replace each verbatim `nforce.createConnection({clientId: FAKE_CLIENT_ID, ...})` call with `makeOrg()` or `makeOrg({ })`. -3. Run `npm test` to confirm all tests still pass. - ---- - -### R12: Replace Magic Strings `'sandbox'` and `'single'` with Named Constants - -**Smell**: Magic Strings (Issue #27) -**Technique**: Replace Magic Number with Symbolic Constant -**Files**: `lib/auth.js`, `lib/http.js`, `lib/constants.js` -**Risk**: Low | **Complexity**: Low | **Impact**: Medium - -**Problem**: The string literals `'sandbox'`, `'single'`, and `'multi'` are used as direct string comparisons in multiple files even though `constants.js` already defines `CONST.ENVS = ['sandbox', 'production']` and `CONST.MODES = ['multi', 'single']`. The constants exist but their individual values are never imported for comparisons. - -**Recommended additions to `lib/constants.js`**: -```js -const SANDBOX = 'sandbox'; -const SINGLE_MODE = 'single'; - -const constants = { - ... - SANDBOX, - SINGLE_MODE, -}; -``` - -**Usage in `lib/auth.js`** (after importing CONST): -```js -// Before: -return this.environment === 'sandbox' ? ... - -// After: -return this.environment === CONST.SANDBOX ? ... -``` - -**Usage in `lib/http.js`**: -```js -// Before: -if (jBody.access_token && this.mode === 'single') { - -// After: -if (jBody.access_token && this.mode === CONST.SINGLE_MODE) { -``` - -**Steps**: -1. Add `SANDBOX` and `SINGLE_MODE` named constants to `lib/constants.js`. -2. Export them from the `constants` object. -3. Import `CONST` in `lib/auth.js` (it is not currently imported there — add the require). -4. Replace all `'sandbox'` comparisons in `auth.js` with `CONST.SANDBOX`. -5. Replace `'single'` in `http.js` and `auth.js` with `CONST.SINGLE_MODE`. -6. Run `npm test`. - ---- - -### R13: Rename `_getOpts` Parameter `d` to `input` - -**Smell**: Uncommunicative Name (Issue #24) -**Technique**: Rename Method (applied to parameter) -**File**: `lib/api.js`, line 10 -**Risk**: Low | **Complexity**: Low | **Impact**: Low - -**Before**: -```js -const _getOpts = function (d, opts = {}) { - let data = {}; - if (opts.singleProp && d && !util.isObject(d)) { - data[opts.singleProp] = d; - } else if (util.isObject(d)) { - data = d; - } - ... -``` - -**After**: -```js -const _getOpts = function (input, opts = {}) { - let data = {}; - if (opts.singleProp && input && !util.isObject(input)) { - data[opts.singleProp] = input; - } else if (util.isObject(input)) { - data = input; - } - ... -``` - -**Steps**: -1. Rename `d` to `input` throughout `_getOpts` in `lib/api.js` (4 occurrences within the function body). -2. Run `npm test`. - ---- - -### R14: Rename API Method `getBody` to `getBinaryContent` - -**Smell**: Ambiguous Method Name (Issue #28) -**Technique**: Rename Method -**File**: `lib/api.js`, lines 226–234 and `module.exports` block -**Risk**: Medium | **Complexity**: Medium | **Impact**: Medium - -**Problem**: The API dispatcher `getBody` (routing to attachment/document/contentversion data) shares its name with `Record.prototype.getBody` (retrieving binary body from a record). Both exist in the same domain, creating conceptual ambiguity. - -**Steps**: -1. Rename the function definition `const getBody = function(...)` to `const getBinaryContent = function(...)` in `lib/api.js`. -2. Update the export: `module.exports = { ..., getBinaryContent, ... }` (remove `getBody`). -3. Search all existing callers: `test/crud.js`, `examples/`, any external documentation. -4. Update caller references from `org.getBody(...)` to `org.getBinaryContent(...)`. -5. Update `BODY_GETTER_MAP` references that dispatch through `this[getter]` — the `getter` values (`getDocumentBody`, `getAttachmentBody`, `getContentVersionData`) are unaffected. -6. Run `npm test`. - -**Breaking Change Note**: This is a public API rename. For a published npm package it warrants either a major version bump or a deprecation shim: -```js -// Deprecation shim (optional bridge): -Connection.prototype.getBody = function (data) { - process.emitWarning('getBody() is deprecated. Use getBinaryContent() instead.', - { code: 'NFORCE8_DEPRECATED_GETBODY' }); - return this.getBinaryContent(data); -}; -``` - ---- - -## Phase 3 — Architectural Improvements - -### R17: Move Multipart Form-Building into `Record` (`toMultipartForm`) - -**Smell**: Feature Envy / Inappropriate Intimacy (Issue #12) -**Technique**: Move Method, Hide Delegate -**Files**: `lib/multipart.js`, `lib/record.js`, `lib/api.js` -**Risk**: Medium | **Complexity**: Medium | **Impact**: High - -**Problem**: `multipart.js` reaches deeply into `Record` internals to build the multipart form: - -```js -const type = opts.sobject.getType(); // reaches into Record -const fileName = opts.sobject.getFileName(); // reaches into Record -isPatch ? opts.sobject.toChangedPayload() : opts.sobject.toPayload() // reaches in -opts.sobject.getBody() // reaches in -``` - -The `Record` class is the Information Expert for its own representation; `multipart.js` violates this by owning the "how to serialize a Record as multipart" logic. - -**Target design**: - -```js -// lib/record.js — new method -Record.prototype.toMultipartForm = function (isPatch) { - const type = this.getType(); - const entity = type === 'contentversion' ? 'content' : type; - const fieldName = type === 'contentversion' ? 'VersionData' : 'Body'; - const safeFileName = this.getFileName() || 'file.bin'; - - const form = new FormData(); - form.append( - 'entity_' + entity, - new Blob( - [JSON.stringify(isPatch ? this.toChangedPayload() : this.toPayload())], - { type: 'application/json' } - ), - 'entity', - ); - - const body = this.getBody(); - if (hasNonEmptyAttachmentBody(body)) { - form.append( - fieldName, - new Blob([body], { type: mimeTypes.lookup(safeFileName) || 'application/octet-stream' }), - safeFileName, - ); - } - return form; -}; -``` - -**After** (`lib/api.js`): -```js -// insert: -if (CONST.MULTIPART_TYPES.includes(type)) { - opts.multipart = opts.sobject.toMultipartForm(false); -} - -// update: -if (CONST.MULTIPART_TYPES.includes(type)) { - opts.multipart = opts.sobject.toMultipartForm(true); -} -``` - -**After** (`lib/multipart.js`): `multipart.js` can be simplified to a thin delegation shim or removed entirely once `toMultipartForm` is proven and all callers updated. - -**Steps**: -1. Move `hasNonEmptyAttachmentBody` and the FormData construction logic into `Record.prototype.toMultipartForm`. -2. Add `require('mime-types')` and `require('mime-types')` usage to `record.js`, or keep `multipart.js` as a pure helper that is called only by `toMultipartForm`. -3. Update `lib/api.js` to call `opts.sobject.toMultipartForm(false/true)` instead of `multipart(opts)`. -4. Update the `applyBody` helper introduced in R07 to delegate to `toMultipartForm`. -5. Run `npm test`. - -**Dependency**: Implement after R07 (applyBody extraction). - ---- - -### R18: Separate Private Helpers from `module.exports` in `auth.js`, `api.js`, `http.js` - -**Smell**: Indecent Exposure (Issue #1) -**Technique**: Hide Method, Extract Interface -**Files**: `lib/auth.js`, `lib/api.js`, `lib/http.js`, `index.js` -**Risk**: High | **Complexity**: High | **Impact**: High - -**Problem**: Private helpers (identified by underscore prefix) are exported from their modules, and then mixed indiscriminately onto `Connection.prototype` via `Object.assign`. This makes `org._authEndpoint()`, `org._apiRequest()`, `org._getOpts()`, etc. genuinely callable from external code. - -**Exported private symbols currently on Connection prototype**: -- `lib/auth.js`: `_authEndpoint`, `_loginEndpoint`, `_revokeEndpoint`, `_notifyAndResolve`, `_resolveOAuth` -- `lib/api.js`: `_getOpts` -- `lib/http.js`: `_apiAuthRequest`, `_apiRequest` - -**Target Architecture**: - -The simplest compliant approach within the existing mixin pattern is to keep private symbols as module-local functions (not exported) and bind them to the instance in the `Connection` constructor using `Object.defineProperty` with `enumerable: false`: - -```js -// index.js — Connection constructor -const Connection = function (opts) { - // ... existing setup ... - - // Bind private helpers non-enumerably - Object.defineProperty(this, '_getOpts', { - value: _getOpts.bind(this), - enumerable: false, writable: false, configurable: false - }); - Object.defineProperty(this, '_apiRequest', { - value: _apiRequest.bind(this), - enumerable: false, writable: false, configurable: false - }); - // ... etc for all private helpers -}; -``` - -**Alternative (simpler, smaller change surface)**: Introduce a private namespace object: -```js -// In Connection constructor: -const _private = { - getOpts: _getOpts.bind(this), - apiRequest: _apiRequest.bind(this), - apiAuthRequest: _apiAuthRequest.bind(this), - // ... -}; -// Store privately (not enumerable): -Object.defineProperty(this, '_private', { value: _private, enumerable: false }); -``` -Then all internal callers use `this._private.apiRequest(opts)` instead of `this._apiRequest(opts)`. - -**Steps**: -1. Audit all callers of each private helper in the codebase (use grep for `this\._getOpts`, `this\._apiRequest`, etc.). -2. For `_getOpts`: it is called by all public API methods in `api.js`. These are on the prototype and use `this._getOpts`; they already work after R18 since the binding installs the function on the instance. -3. For test files that call private methods directly (e.g., `test/connection.js` lines 377–443): refactor tests to exercise the observable public behaviour instead. -4. Remove private symbols from `module.exports` in each of the three files. -5. Update `index.js` `Connection` constructor to install private bindings. -6. Run `npm test` after each file is updated. - -**Risk Mitigation**: -- This is the highest-risk recommendation. Implement after full test suite runs green on all prior phases. -- Maintain a backwards-compatibility shim for one major version if the package has downstream consumers relying on private methods. -- Document the breaking change clearly in CHANGELOG.md. - ---- - -### R19: Sub-divide `lib/api.js` into Domain Modules - -**Smell**: God Module (Issue #3) -**Technique**: Extract Class (module-level), Move Method -**File**: `lib/api.js` (503 lines, 30 exported symbols) -**Risk**: High | **Complexity**: High | **Impact**: High - -**Problem**: `lib/api.js` combines eight conceptually distinct concerns: -- **System Metadata**: `getVersions`, `getResources`, `getSObjects`, `getMetadata`, `getDescribe`, `getLimits` -- **Identity**: `getPasswordStatus`, `updatePassword`, `getIdentity` -- **CRUD**: `insert`, `update`, `upsert`, `delete`, `getRecord` -- **Binary/Blob**: `getBody`/`getBinaryContent`, `getAttachmentBody`, `getDocumentBody`, `getContentVersionData` -- **Query/Search**: `query`, `queryAll`, `search`, `_queryHandler`, `respToJson` -- **URL Access**: `getUrl`, `putUrl`, `postUrl`, `deleteUrl`, `_urlRequest` -- **Apex REST**: `apexRest` -- **Streaming**: `createStreamClient`, `subscribe`, `stream` - -**Target file structure**: -``` -lib/ - crud.js — insert, update, upsert, delete, getRecord - query.js — query, queryAll, search, _queryHandler, respToJson - metadata.js — getVersions, getResources, getSObjects, getMetadata, getDescribe, getLimits, getIdentity, getPasswordStatus, updatePassword - blob.js — getBinaryContent, getAttachmentBody, getDocumentBody, getContentVersionData - url.js — getUrl, putUrl, postUrl, deleteUrl, _urlRequest - apexrest.js — apexRest - streaming.js — createStreamClient, subscribe, stream - apiutils.js — _getOpts, sobjectPath, resolveId, resolveType, requireForwardSlash, applyBody (from R07) -``` - -**Updated `index.js`**: -```js -const crudMethods = require('./lib/crud'); -const queryMethods = require('./lib/query'); -const metadataMethods = require('./lib/metadata'); -const blobMethods = require('./lib/blob'); -const urlMethods = require('./lib/url'); -const apexMethods = require('./lib/apexrest'); -const streamingMethods = require('./lib/streaming'); - -Object.assign(Connection.prototype, - httpMethods, authMethods, - crudMethods, queryMethods, metadataMethods, blobMethods, - urlMethods, apexMethods, streamingMethods -); -``` - -**Steps**: -1. Begin with `streaming.js` — the most self-contained concern (creates `FDCStream.Client`, no shared utilities beyond `_getOpts`). -2. Extract `query.js` next — `_queryHandler` and `respToJson` are query-only concerns. -3. Extract `blob.js` — blob methods all follow the same resource-path + `blob: true` pattern. -4. Extract `crud.js` — depends on `applyBody` (R07) and `multipart`. -5. Extract `metadata.js` — pure GET calls against system endpoints. -6. Extract `url.js` and `apexrest.js` — each is small and independent. -7. Create `apiutils.js` (or `lib/requestutils.js`) for shared helpers (`_getOpts`, `sobjectPath`, etc.). -8. Update all `require` references across affected modules. -9. Update `index.js` to mix in from the new modules. -10. Run `npm test` after each extraction. - -**Dependency**: Implement after R07 (applyBody), R17 (multipart into Record). Coordinate with R18 (private helpers) to avoid extracting private symbols into new public module exports. - ---- - -### R20: Introduce Typed Request Value Objects to Replace the Opts Bag - -**Smell**: Primitive Obsession / Data Clump (Issue #2) -**Technique**: Introduce Parameter Object, Replace Data Value with Object -**Files**: `lib/api.js`, `lib/auth.js`, `lib/http.js`, `lib/optionhelper.js` -**Risk**: High | **Complexity**: High | **Impact**: High - -**Problem**: A single mutable plain object (`opts`) accumulates all properties — OAuth credentials, HTTP verb, URL fragments, serialized body, retry counter, feature flags — as it flows through every layer. Each layer is implicitly coupled to the full bag schema. Runtime state (`_retryCount`) is grafted onto the caller's object. - -**Target design** — introduce three lightweight boundary objects: - -```js -// Represents what a caller provides to an API method (pre-HTTP): -class ApiRequestOptions { - constructor({ oauth, resource, method = 'GET', body, qs, headers, blob, raw, signal } = {}) { - this.oauth = oauth; - this.resource = resource; - this.method = method; - this.body = body; - this.qs = qs; - this.headers = headers; - this.blob = blob; - this.raw = raw; - this.signal = signal; - } -} - -// Represents the resolved HTTP-layer request (post-optionhelper): -class HttpRequest { - constructor({ uri, method, headers, body, qs, signal } = {}) { - this.uri = uri; - this.method = method; - this.headers = headers; - this.body = body; - this.qs = qs; - this.signal = signal; - } -} - -// Retry context — separate from the request: -class RetryContext { - constructor({ maxRetries = 1, count = 0 } = {}) { - this.maxRetries = maxRetries; - this.count = count; - } - get canRetry() { return this.count < this.maxRetries; } - increment() { return new RetryContext({ maxRetries: this.maxRetries, count: this.count + 1 }); } -} -``` - -**Migration strategy** (incremental): -1. Start with `RetryContext` only: remove `opts._retryCount` and pass context as a second parameter to `_apiRequest`. This is a low-disruption first step. -2. Introduce `HttpRequest` as the output type of `optionhelper.getApiRequestOptions`. Internal to `http.js`; no external surface change required. -3. Introduce `ApiRequestOptions` for the boundary between public API methods and `_apiRequest`. This is the highest-effort step as it touches every API method in `api.js`. - -**Immediate partial improvement** (extract retry state only — see also R21): -```js -// _apiRequest with explicit retry context: -const _apiRequest = function (opts, retryCtx = new RetryContext()) { - // ... - .catch((err) => { - if (isAuthError(err) && this.autoRefresh && retryCtx.canRetry) { - return this.autoRefreshToken(opts).then(() => - this._apiRequest(opts, retryCtx.increment()) - ); - } - throw err; - }); -}; -``` - -**Steps**: See detailed sub-steps in R21 for the retry extraction. Full typed-object introduction is an ongoing refactoring and need not be done in a single commit. - ---- - -### R21: Separate Retry State from the Opts Bag in `_apiRequest` - -**Smell**: Temporary Field, Status Variable (Issue #8) -**Technique**: Split Temporary Variable, Remove Assignments to Parameters -**File**: `lib/http.js`, lines 167–182 -**Risk**: Medium | **Complexity**: Medium | **Impact**: Medium - -**Problem**: `opts._retryCount` is used as a guard to prevent infinite recursion during auto-refresh. It is written onto the caller's opts object — an object the caller owns. `opts._refreshResult` is already removed by R01. - -**Before**: -```js -return this.autoRefreshToken(opts).then((res) => { - opts._retryCount = 1; - return this._apiRequest(opts); -}); -``` - -**After** — pass retry state as a separate parameter (can be private/internal; callers never set it): -```js -const _apiRequest = function (opts, _retryCount = 0) { - const ropts = optionHelper.getApiRequestOptions(opts); - ropts.signal = buildSignal(ropts.signal, this.timeout); - const uri = optionHelper.getFullUri(ropts); - const sobject = opts.sobject; - - return fetch(uri, ropts) - .then((res) => responseFailureCheck(res)) - .then((res) => unsuccessfulResponseCheck(res)) - .then((res) => { /* blob/json/text handling ... */ }) - .then((body) => addSObjectAndId(body, sobject)) - .catch((err) => { - if ( - isAuthError(err) && - this.autoRefresh === true && - hasRefreshCredentials(opts) && - _retryCount === 0 - ) { - return this.autoRefreshToken(opts).then(() => - this._apiRequest(opts, 1) - ); - } - throw err; - }); -}; -``` - -Where `isAuthError` and `hasRefreshCredentials` are extracted helper functions: -```js -function isAuthError(err) { - return err.errorCode === 'INVALID_SESSION_ID' || err.errorCode === 'Bad_OAuth_Token'; -} -function hasRefreshCredentials(opts) { - return opts.oauth?.refresh_token || (this.username && this.password); -} -``` - -**Steps**: -1. Add `_retryCount = 0` as a second parameter to `_apiRequest`. -2. Replace `!opts._retryCount` guard with `_retryCount === 0`. -3. Replace `opts._retryCount = 1` mutation with passing `1` as the second argument to `this._apiRequest(opts, 1)`. -4. Extract `isAuthError(err)` as a module-level helper. -5. Verify `_apiRequest` is never called externally with a `_retryCount` argument (it should not be; callers always pass only opts). -6. Run `npm test`. - ---- - -### R22: Standardize on ES6 Class Syntax for `Connection` and `Record` - -**Smell**: Inconsistent Module Pattern (Issue #30) -**Technique**: Extract Superclass pattern, Substitute Algorithm -**Files**: `index.js`, `lib/record.js`, `lib/fdcstream.js` -**Risk**: Medium | **Complexity**: High | **Impact**: Medium - -**Problem**: `fdcstream.js` uses ES6 `class` syntax while `index.js` (Connection) and `lib/record.js` use the older ES5 constructor-function-with-prototype pattern. This is a stylistic inconsistency that increases cognitive load for contributors unfamiliar with both patterns. - -**Target design for `Record`**: -```js -class Record { - constructor(data) { - this.attributes = {}; - this._changed = new Set(); - this._previous = {}; - this._fields = Object.entries(data).reduce(/* ... same logic ... */); - } - - static fromResponse(data) { - const rec = new Record(data); - rec.reset(); - return rec; - } - - get(field) { /* ... */ } - set(field, value) { /* ... */ } - // ... all existing prototype methods as class methods ... -} - -module.exports = Record; -``` - -**Target design for `Connection`** (with prototype-mixin maintained): -```js -class Connection { - constructor(opts) { - // ... same body as the existing constructor function ... - } -} - -// Mixin remains valid with ES6 class: -Object.assign(Connection.prototype, httpMethods, authMethods, apiMethods); -``` - -**Steps**: -1. Convert `lib/record.js` first: translate `const Record = function(data) {...}` and all `Record.prototype.*` assignments to a `class Record { ... }` body. -2. Verify no external code reads `Record.prototype` directly. -3. Convert `index.js` Connection function to `class Connection`. -4. Verify the mixin `Object.assign(Connection.prototype, ...)` still works (it does; classes are syntactic sugar over prototypes in JS). -5. Run `npm test` after each conversion. - -**Sequencing Note**: This is the lowest-priority item in Phase 3. It is purely stylistic and carries no functional benefit. Implement it last, after all functional improvements are in place and tests are green. - ---- - -## Risk Assessment Summary - -### High-Risk Recommendations - -| Recommendation | Primary Risk | Mitigation | -|---|---|---| -| R18 (Separate private helpers) | External code may call `org._apiRequest()` etc. | Release as a major version bump; add deprecation warnings; maintain one-version shim | -| R19 (Sub-divide api.js) | Cross-module require graph changes; shared helper dependencies | Extract one module at a time; run tests between each extraction | -| R20 (Typed request objects) | All API method signatures change at the boundary | Migrate incrementally; start with retry context (R21) only | - -### Medium-Risk Recommendations - -| Recommendation | Primary Risk | Mitigation | -|---|---|---| -| R14 (Rename getBody to getBinaryContent) | Public API rename; downstream breakage | Emit deprecation warning from old name; keep shim for one major version | -| R17 (Move multipart into Record) | Requires adding `mime-types` dependency to record.js | Record already indirectly depends on the data; keep mime-types in a helper module that Record delegates to | -| R21 (Separate retry state) | Internal API change; verify no external callers depend on `opts._retryCount` | grep for `_retryCount` across all files first | -| R22 (ES6 class syntax) | Prototype-mixin pattern unchanged, but class syntax may confuse legacy tooling | Test across minimum supported Node.js version (>22 — no issue here) | - ---- - -## Dependencies and Recommended Sequencing - -``` -Phase 1 (all independent, can be done in any order): - R01 → R02 → R03 → R04 → R05 → R06 → R16 - -Phase 2 (R08 before R12; others independent): - R07 (extract applyBody) - R08 (extract resolveEndpoint) ← before R12 - R09 (inline _resolveOAuth) - R10 (fail-fast guard) - R11 (makeOrg test helper) - R12 (magic string constants) ← after R08 - R13 (rename _getOpts param) - R14 (rename getBody) - -Phase 3 (strict ordering required): - R21 (retry state separation) ← prerequisite for R20 - R07 already done ← prerequisite for R17 - R17 (multipart into Record) ← prerequisite for R19 - R19 (subdivide api.js) ← coordinate with R18 - R18 (hide private helpers) ← last, after all modules stabilized - R20 (typed request objects) ← ongoing, can begin with R21 as first step - R22 (ES6 class syntax) ← truly last, purely cosmetic -``` - ---- - -## Before/After Code Summary - -| File | Before | After | Technique | -|---|---|---|---| -| `lib/http.js` | Two 6-line AbortSignal blocks | `buildSignal(signal, timeout)` helper | Extract Method | -| `lib/http.js` | `opts._retryCount = 1` mutation | `_apiRequest(opts, 1)` second param | Remove Assignments to Parameters | -| `lib/http.js` | `opts._refreshResult = res` dead write | Deleted | Remove Dead Code | -| `lib/api.js` | Duplicate multipart/JSON conditional | `applyBody(opts, type, payloadFn)` | Extract Method | -| `lib/auth.js` | Three `environment === 'sandbox'` conditionals | `resolveEndpoint(env, prod, test)` | Extract Method, Consolidate Conditional | -| `lib/auth.js` | `_resolveOAuth` one-liner wrapper | `Promise.resolve(newOauth)` inline | Inline Method | -| `lib/api.js` | `_getOpts(d, ...)` | `_getOpts(input, ...)` | Rename Method (parameter) | -| `lib/api.js` | `getBody` name collision | `getBinaryContent` | Rename Method | -| `lib/errors.js` | `emptyResponse()` no `.type` | `.type = 'empty-response'` added | Error API alignment | -| `index.js` | `require('./package.json').sfdx.api` | `CONST.API` | Inline Temp | -| `test/connection.js` | 17 inline `createConnection(...)` blocks | `makeOrg(overrides)` helper | Extract Method | -| `lib/multipart.js` | Deep reach into Record internals | `record.toMultipartForm(isPatch)` | Move Method | -| `lib/api.js` | 503 lines, 8 concerns | Domain modules: crud, query, metadata, etc. | Extract Class (module) | -| `lib/constants.js` | `ENVS`, `MODES` arrays only | + `SANDBOX`, `SINGLE_MODE` named strings | Replace Magic Number with Symbolic Constant | diff --git a/code-refactoring-summary.md b/code-refactoring-summary.md deleted file mode 100644 index cf6f4d4..0000000 --- a/code-refactoring-summary.md +++ /dev/null @@ -1,161 +0,0 @@ -# Code Refactoring Summary - -## Project: nforce8 — Node.js REST API Wrapper for Salesforce - -**Date**: 2026-03-28 -**Total Recommendations**: 22 -**Source**: code-refactoring-report.md - ---- - -## High-Level Overview - -The 22 refactoring recommendations address three recurring themes identified in the code smell report: - -| Theme | Recommendations | Risk Profile | -|---|---|---| -| Scattered duplication (identical code blocks, dead code, redundant wrappers) | R01–R09, R11 | Low | -| Missing guards and naming clarity | R10, R12–R14, R16 | Low | -| Architectural exposure and module cohesion | R15, R17–R22 | Medium–High | - -The prior refactoring campaign already achieved the most impactful structural changes (decomposing the monolithic index.js into domain modules). The remaining work is refinement and hardening. - ---- - -## Priority Matrix - -| Recommendation | Description | Impact | Complexity | Risk | -|---|---|---|---|---| -| R01 | Remove dead `opts._refreshResult` write | M | L | L | -| R02 | Remove commented-out credential block | L | L | L | -| R03 | Fix fallacious `#getUrl` test description | L | L | L | -| R04 | Eliminate duplicate `package.json` read | L | L | L | -| R05 | Add `err.type = 'empty-response'` to error factory | M | L | L | -| R06 | Extract `buildSignal()` — remove duplicate AbortSignal setup | M | L | L | -| R07 | Extract `applyBody()` — unify insert/update body logic | M | L | L | -| R08 | Extract `resolveEndpoint()` — unify 3 endpoint conditionals | M | L | L | -| R09 | Inline `_resolveOAuth` — remove trivial wrapper | M | L | L | -| R10 | Add fail-fast guard for single-mode missing OAuth | H | L | L | -| R11 | Extract `makeOrg()` test helper | M | L | L | -| R12 | Replace magic strings with named constants | M | L | L | -| R13 | Rename `_getOpts` parameter `d` to `input` | L | L | L | -| R14 | Rename `getBody` to `getBinaryContent` | M | M | M | -| R15 | Apply `eslint --fix` for spacing inconsistencies | L | L | L | -| R17 | Move multipart form-building into `Record.toMultipartForm` | H | M | M | -| R18 | Separate private helpers from `module.exports` | H | H | H | -| R19 | Sub-divide `lib/api.js` into domain modules | H | H | H | -| R20 | Introduce typed request value objects | H | H | H | -| R21 | Separate retry state from opts bag | M | M | M | -| R22 | Standardize on ES6 class syntax | M | H | M | - ---- - -## Quick Reference — Refactoring Techniques Applied - -| Technique | Applied In | -|---|---| -| **Extract Method** | R06, R07, R08, R11 | -| **Inline Method** | R09 | -| **Remove Dead Code** | R01, R02 | -| **Rename Method** (incl. parameters) | R03, R13, R14 | -| **Inline Temp** | R04 | -| **Introduce Assertion** | R05, R10 | -| **Replace Magic Number with Symbolic Constant** | R12 | -| **Move Method** | R17 | -| **Hide Delegate** | R17 | -| **Hide Method** | R18 | -| **Extract Interface** | R18 | -| **Extract Class** (module-level) | R19 | -| **Introduce Parameter Object** | R20 | -| **Replace Data Value with Object** | R20 | -| **Split Temporary Variable** | R21 | -| **Remove Assignments to Parameters** | R21 | -| **Substitute Algorithm** (ES6 conversion) | R22 | -| **Consolidate Conditional Expression** | R08 | - ---- - -## Key Benefits Expected - -### Phase 1 Quick Wins -- **Dead code removal** (R01, R02): Eliminates misleading code paths and stale credential artifacts. Reduces noise during code review and grep searches. -- **Test accuracy** (R03): Removes a misleading test description that could cause developers to incorrectly diagnose test failures. -- **Constants consolidation** (R04): Ensures `API_VERSION` in `index.js` always tracks the same value as `CONST.API`, eliminating the risk of them diverging. -- **Error API symmetry** (R05): Enables programmatic error type discrimination for `emptyResponse` errors, matching the existing `invalidJson` capability. -- **Signal helper** (R06): Removes a copy-paste hazard; any future change to timeout/abort logic is made in exactly one place. - -### Phase 2 Design Improvements -- **Body logic unification** (R07): Makes `insert` and `update` visually symmetric and reduces future mutation risk. -- **Endpoint helper** (R08): Reduces the three sandbox/production endpoint functions to one-liners, making the shared logic obvious and testable in isolation. -- **Remove trivial wrapper** (R09): Reduces the public surface of Connection by one private method; tests that relied on the wrapper are redirected to test observable outcomes. -- **Fail-fast guard** (R10): Converts a cryptic `TypeError` (property access on undefined) into a descriptive, actionable error message when a single-mode connection is used without authentication. High developer experience impact for low implementation cost. -- **Test helper** (R11): Cuts test boilerplate by approximately 70 lines and ensures all connection tests share a single source of truth for default options. -- **Named constants** (R12): Eliminates scattered raw string comparisons for `'sandbox'` and `'single'`; any future renaming or addition of modes/environments is a single-file change. - -### Phase 3 Architectural Improvements -- **Multipart into Record** (R17): Restores the Information Expert principle — `Record` becomes the single authority for all its serialization formats (JSON payload, changed payload, multipart form). `multipart.js` is reduced to a thin adapter or eliminated. -- **Private helper segregation** (R18): The most impactful long-term change. After this, external consumers can no longer accidentally depend on internal implementation details, enabling free internal refactoring without breaking semver. -- **api.js subdivision** (R19): Reduces the change surface for individual concerns. A streaming bug fix touches only `streaming.js`; a CRUD change touches only `crud.js`. PR diffs become scoped and reviewable. -- **Typed request objects** (R20): Eliminates the implicit coupling created by the opts bag. Each layer's contract is explicit and statically verifiable. The incremental approach (starting with retry context via R21) keeps risk manageable. -- **Retry state separation** (R21): Removes the only case where runtime state is written back onto the caller's opts object, eliminating a subtle mutation side effect. -- **ES6 class unification** (R22): Stylistic but meaningful: new contributors encounter a single, consistent OOP pattern throughout the codebase. - ---- - -## Recommended Implementation Sequence - -``` -PHASE 1 — Zero-risk quick wins (do in one PR) - 1. R15 Apply eslint --fix for spacing (lib/api.js) - 2. R01 Remove dead opts._refreshResult - 3. R02 Remove commented-out credential block (test/integration.js) - 4. R03 Fix fallacious test description (test/record.js) - 5. R04 Inline CONST.API in index.js - 6. R05 Add err.type to emptyResponse() - 7. R06 Extract buildSignal() helper (lib/http.js) - -PHASE 2 — Design improvements (can be individual PRs) - 8. R13 Rename _getOpts 'd' -> 'input' - 9. R11 Extract makeOrg() test helper - 10. R08 Extract resolveEndpoint() (lib/auth.js) - 11. R12 Add SANDBOX/SINGLE_MODE constants (after R08) - 12. R09 Inline _resolveOAuth - 13. R07 Extract applyBody() (lib/api.js) - 14. R10 Add fail-fast guard for single-mode OAuth - 15. R14 Rename getBody -> getBinaryContent - -PHASE 3 — Architectural uplift (coordinated, breaking change window) - 16. R21 Separate retry state from opts bag - 17. R17 Move toMultipartForm into Record (after R07) - 18. R19 Sub-divide lib/api.js into domain modules (after R17) - 19. R18 Separate private helpers from module.exports (after R19) - 20. R20 Introduce typed request objects (incremental, starts alongside R21) - 21. R22 Standardize on ES6 class syntax (last — purely cosmetic) -``` - ---- - -## Effort Estimate - -| Phase | Recommendations | Estimated Engineering Effort | -|---|---|---| -| Phase 1 | R01–R06, R15 | 2–4 hours | -| Phase 2 | R07–R14 | 4–8 hours | -| Phase 3 | R17–R22 | 20–40 hours | - -Phase 3 effort is high because R18 and R19 require coordinated changes across all modules, careful test updates, and a communication plan for any downstream consumers of private methods. - ---- - -## SOLID / GRASP Compliance Expected After Refactoring - -| Principle | Current Score | Expected After Phase 1+2 | Expected After Phase 3 | -|---|---|---|---| -| **S** — Single Responsibility | 6/10 | 7/10 | 9/10 | -| **O** — Open/Closed | 7/10 | 7/10 | 8/10 | -| **L** — Liskov Substitution | 9/10 | 9/10 | 9/10 | -| **I** — Interface Segregation | 5/10 | 6/10 | 9/10 | -| **D** — Dependency Inversion | 7/10 | 7/10 | 8/10 | -| **Information Expert (GRASP)** | Partial | Improved (R17) | Strong | -| **Low Coupling (GRASP)** | Partial | Improved (R12, R21) | Strong | -| **High Cohesion (GRASP)** | Partial | Improved (R07, R08) | Strong (R19) | diff --git a/code-smell-detector-data.json b/code-smell-detector-data.json deleted file mode 100644 index 6f913f4..0000000 --- a/code-smell-detector-data.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "grade": "C", - "total_issues": 30, - "severity_distribution": { - "high": 3, - "medium": 13, - "low": 14 - }, - "category_distribution": { - "Dispensables": 9, - "Lexical Abusers": 8, - "Object-Oriented Abusers": 4, - "Bloaters": 4, - "Couplers": 3, - "Data Dealers": 2 - }, - "solid_compliance": { - "SRP": 6, - "OCP": 7, - "LSP": 9, - "ISP": 5, - "DIP": 7 - }, - "top_issues": [ - { - "file": "lib/auth.js", - "issue": "Indecent Exposure: private methods (_authEndpoint, _loginEndpoint, _revokeEndpoint, _notifyAndResolve, _resolveOAuth) exported onto public Connection prototype", - "severity": "high", - "category": "Object-Oriented Abusers" - }, - { - "file": "lib/api.js", - "issue": "Primitive Obsession / Data Clump: pervasive mutable opts-bag pattern carries credentials, HTTP verbs, payloads, retry state, and feature flags through every layer", - "severity": "high", - "category": "Bloaters" - }, - { - "file": "lib/api.js", - "issue": "God Module: 503 lines, 30 exports combining 8 unrelated concerns (CRUD, metadata, blob, query, search, URL access, Apex REST, streaming)", - "severity": "high", - "category": "Bloaters" - }, - { - "file": "lib/http.js", - "issue": "Duplicated Code: identical AbortSignal.timeout/merge block repeated in _apiAuthRequest (lines 100-106) and _apiRequest (lines 139-145)", - "severity": "medium", - "category": "Dispensables" - }, - { - "file": "lib/api.js", - "issue": "Duplicated Code: multipart-or-JSON body selection logic duplicated in insert() and update()", - "severity": "medium", - "category": "Dispensables" - }, - { - "file": "lib/http.js", - "issue": "Dead Write / Status Variable: opts._refreshResult assigned but never read; opts._retryCount embeds flow-control state in the data bag", - "severity": "medium", - "category": "Data Dealers" - }, - { - "file": "lib/auth.js", - "issue": "Lazy Element: _resolveOAuth is a one-line Promise.resolve wrapper with no added behaviour, exported publicly and tested directly", - "severity": "medium", - "category": "Dispensables" - }, - { - "file": "lib/multipart.js", - "issue": "Feature Envy: multipart() reaches into Record for type, fileName, payload, and body instead of Record owning its multipart representation", - "severity": "medium", - "category": "Couplers" - }, - { - "file": "test/record.js", - "issue": "Inappropriate Intimacy: tests access _fields, _changed, _previous, and _getPayload directly, coupling tests to private implementation details", - "severity": "medium", - "category": "Couplers" - }, - { - "file": "test/connection.js", - "issue": "Required Setup Code: nforce.createConnection() with full boilerplate repeated verbatim in 17 individual test cases", - "severity": "medium", - "category": "Dispensables" - } - ] -} diff --git a/code-smell-detector-report.md b/code-smell-detector-report.md deleted file mode 100644 index 499eb16..0000000 --- a/code-smell-detector-report.md +++ /dev/null @@ -1,856 +0,0 @@ -# Code Smell Detection Report - -## Executive Summary - -**Project**: nforce8 — Node.js REST API wrapper for Salesforce -**Analysis Date**: 2026-03-28 -**Languages**: JavaScript (Node.js, CommonJS modules) -**Files Analyzed**: 13 source files, 8 test files (22 total) - -The codebase is in good shape overall. A previous refactoring campaign has clearly reduced complexity; the monolithic `index.js` has been broken into domain modules and the prototype-mixin architecture is clean for its pattern. Most remaining findings are low-to-medium severity, concentrated in a few recurring themes: a pervasive "opts bag" pattern that acts as Primitive Obsession / Data Clump, internal methods exposed on public surfaces (Indecent Exposure), duplicated timeout-signal setup logic, and test files that repeat connection boilerplate exhaustively. - -**Total Issues Found**: 30 -| Severity | Count | -|---|---| -| High | 3 | -| Medium | 13 | -| Low | 14 | - -**Code Quality Grade**: C (16–30 total issues, 3 high-severity) - ---- - -## Project Analysis - -### Languages and Frameworks Detected -- **JavaScript** (Node.js >= 22, CommonJS `require`) -- **Faye** streaming library (`lib/fdcstream.js`) -- **Test stack**: Mocha + should.js + NYC coverage - -### Project Structure -``` -index.js — Entry point, Connection constructor, exports (77 lines) -lib/api.js — All Salesforce API methods (503 lines) -lib/auth.js — OAuth/authentication methods (267 lines) -lib/connection.js — Connection options validation (88 lines) -lib/constants.js — Endpoints, API version, defaults (52 lines) -lib/errors.js — Custom error factories (13 lines) -lib/fdcstream.js — Faye-based streaming API client (99 lines) -lib/http.js — Fetch-based request engine (189 lines) -lib/multipart.js — Multipart form-data builder (56 lines) -lib/optionhelper.js — HTTP request option builder (98 lines) -lib/plugin.js — Plugin extension system (52 lines) -lib/record.js — SObject record class (183 lines) -lib/util.js — Type utilities, ID resolution (77 lines) -``` - ---- - -## High Severity Issues (Architectural Impact) - -### 1. Indecent Exposure — Private Implementation Methods on the Public API Surface - -**Category**: Object-Oriented Abusers / Obfuscators -**Severity**: High -**Principle Violated**: Interface Segregation Principle (ISP), Information Hiding - -**Files and Lines**: -- `lib/auth.js`, lines 248–267 (exports block) -- `lib/api.js`, lines 472–503 (exports block) -- `lib/http.js`, lines 186–189 (exports block) - -**Description**: Implementation-private helpers are exported and thereby mixed into the public `Connection` prototype. A caller can invoke `org._authEndpoint()`, `org._loginEndpoint()`, `org._revokeEndpoint()`, `org._getOpts()`, `org._apiRequest()`, `org._apiAuthRequest()`, `org._notifyAndResolve()`, and `org._resolveOAuth()` directly. The leading underscore signals intent-to-be-private, but the mixin architecture (`Object.assign(Connection.prototype, ...)`) makes these genuinely public. - -```js -// lib/auth.js -module.exports = { - _authEndpoint, // private — should not be public - _loginEndpoint, // private — should not be public - _revokeEndpoint, // private — should not be public - _notifyAndResolve, // private — should not be public - _resolveOAuth, // private — should not be public - ... -}; - -// lib/api.js -module.exports = { - _getOpts, // private — should not be public - ... -}; -``` - -**Impact**: Callers depending on private methods create hidden coupling; internal refactoring becomes impossible without breaking external consumers. Tests currently exercise `org._notifyAndResolve` and `org._resolveOAuth` directly (test/connection.js, lines 377–443), cementing this coupling. - -**Recommendation**: Use a module-internal map for private methods and expose only the public API surface in `module.exports`. The `Connection` constructor can install private helpers via a non-enumerable property bag rather than through the shared prototype. - ---- - -### 2. Primitive Obsession / Data Clump — Pervasive Opts Bag Pattern - -**Category**: Bloaters / Data Dealers -**Severity**: High -**Principle Violated**: Single Responsibility Principle (SRP), Information Expert (GRASP) - -**Files and Lines**: -- `lib/api.js`, lines 10–27 (`_getOpts` function), and every API method (~27 call sites) -- `lib/auth.js`, lines 124–172 (`authenticate`), 174–219 (`refreshToken`) -- `lib/http.js`, lines 95–131 (`_apiAuthRequest`), 136–184 (`_apiRequest`) -- `lib/optionhelper.js`, lines 27–78 (`getApiRequestOptions`) - -**Description**: The entire system is organized around a single plain-object "opts bag" that accumulates properties as it flows through the call stack. Each layer reads from and writes to the same mutable object: - -```js -// A single opts object grows across multiple boundaries: -const opts = this._getOpts(data); // step 1: populate from caller -opts.resource = sobjectPath(type, id); // step 2: mutate resource -opts.method = 'PATCH'; // step 3: mutate method -opts.body = JSON.stringify(...); // step 4: mutate body -return this._apiRequest(opts); // step 5: pass mutable bag down -``` - -The same object carries OAuth credentials, HTTP verbs, URL fragments, serialized payloads, retry counters, feature flags (`blob`, `raw`, `fetchAll`, `includeDeleted`), and query parameters all at once. There is no type that communicates which properties are required at which layer. - -**Impact**: Every function touching `opts` is implicitly coupled to its entire schema. Adding a new property risks silent conflicts. Testing is harder because callers must understand the full bag contract. `opts._retryCount` and `opts._refreshResult` are state variables grafted onto the request bag at runtime (`lib/http.js`, lines 177–178), making the retry state invisible and surprising. - -**Recommendation**: Define typed request objects — even lightweight ones — for the major boundaries: `AuthRequest`, `ApiRequest`, `QueryOptions`. Separate what the caller provides from what the HTTP layer needs. Remove in-band state flags (`_retryCount`, `_refreshResult`) from the request bag; track retry state separately. - ---- - -### 3. God Module — `lib/api.js` as Monolithic API Surface - -**Category**: Bloaters -**Severity**: High -**Principle Violated**: Single Responsibility Principle (SRP), High Cohesion (GRASP) - -**File**: `lib/api.js` (503 lines, 30 exported symbols) - -**Description**: `lib/api.js` combines several conceptually independent concerns into one module: -- **System metadata**: getVersions, getResources, getSObjects, getMetadata, getDescribe, getLimits -- **CRUD operations**: insert, update, upsert, delete, getRecord -- **Binary/blob access**: getBody, getAttachmentBody, getDocumentBody, getContentVersionData -- **Query and search**: query, queryAll, search, internal `_queryHandler`, `respToJson` -- **URL access**: getUrl, putUrl, postUrl, deleteUrl, internal `_urlRequest` -- **Apex REST**: apexRest -- **Streaming**: createStreamClient, subscribe, deprecated stream -- **Internal utilities**: `_getOpts`, `sobjectPath`, `resolveId`, `resolveType`, `requireForwardSlash` - -While each individual function is small and well-written, lumping 30 exports into one file creates a single massive change surface. Adding a streaming feature, modifying CRUD behavior, and patching a query pagination bug all touch the same file. - -**Recommendation**: Sub-divide by domain: `lib/crud.js`, `lib/query.js`, `lib/streaming.js`, `lib/metadata.js`. The shared helpers (`_getOpts`, `sobjectPath`, `resolveId`, `resolveType`) should live in a shared utilities module rather than being buried inside api.js and exported from there. - ---- - -## Medium Severity Issues (Design Problems) - -### 4. Duplicated Code — Timeout/AbortSignal Setup Block - -**Category**: Dispensables -**Severity**: Medium -**Principle Violated**: DRY - -**File**: `lib/http.js`, lines 100–106 and lines 139–145 - -**Description**: An identical pattern for merging an `AbortSignal.timeout` with an optional existing signal appears twice — once in `_apiAuthRequest` and once in `_apiRequest`: - -```js -// Lines 100-106 (_apiAuthRequest): -if (this.timeout) { - const timeoutSignal = AbortSignal.timeout(this.timeout); - opts.signal = - opts.signal !== undefined - ? AbortSignal.any([timeoutSignal, opts.signal]) - : timeoutSignal; -} - -// Lines 139-145 (_apiRequest): -if (this.timeout) { - const timeoutSignal = AbortSignal.timeout(this.timeout); - ropts.signal = - ropts.signal !== undefined - ? AbortSignal.any([timeoutSignal, ropts.signal]) - : timeoutSignal; -} -``` - -**Recommendation**: Extract a `buildSignal(existingSignal, timeout)` helper function and call it from both locations. - ---- - -### 5. Duplicated Code — Multipart Type Check in `insert` and `update` - -**Category**: Dispensables -**Severity**: Medium -**Principle Violated**: DRY - -**File**: `lib/api.js`, lines 153–157 and lines 167–171 - -**Description**: The `insert` and `update` functions each duplicate the same conditional multipart-or-JSON body selection: - -```js -// insert (lines 153-157): -if (CONST.MULTIPART_TYPES.includes(type)) { - opts.multipart = multipart(opts); -} else { - opts.body = JSON.stringify(opts.sobject.toPayload()); -} - -// update (lines 167-171): -if (CONST.MULTIPART_TYPES.includes(type)) { - opts.multipart = multipart(opts); -} else { - opts.body = JSON.stringify(opts.sobject.toChangedPayload()); -} -``` - -The only difference is `toPayload()` vs `toChangedPayload()`. The multipart detection logic is expressed twice. - -**Recommendation**: Extract a helper such as `applyBody(opts, type, payloadFn)` that selects multipart or JSON, where the caller provides the payload function. - ---- - -### 6. Duplicated Code — Environment-Conditional Endpoint Selection - -**Category**: Dispensables -**Severity**: Medium -**Principle Violated**: DRY, Open/Closed Principle (OCP) - -**File**: `lib/auth.js`, lines 37–48 - -**Description**: Three helper functions apply the same sandbox/production conditional to different URL properties: - -```js -const _authEndpoint = function (opts = {}) { - if (opts.authEndpoint) return opts.authEndpoint; - return this.environment === 'sandbox' ? this.testAuthEndpoint : this.authEndpoint; -}; -const _loginEndpoint = function () { - return this.environment === 'sandbox' ? this.testLoginUri : this.loginUri; -}; -const _revokeEndpoint = function () { - return this.environment === 'sandbox' ? this.testRevokeUri : this.revokeUri; -}; -``` - -**Recommendation**: Extract a single `_resolveEndpoint(prod, test)` helper: `return this.environment === 'sandbox' ? test : prod`. Each named function then becomes a one-liner calling the shared helper. - ---- - -### 7. Duplicated Code — `package.json` Read for API Version - -**Category**: Dispensables -**Severity**: Medium -**Principle Violated**: DRY - -**Files**: -- `lib/constants.js`, line 15: `require('../package.json').sfdx.api` -- `index.js`, line 68: `require('./package.json').sfdx.api` - -**Description**: `package.json` is loaded separately in two files to extract `sfdx.api`. `index.js` already imports `CONST` from `lib/constants`, which exposes `CONST.API` from the same value. - -**Recommendation**: Remove the redundant `require('./package.json').sfdx.api` in `index.js` and re-export `API_VERSION` from the already-imported `CONST`: - -```js -// index.js — after fix -const API_VERSION = CONST.API; -``` - ---- - -### 8. Temporary Field / Status Variable — `_retryCount` and `_refreshResult` on the opts bag - -**Category**: Data Dealers / Object-Oriented Abusers -**Severity**: Medium -**Principle Violated**: Mutable Data, Separation of Concerns - -**File**: `lib/http.js`, lines 167–182 - -**Description**: The retry logic in `_apiRequest` writes state directly onto the opts bag that was originally owned by the caller: - -```js -opts._refreshResult = res; // line 177 — internal state on caller's object -opts._retryCount = 1; // line 178 — sentinel to prevent infinite recursion -return this._apiRequest(opts); -``` - -`_retryCount` acts as a Status Variable — flow control encoded in a data structure rather than in program structure. `_refreshResult` is a dead write; it is assigned but never read anywhere in the codebase. - -**Recommendation**: Represent retry logic as a standalone function with its own local retry state rather than piggybacking on the caller's opts. Alternatively, pass an explicit `{ maxRetries, retryCount }` context object through the retry boundary. Remove `_refreshResult`. - ---- - -### 9. Dead Write — `opts._refreshResult` Never Read - -**Category**: Dispensables (Dead Code) -**Severity**: Medium -**Principle Violated**: YAGNI - -**File**: `lib/http.js`, line 177 - -**Description**: `opts._refreshResult = res` is assigned within the retry path but is never read anywhere in the codebase. A search across all source files and tests confirms no consumer of `_refreshResult` exists. - -**Recommendation**: Remove the dead assignment. - ---- - -### 10. Magic Strings — Salesforce Content Type Identifiers - -**Category**: Lexical Abusers -**Severity**: Medium -**Principle Violated**: DRY, Single Source of Truth - -**Files**: -- `lib/constants.js`, line 13: `MULTIPART_TYPES = ['document', 'attachment', 'contentversion']` -- `lib/multipart.js`, lines 17–18: inline `'contentversion'` comparisons -- `lib/api.js`, line 228: type compared against `BODY_GETTER_MAP` string keys - -**Description**: The string `'contentversion'` appears in multiple files in hardcoded comparisons that must all agree. `multipart.js` compares the type against `'contentversion'` independently, without referencing `CONST.MULTIPART_TYPES`: - -```js -// multipart.js — independent magic strings -const entity = type === 'contentversion' ? 'content' : type; -const name = type === 'contentversion' ? 'VersionData' : 'Body'; -``` - -If the type string changes, or a new special type is added, `multipart.js` will not automatically stay in sync with `constants.js`. - -**Recommendation**: Define the content-type names as named constants and import them where needed. Use a lookup map in `multipart.js` keyed on the same constants. - ---- - -### 11. Inconsistent Style — Missing Spaces Around Assignment Operators - -**Category**: Lexical Abusers -**Severity**: Medium - -**File**: `lib/api.js`, lines 150, 163–164, 177–179, 188–189 - -**Description**: Several consecutive local variable assignments in the CRUD methods omit the space before the `=` operator: - -```js -const type =opts.sobject.getType(); // line 150 — missing space before opts -const id =opts.sobject.getId(); // line 164 — missing space before opts -const extId =opts.sobject... // line 179 -``` - -This is inconsistent with the rest of the file and with standard ESLint `space-infix-ops` expectations. - -**Recommendation**: Apply `eslint --fix` to normalize spacing. Consider adding `space-infix-ops: error` to the ESLint config if not already enforced. - ---- - -### 12. Feature Envy — `multipart.js` Reaching Deep into SObject - -**Category**: Couplers -**Severity**: Medium -**Principle Violated**: Information Expert (GRASP), Law of Demeter - -**File**: `lib/multipart.js`, lines 16–43 - -**Description**: The `multipart()` function interrogates many facets of an `opts.sobject` to build the form: - -```js -const type = opts.sobject.getType(); -const fileName = opts.sobject.getFileName(); -// ... -isPatch ? opts.sobject.toChangedPayload() : opts.sobject.toPayload() -// ... -opts.sobject.getBody() -``` - -The function's knowledge of SObject internals (that it has a body, a filename, a payload, and a type) makes `multipart.js` tightly coupled to the `Record` class. Any change to how `Record` exposes these concerns requires changes in `multipart.js`. - -**Recommendation**: Move the logic for building a multipart representation into `Record` itself (or a helper it owns), and expose a single `record.toMultipartForm(isPatch)` method. `multipart.js` then becomes a thin adapter. - ---- - -### 13. Lazy Element — `_resolveOAuth` in `lib/auth.js` - -**Category**: Dispensables -**Severity**: Medium -**Principle Violated**: YAGNI - -**File**: `lib/auth.js`, lines 120–122 - -**Description**: `_resolveOAuth` is a one-liner that wraps `Promise.resolve`: - -```js -const _resolveOAuth = function (newOauth) { - return Promise.resolve(newOauth); -}; -``` - -This function exists solely to be the symmetrical counterpart to `_notifyAndResolve`, but it adds no behavior, no documentation value, and no abstraction value. It is exported publicly and exercised by tests (`test/connection.js`, lines 426–443), which deepens the coupling. - -**Recommendation**: Replace calls to `this._resolveOAuth(newOauth)` with `Promise.resolve(newOauth)` directly in `authenticate`. Remove the function from the exports and update the one test that exercises it. - ---- - -### 14. Speculative Generality — `opts.requestOpts` Passthrough - -**Category**: Dispensables -**Severity**: Medium -**Principle Violated**: YAGNI - -**Files**: -- `lib/optionhelper.js`, lines 73–75 -- `lib/http.js`, lines 96–98 - -**Description**: Both `_apiAuthRequest` and `getApiRequestOptions` apply `Object.assign(opts, opts.requestOpts)` / `Object.assign(ropts, opts.requestOpts)`, providing an open-ended escape hatch for callers to inject arbitrary Fetch options. This is undocumented in user-facing docs, creates an implicit contract surface that is hard to test, and has no known current consumer in the test suite or examples. - -**Recommendation**: If `requestOpts` is intentional for power users, document it explicitly in the README. If it is unused in practice, remove it. A principled alternative is to expose specific typed options: `signal` for abort control and `headers` for header injection. - ---- - -### 15. Inappropriate Intimacy — Tests Accessing Internal `_fields`, `_changed`, `_previous` - -**Category**: Couplers -**Severity**: Medium -**Principle Violated**: Information Hiding, Encapsulation - -**File**: `test/record.js`, lines 41, 49, 55, 102, 109, 117, 160–161, 195–200, 217–218, 243–245 - -**Description**: The `Record` test suite routinely reaches into private state to set it up and assert against it: - -```js -acc._changed = new Set(); // line 217 — bypassing reset() -acc._previous = {}; // line 218 -acc._fields.id.should.equal(...) // line 160 — bypassing getId() -Object.keys(acc._fields) // line 41 — bypassing public API -acc._getPayload(true) // line 220 — testing private method -``` - -`_getPayload` is also accessed directly in `test/connection.js` (line 198) and multiple places in `test/record.js` (lines 347–378). Tests coupled to private internals break whenever internals are refactored, even if observable behaviour is unchanged. - -**Recommendation**: Test only the public contract: `get()`, `set()`, `changed()`, `previous()`, `hasChanged()`, `toPayload()`, `toChangedPayload()`. Introduce `reset()` setup patterns rather than direct field mutation. Refactor tests of `_getPayload` to test the public `toPayload` / `toChangedPayload` equivalents. - ---- - -### 16. Required Setup or Teardown Code — Repeated Connection Boilerplate in Tests - -**Category**: Other -**Severity**: Medium -**Principle Violated**: DRY, Test Readability - -**File**: `test/connection.js`, lines 8–168 (30 occurrences of `nforce.createConnection(...)`) - -**Description**: Every individual test creates a full `nforce.createConnection({...})` inline with `FAKE_CLIENT_ID`, `FAKE_REDIRECT_URI`, and various option permutations. The constants `FAKE_CLIENT_ID` and `FAKE_REDIRECT_URI` are referenced 88 times combined in the file. A baseline valid connection configuration is recreated from scratch in 17 `it` blocks: - -```js -// Repeated ~17 times verbatim or near-verbatim: -let org = nforce.createConnection({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_ID, - redirectUri: FAKE_REDIRECT_URI, - environment: 'production' -}); -``` - -**Recommendation**: Extract a shared `makeOrg(overrides = {})` helper at the top of the test file. Individual tests pass only the options that differ from the baseline. - ---- - -## Low Severity Issues (Readability / Maintenance) - -### 17. Trivial Getter/Setter Proliferation — auth.js - -**Category**: Dispensables (Lazy Element) -**Severity**: Low -**Principle Violated**: YAGNI - -**File**: `lib/auth.js`, lines 5–35 - -**Description**: Eight trivial getter/setter pairs expose raw properties with no validation, transformation, or documentation value: - -```js -const getOAuth = function () { return this.oauth; }; -const setOAuth = function (oauth) { this.oauth = oauth; }; -const getUsername = function () { return this.username; }; -const setUsername = function (username) { this.username = username; }; -const getPassword = function () { return this.password; }; -const setPassword = function (password) { this.password = password; }; -const getSecurityToken = function () { return this.securityToken; }; -const setSecurityToken = function (token) { this.securityToken = token; }; -``` - -These provide no encapsulation: the underlying fields are directly accessible on `this` via `Connection`. `setOAuth` is used in tests (`orgSingle.setOAuth(oauth)`) and `getOAuth()` in one example, but none perform validation or trigger side effects. - -**Recommendation**: Keep `setOAuth` since it is part of the documented public API. Consider removing the password/token setters or consolidating into a single `setCredentials({ username, password, securityToken })` method with validation. The trivial getters for username/password/securityToken add no value over direct property access. - ---- - -### 18. What Comment — Inline Comments Restating Obvious Code - -**Category**: Other -**Severity**: Low -**Principle Violated**: Communication (comments should explain why, not what) - -**Files**: -- `lib/constants.js`, line 14: `// This needs update for each SFDC release!` — describes manual maintenance burden rather than automating it -- `lib/http.js`, line 92–93: `// Auth request — used for OAuth token endpoints` — repeats what the function name communicates -- `lib/http.js`, line 133–134: `// API request — used for all Salesforce REST API calls` -- `test/mock/sfdc-rest-api.js`, line 13–14: `// Default answer, when none provided` - -**Description**: Several comments explain what the code does (which is evident from reading it) rather than the non-obvious "why" — the tradeoffs, constraints, or invariants being maintained. - -**Recommendation**: Remove obvious what-comments. For `constants.js` line 14, consider replacing the manual note with a reference to the Salesforce release schedule or a CI check. - ---- - -### 19. Fallacious Comment — `test/record.js` `#getUrl` Test Description - -**Category**: Lexical Abusers -**Severity**: Low - -**File**: `test/record.js`, line 202 - -**Description**: The describe block `'#getUrl'` contains a test with the description `'should let me get the id'`, but the test actually exercises `getUrl()`, not `getId()`: - -```js -describe('#getUrl', function () { - it('should let me get the id', function () { // wrong description - acc.getUrl().should.equal('http://www.salesforce.com'); - }); -}); -``` - -**Recommendation**: Change the test description to `'should let me get the url'`. - ---- - -### 20. Dead Code — Commented-Out Object Literal in `test/integration.js` - -**Category**: Dispensables -**Severity**: Low -**Principle Violated**: YAGNI - -**File**: `test/integration.js`, lines 56–67 - -**Description**: A large commented-out object literal with hardcoded credential placeholders has been left in the integration test, alongside a `TODO: fix the creds` marker (line 18): - -```js -/* - let x = { - clientId: "ADFJSD234ADF765SFG55FD54S", - clientSecret: "adsfkdsalfajdskfa", - ... - } - */ -``` - -This block serves no purpose and represents abandoned work superseded by the mock API approach. - -**Recommendation**: Remove the commented-out block and the `TODO` comment on line 18. - ---- - -### 21. Temporary Field — `let client = undefined` in Integration Test - -**Category**: Data Dealers -**Severity**: Low - -**File**: `test/integration.js`, line 7 - -**Description**: `let client = undefined` is a mutable Temporary Field initialized to undefined, then conditionally assigned in `before()`. Defensive null-checks scatter across the test: - -```js -let client = undefined; // nullable init -before(() => { - client = nforce.createConnection(creds); // maybe assigned -}); -after(() => { - if (client != null && ...) { ... } // defensive check -}); -``` - -**Recommendation**: Use `describe.skip` (already partially implemented in the file) to skip the entire suite when credentials are absent, eliminating the nullable client variable and defensive checks entirely. - ---- - -### 22. Inconsistent Null Checks — `== null` vs `=== undefined` vs `!x` - -**Category**: Lexical Abusers -**Severity**: Low - -**Files**: -- `test/integration.js`, lines 14, 24: `== null`, `!= null` -- `lib/util.js`, line 32: `candidate !== null` -- `lib/optionhelper.js`, line 34: `!opts.resource` -- `lib/http.js`, lines 24–28: mix of `!== undefined`, `!== null`, string comparison - -**Description**: At least four different idioms for checking absent values are used across the codebase. This is not a bug, but it increases cognitive load when reading unfamiliar code paths. - -**Recommendation**: Establish a project convention in CLAUDE.md or ESLint config. Use strict `=== null` / `=== undefined` when the distinction matters, and optional chaining / nullish coalescing where idiomatic. - ---- - -### 23. Boolean Blindness — `raw` Flag in Query, Search, and getRecord - -**Category**: Lexical Abusers -**Severity**: Low - -**File**: `lib/api.js`, lines 203–213, 267–276, 357–370 - -**Description**: A boolean `raw` flag controls whether results are hydrated as `Record` instances or returned as plain objects: - -```js -org.query({ query: q, raw: false }); // raw=false means Records -org.query({ query: q, raw: true }); // raw=true means plain objects -``` - -A reader encountering `raw: true` must look up the convention to understand its meaning. The boolean loses the semantic context of what "raw" means in this domain. - -**Recommendation**: Consider a string discriminant such as `{ responseType: 'records' }` vs `{ responseType: 'raw' }`, or provide explicit `queryRaw()` / `searchRaw()` variants so the intent is self-documenting at the call site. - ---- - -### 24. Uncommunicative Name — `d` Parameter in `_getOpts` - -**Category**: Lexical Abusers -**Severity**: Low - -**File**: `lib/api.js`, line 10 - -**Description**: The parameter `d` in `_getOpts(d, opts = {})` is a single-letter name with no communicated meaning: - -```js -const _getOpts = function (d, opts = {}) { - let data = {}; - if (opts.singleProp && d && !util.isObject(d)) { - data[opts.singleProp] = d; - } else if (util.isObject(d)) { - data = d; - } - ... -} -``` - -The variable represents "the raw caller-provided input, which may be a string, number, or object." - -**Recommendation**: Rename `d` to `input` or `callerArg` to communicate its role. - ---- - -### 25. Speculative API Version Stripping — `apiVersion.substring(1)` in `fdcstream.js` - -**Category**: Obfuscators -**Severity**: Low - -**File**: `lib/fdcstream.js`, line 47 - -**Description**: The streaming endpoint is built by stripping the leading `'v'` from the version string: - -```js -this._endpoint = - opts.oauth.instance_url + '/cometd/' + opts.apiVersion.substring(1); -``` - -This assumes the API version always starts with `'v'` (enforced by `API_VERSION_RE` in `connection.js`), but the dependency between the regex in one module and the string manipulation in another is implicit. - -**Recommendation**: Extract a `stripVersionPrefix(v)` helper that documents the invariant, or add a comment referencing the format constraint. - ---- - -### 26. Missing Fail-Fast Guard — Single Mode Without OAuth - -**Category**: Obfuscators -**Severity**: Low - -**File**: `lib/api.js`, lines 18–21 - -**Description**: In single mode, `_getOpts` silently injects `this.oauth` into the data bag: - -```js -if (this.mode === 'single' && !data.oauth) { - data.oauth = this.oauth; -} -``` - -If `this.oauth` is undefined (connection created but never authenticated), the injected value will be undefined. This causes a silent failure deep in `optionhelper.js` when attempting `opts.oauth.instance_url` (property access on undefined). There is no fail-fast guard. - -**Recommendation**: Add an explicit guard with a descriptive error: if `this.mode === 'single'` and `this.oauth` is falsy, throw `"Connection is in single-user mode but no OAuth token has been set. Call authenticate() first."`. - ---- - -### 27. Magic Strings — Mode and Environment Literals - -**Category**: Lexical Abusers -**Severity**: Low -**Principle Violated**: DRY, Single Source of Truth - -**Files**: -- `lib/auth.js`: `'sandbox'` at lines 39, 43, 47; `'single'` at line 156 -- `lib/http.js`: `'single'` at line 126 -- `lib/constants.js`: defines `CONST.ENVS` and `CONST.MODES` - -**Description**: The string literals `'sandbox'`, `'single'`, and `'multi'` appear as direct comparisons throughout multiple files even though they are defined in `constants.js`. The constants exist but are not used for comparisons. - -**Recommendation**: Export named string constants from `constants.js` (e.g., `CONST.SANDBOX = 'sandbox'`, `CONST.SINGLE_MODE = 'single'`) and use them in comparisons rather than raw strings. - ---- - -### 28. Ambiguous Method Name — `getBody` Conflicts with `Record.prototype.getBody` - -**Category**: Lexical Abusers -**Severity**: Low - -**File**: `lib/api.js`, lines 226–234 - -**Description**: The API method `getBody` (a dispatcher routing to `getDocumentBody`, `getAttachmentBody`, or `getContentVersionData`) shares its name with `Record.prototype.getBody` (which retrieves the binary attachment body from a record object). Both names appear in the same domain (Salesforce SObjects), creating conceptual ambiguity. - -**Recommendation**: Rename the API dispatcher to `getBinaryContent` or `getFileBody`, which more accurately signals that it retrieves binary file content from Salesforce storage. - ---- - -### 29. Incomplete Error Factory — `emptyResponse` Missing `type` Property - -**Category**: Dispensables -**Severity**: Low - -**File**: `lib/errors.js`, lines 9–11 - -**Description**: `invalidJson()` sets `err.type = 'invalid-json'` (line 5–7), allowing callers to programmatically distinguish the error. `emptyResponse()` does not set a corresponding `type` property, making the API asymmetric: - -```js -const invalidJson = () => { - const err = new Error('...'); - err.type = 'invalid-json'; // type is set - return err; -}; - -const emptyResponse = () => { - return new Error('Unexpected empty response'); // no type -}; -``` - -The test in `test/errors.js` checks `err.type` for invalid-JSON errors, but `emptyResponse` cannot be caught by type in the same way. - -**Recommendation**: Add `err.type = 'empty-response'` to `emptyResponse()` for symmetry. - ---- - -### 30. Inconsistent Module Pattern — Constructor Functions vs. ES6 Classes - -**Category**: Inconsistent Style -**Severity**: Low - -**Files**: -- `index.js`, line 16: `const Connection = function (opts) { ... }` — ES5 constructor function -- `lib/fdcstream.js`, lines 6–37, 41–94: `class Subscription` / `class Client` — ES6 classes -- `lib/record.js`, line 3: `const Record = function (data) { ... }` — ES5 constructor function - -**Description**: The codebase mixes two OOP patterns. `fdcstream.js` uses ES6 class syntax while `index.js` and `record.js` use the older constructor-function-with-prototype pattern. This is not a correctness issue but creates stylistic inconsistency that increases cognitive load for contributors. - -**Recommendation**: Standardize on ES6 class syntax. Convert `Connection` and `Record` to ES6 classes. The prototype-mixin pattern for `Connection` (`Object.assign(Connection.prototype, ...)`) can be replaced with explicit method definitions in the class body or a deliberate mixin pattern using a shared base. - ---- - -## SOLID Principle Violation Summary - -| Principle | Compliance Score (0–10) | Key Violations | -|---|---|---| -| **S** — Single Responsibility | 6/10 | `lib/api.js` handles 8 distinct concerns; Connection prototype mixes HTTP, auth, and API | -| **O** — Open/Closed | 7/10 | New Salesforce object types require editing `CONST.MULTIPART_TYPES` and `BODY_GETTER_MAP` | -| **L** — Liskov Substitution | 9/10 | No inheritance hierarchies; no violations found | -| **I** — Interface Segregation | 5/10 | Public API surface includes private methods; no interface contracts | -| **D** — Dependency Inversion | 7/10 | `_apiRequest` uses global `fetch`; `fdcstream.js` hardcodes Faye | - -## GRASP Principle Violation Summary - -| Principle | Assessment | -|---|---| -| **Information Expert** | Partially violated: `multipart.js` reaches into `Record` instead of `Record` owning its own representation | -| **Creator** | Adequate: `api.js` creates `Record` instances from response data, which is appropriate | -| **Controller** | Adequate: `Connection` acts as controller; not bloated beyond existing description | -| **Low Coupling** | Partially violated: the opts bag creates implicit coupling across all layers | -| **High Cohesion** | Partially violated: `lib/api.js` is low-cohesion, mixing 8 concerns | -| **Polymorphism** | Adequate: `BODY_GETTER_MAP` dispatch is a reasonable OCP-friendly dispatch approach | -| **Pure Fabrication** | Adequate: `util.js`, `errors.js`, `optionhelper.js` are appropriate fabrications | -| **Indirection** | Adequate: layers are reasonably separated | -| **Protected Variations** | Partially violated: endpoint URLs hardcoded without abstraction for environments beyond sandbox/production | - ---- - -## Impact Assessment - -**Total Issues Found**: 30 issues -- **High Severity**: 3 (architectural impact) -- **Medium Severity**: 13 (design impact) -- **Low Severity**: 14 (readability/maintenance) - -**Breakdown by Category**: -| Category | Count | -|---|---| -| Dispensables (duplicated/dead code, YAGNI) | 9 | -| Lexical Abusers (naming, comments, magic strings) | 8 | -| Object-Oriented Abusers (exposure, style) | 4 | -| Bloaters (size, opts bag) | 4 | -| Couplers (inappropriate intimacy, feature envy) | 3 | -| Data Dealers (mutable state, temporary fields) | 2 | - ---- - -## Recommendations and Refactoring Roadmap - -### Phase 1 — Quick Wins (Low Risk, High Clarity) -1. Fix spacing inconsistencies in `lib/api.js` lines 150, 163–164, 177–179, 188–189 (`eslint --fix`) -2. Remove dead code: `opts._refreshResult` assignment (`http.js:177`) and the commented-out block in `test/integration.js` (lines 56–67) -3. Fix fallacious test description in `test/record.js` line 202: `'should let me get the id'` -> `'should let me get the url'` -4. Remove duplicate `package.json` read in `index.js` line 68; use `CONST.API` from the already-imported constants -5. Add `err.type = 'empty-response'` to `emptyResponse()` in `lib/errors.js` -6. Extract a `buildSignal(existingSignal, timeout)` helper in `lib/http.js` to eliminate the two identical timeout/signal setup blocks - -### Phase 2 — Design Improvements (Medium Risk) -7. Extract `applyBody(opts, type, payloadFn)` helper in `lib/api.js` to unify the duplicated multipart/JSON body logic in `insert` and `update` -8. Extract `_resolveEndpoint(prod, test)` helper in `lib/auth.js` to unify the three environment-conditional endpoint functions -9. Remove `_resolveOAuth` — replace with `Promise.resolve(newOauth)` inline; remove from exports and update test -10. Add fail-fast guard in `_getOpts` for single-mode missing oauth -11. Extract a `makeOrg(overrides)` helper in `test/connection.js` to eliminate repeated boilerplate - -### Phase 3 — Architectural Improvements (Higher Risk, Higher Value) -12. Separate private methods from `module.exports` in `auth.js`, `api.js`, and `http.js`. Expose only the public API surface on `Connection.prototype` -13. Sub-divide `lib/api.js` into domain-specific modules: `lib/crud.js`, `lib/query.js`, `lib/streaming.js`, `lib/metadata.js` -14. Move multipart representation into `Record`: expose `record.toMultipartForm(isPatch)` rather than having `multipart.js` reach into Record internals -15. Standardize on ES6 class syntax for `Connection` and `Record` -16. Introduce typed options objects or at minimum document the opts-bag schema per layer boundary to reduce Primitive Obsession impact - ---- - -## Appendix — Files Analyzed - -| File | Lines | Status | -|---|---|---| -| `index.js` | 77 | Analyzed | -| `lib/api.js` | 503 | Analyzed | -| `lib/auth.js` | 267 | Analyzed | -| `lib/connection.js` | 88 | Analyzed | -| `lib/constants.js` | 52 | Analyzed | -| `lib/errors.js` | 13 | Analyzed | -| `lib/fdcstream.js` | 99 | Analyzed | -| `lib/http.js` | 189 | Analyzed | -| `lib/multipart.js` | 56 | Analyzed | -| `lib/optionhelper.js` | 98 | Analyzed | -| `lib/plugin.js` | 52 | Analyzed | -| `lib/record.js` | 183 | Analyzed | -| `lib/util.js` | 77 | Analyzed | -| `test/connection.js` | 444 | Analyzed | -| `test/crud.js` | 242 | Analyzed | -| `test/errors.js` | 68 | Analyzed | -| `test/integration.js` | 68 | Analyzed | -| `test/mock/sfdc-rest-api.js` | 131 | Analyzed | -| `test/plugin.js` | 108 | Analyzed | -| `test/query.js` | 204 | Analyzed | -| `test/record.js` | 379 | Analyzed | -| `test/util.js` | 47 | Analyzed | - -**Files Excluded**: `examples/` (snippet-style scripts, linted differently per ESLint config), `node_modules/`, `.nyc_output/`, config files. - ---- - -## Detection Methodology - -- Manual source reading of all 22 analyzed files -- Cross-file pattern analysis using grep for duplicate constructs, magic strings, and naming inconsistencies -- Verification of usage for potentially dead code (search across all source files) -- SOLID and GRASP principle assessment per module -- JavaScript/Node.js-specific thresholds: long method > 50 lines, large module > 300 lines -- Historical catalog references: Fowler (1999/2018), Martin (2008), Jerzyk (2022) diff --git a/code-smell-detector-summary.md b/code-smell-detector-summary.md deleted file mode 100644 index d4e1812..0000000 --- a/code-smell-detector-summary.md +++ /dev/null @@ -1,73 +0,0 @@ -# Code Quality Summary — nforce8 - -## Critical Issues -**3 High-severity issues found — no CI-blocking bugs, but architectural patterns that increase maintenance cost** - -### Top 3 Problems -1. **Indecent Exposure** — Private implementation helpers (`_authEndpoint`, `_getOpts`, `_apiRequest`, etc.) are exported onto the public `Connection` prototype — **Priority: High** -2. **Primitive Obsession / Data Clump** — A single mutable "opts bag" object carries all request state through every layer of the call stack, creating invisible coupling — **Priority: High** -3. **God Module** — `lib/api.js` (503 lines, 30 exports) combines 8 unrelated concerns: CRUD, metadata, blob retrieval, query, search, URL access, Apex REST, and streaming — **Priority: High** - ---- - -## Overall Assessment -- **Project Size**: 22 files analyzed, 1 language (JavaScript / Node.js) -- **Code Quality Grade**: C -- **Total Issues**: High: 3 | Medium: 13 | Low: 14 -- **Overall Complexity**: Medium — individual functions are well-written; architectural patterns create the majority of the debt - -## Business Impact -- **Technical Debt**: Medium — the codebase is functional and well-tested; debt is structural rather than buggy -- **Maintenance Risk**: Medium — the opts-bag pattern and exposed privates make refactoring risky without broad test coverage -- **Development Velocity Impact**: Medium — new API methods are easy to add but hard to isolate, test, and reason about due to the shared opts pattern -- **Recommended Priority**: Medium — no immediate stability risk; address before the next major feature cycle - ---- - -## Quick Wins -- **Fix spacing in `lib/api.js`**: Priority: Low — zero risk; eliminates 8 linting warnings -- **Remove `opts._refreshResult` dead write**: Priority: Medium — removes misleading code in the retry path -- **Fix mislabelled test in `test/record.js`**: Priority: Low — prevents future developer confusion -- **Remove commented-out block in `test/integration.js`**: Priority: Low — removes outdated credential placeholder -- **Add `err.type` to `emptyResponse()`**: Priority: Low — makes error factory API consistent - -## Major Refactoring Needed -- **`lib/api.js`**: Priority: High — splitting into domain modules reduces change surface and improves testability -- **Private method exposure**: Priority: High — removing `_prefixed` methods from public exports prevents accidental external dependencies -- **Opts bag pattern**: Priority: Medium — introducing typed request objects reduces implicit coupling between call layers - ---- - -## Recommended Action Plan - -### Phase 1 — Immediate (Low risk, no API surface changes) -- Apply ESLint auto-fix for spacing in `lib/api.js` -- Remove the dead `opts._refreshResult` assignment in `lib/http.js` line 177 -- Remove commented-out block and TODO in `test/integration.js` -- Fix mislabelled test description in `test/record.js` line 202 -- Add `err.type = 'empty-response'` to `lib/errors.js` -- Extract `buildSignal()` helper in `lib/http.js` to eliminate duplicated timeout logic - -### Phase 2 — Short-term (Design improvements, minimal API impact) -- Extract `applyBody()` helper in `lib/api.js` to unify the insert/update multipart logic -- Extract `_resolveEndpoint()` helper in `lib/auth.js` to unify the three endpoint functions -- Remove `_resolveOAuth` wrapper; replace with `Promise.resolve()` inline -- Add single-mode fail-fast guard in `_getOpts` for missing oauth -- Extract `makeOrg(overrides)` helper in `test/connection.js` - -### Phase 3 — Long-term (Architectural, requires coordination) -- Stop exporting private `_`-prefixed methods from `auth.js`, `api.js`, `http.js` -- Sub-divide `lib/api.js` into `lib/crud.js`, `lib/query.js`, `lib/streaming.js`, `lib/metadata.js` -- Move multipart form construction into the `Record` class -- Standardize ES6 class syntax across `Connection` and `Record` - ---- - -## Key Takeaways -- The codebase is significantly better than its starting point; the refactoring to extract domain modules was the right direction -- The largest remaining risk is the public exposure of private methods — external consumers could start depending on them, making future internal refactoring breaking changes -- The opts-bag pattern is the root cause of multiple related smells (Data Clump, Primitive Obsession, Mutable Data, Temporary Field) and is the highest-leverage thing to address long-term -- Test coverage is good, but many tests are coupled to private internals of `Record`, which will cause false test failures during future refactoring - ---- -*Detailed technical analysis available in `code-smell-detector-report.md`* diff --git a/docs/superpowers/plans/2026-03-30-refactoring-phases-1-4.md b/docs/superpowers/plans/2026-03-30-refactoring-phases-1-4.md new file mode 100644 index 0000000..b14a3fc --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-refactoring-phases-1-4.md @@ -0,0 +1,931 @@ +# Refactoring Phases 1-4 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement all 18 refactoring recommendations (R01-R18) across 4 phases, committing after each recommendation with tests passing. + +**Architecture:** Sequential phase execution — Phase 1 fixes bugs, Phase 2 applies quick-win cleanups, Phase 3 improves design, Phase 4 adds features and restructures test infrastructure. Each recommendation is a discrete commit. + +**Tech Stack:** Node.js >=22.4.0, Mocha + should.js, ESLint (flat config, single quotes) + +--- + +## File Map + +| File | Changes | +|------|---------| +| `test/mock/cometd-server.js` | R01: already fixed (crypto). R08: hoist inline `require('events')` | +| `lib/api.js` | R03: fix upsert to use `applyBody`. R04: fix spacing | +| `test/crud.js` | R02: fix error-swallowing pattern (4 tests). R03: add upsert multipart test | +| `test/query.js` | R02: fix error-swallowing pattern (9 tests) | +| `lib/cometd.js` | R05: extract `_resubscribeAll()`. R07: fix quote style. R16: propagate errors | +| `lib/auth.js` | R06: remove `Promise.resolve()`. R09: modernize onRefresh. R10: refactor getAuthUri | +| `index.js` | R11: inline temp variable | +| `lib/util.js` | R12: named constant. R13: rename function | +| `lib/optionhelper.js` | R14: let->const. R15: rename function | +| `lib/http.js` | R15: update caller of renamed function | +| `test/integration.js` | R17: dead code cleanup | +| `test/mock/sfdc-rest-api.js` | R18: convert to class-based instance | +| `test/connection.js` | R09: add async onRefresh test | + +--- + +## Phase 1 — Critical Bug Fixes + +### Task 1: R01 — Verify `require('crypto')` Fix (Already Applied) + +R01 was already fixed in a prior session. The inline `require('crypto')` was removed and the file now uses Node 22's built-in `crypto` global. + +**Files:** +- Verify: `test/mock/cometd-server.js` + +- [ ] **Step 1: Verify the fix is in place** + +Run: `grep -n 'require.*crypto' test/mock/cometd-server.js` +Expected: No output (no require calls for crypto) + +- [ ] **Step 2: Run tests to confirm** + +Run: `npm test` +Expected: 143 passing + +- [ ] **Step 3: Skip commit — already committed** + +--- + +### Task 2: R03 — Fix `upsert()` to Use `applyBody` Helper + +**Files:** +- Modify: `lib/api.js:253-261` +- Test: `test/crud.js` (add upsert multipart test) + +- [ ] **Step 1: Write the failing test for multipart upsert** + +Add to `test/crud.js` inside the `#upsert` describe block, after the existing test: + +```js +it('should send multipart/form-data for ContentVersion upsert', (done) => { + let upsertResponse = { + code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: '068DEADBEEF', success: true }) + }; + let obj = nforce.createSObject('ContentVersion', { + Title: 'TestFile', + PathOnClient: 'test.txt' + }); + obj.setAttachment('test.txt', Buffer.from('binary content')); + obj.setExternalId('My_Ext_Id__c', 'ext123'); + api + .getGoodServerInstance(upsertResponse) + .then(() => org.upsert({ sobject: obj, oauth: oauth })) + .then((res) => { + should.exist(res); + res.id.should.equal('068DEADBEEF'); + let ct = api.getLastRequest().headers['content-type']; + ct.should.startWith('multipart/form-data'); + ct.should.containEql('boundary'); + api.getLastRequest().method.should.equal('PATCH'); + }) + .then(() => done()) + .catch((err) => done(err)); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx mocha test/crud.js --grep "multipart/form-data for ContentVersion upsert"` +Expected: FAIL — request body is JSON, not multipart + +- [ ] **Step 3: Fix `upsert()` in `lib/api.js`** + +Replace line 260: +```js +opts.body = JSON.stringify(opts.sobject.toPayload()); +``` +with: +```js +applyBody(opts, type, () => opts.sobject.toPayload()); +``` + +- [ ] **Step 4: Run tests to verify the fix** + +Run: `npm test` +Expected: All tests passing (including new multipart upsert test) + +- [ ] **Step 5: Commit** + +```bash +git add lib/api.js test/crud.js +git commit -m "fix: upsert() now uses applyBody for multipart support (R03)" +``` + +--- + +### Task 3: R02 — Fix Silent Error Swallowing in Test Promise Chains + +**Files:** +- Modify: `test/crud.js` (4 occurrences) +- Modify: `test/query.js` (9 occurrences) + +The pattern `.catch((err) => should.not.exist(err)).finally(() => done())` silently passes when assertions throw. Replace with returning the promise (Mocha handles rejections natively). + +- [ ] **Step 1: Fix `test/crud.js` — 4 occurrences** + +In `test/crud.js`, find all instances of this pattern: + +```js +// Pattern 1 (insert test ~line 68-71): +.catch((err) => { + should.not.exist(err); +}) +.finally(() => done()); + +// Pattern 2 (update test ~line 93-96): +.catch((err) => { + should.not.exist(err); +}) +.finally(() => done()); + +// Pattern 3 (upsert test ~line 123): +.catch((err) => should.not.exist(err)) +.finally(() => done()); + +// Pattern 4 (delete test ~line 146-147): +.catch((err) => should.not.exist(err)) +.finally(() => done()); +``` + +For each, replace with returning the promise and remove the `done` callback parameter: + +**crud.js insert test (~line 43):** Change `it('should create a proper request on insert', (done) => {` to `it('should create a proper request on insert', () => {`, add `return` before `org`, remove the `.catch(...)` and `.finally(...)`. + +**crud.js update test (~line 76):** Same transformation. + +**crud.js upsert test (~line 101):** Same transformation. + +**crud.js delete test (~line 128):** Same transformation. + +- [ ] **Step 2: Fix `test/query.js` — 9 occurrences** + +Apply the same transformation to all 9 tests in `test/query.js` that use the `.catch((err) => should.not.exist(err)).finally(() => done())` pattern: + +- `#query` "multi-user mode" (~line 35) +- `#query` "single-user mode" (~line 52) +- `#query` "string query single-user" (~line 64) +- `#queryAll` "multi-user mode" (~line 82) +- `#queryAll` "single-user mode" (~line 94) +- `#queryAll` "string query single-user" (~line 106) +- `#search` "Record instances" (~line 120) +- `#search` "raw results" (~line 149) +- `#search` "empty searchRecords" (~line 175) + +Each test: remove `(done)` parameter, add `return` before the promise chain, remove `.catch(...)` and `.finally(...)`. + +- [ ] **Step 3: Run tests** + +Run: `npm test` +Expected: All tests passing. If any test newly fails, that reveals a previously-masked bug — investigate and fix. + +- [ ] **Step 4: Commit** + +```bash +git add test/crud.js test/query.js +git commit -m "fix: replace error-swallowing test patterns with promise returns (R02)" +``` + +--- + +## Phase 2 — Code Quality Quick Wins + +### Task 4: R04 + R07 + R14 — ESLint Auto-Fixes + +**Files:** +- Modify: `lib/api.js` (spacing) +- Modify: `lib/cometd.js` (quote style) +- Modify: `lib/optionhelper.js` (let->const) + +- [ ] **Step 1: Run ESLint auto-fix on all three files** + +Run: `npx eslint --fix lib/api.js lib/cometd.js lib/optionhelper.js` + +- [ ] **Step 2: Verify only style changes** + +Run: `git diff lib/api.js lib/cometd.js lib/optionhelper.js` +Confirm: Only whitespace, quote, and let->const changes. + +- [ ] **Step 3: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 4: Commit** + +```bash +git add lib/api.js lib/cometd.js lib/optionhelper.js +git commit -m "style: fix spacing, quotes, and let->const via ESLint (R04, R07, R14)" +``` + +--- + +### Task 5: R05 — Extract `_resubscribeAll()` in `lib/cometd.js` + +**Files:** +- Modify: `lib/cometd.js` + +- [ ] **Step 1: Add `_resubscribeAll()` method** + +Add this method to the CometDClient class, just before `_sendSubscribe`: + +```js +/** + * Re-subscribe all active topics after a handshake. + */ +async _resubscribeAll() { + for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); + } +} +``` + +- [ ] **Step 2: Replace inline loops in `_rehandshake()` and `_scheduleReconnect()`** + +In `_rehandshake()`, replace: +```js +for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); +} +``` +with: +```js +await this._resubscribeAll(); +``` + +In `_scheduleReconnect()`, replace the same loop with: +```js +await this._resubscribeAll(); +``` + +- [ ] **Step 3: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 4: Commit** + +```bash +git add lib/cometd.js +git commit -m "refactor: extract _resubscribeAll() to deduplicate reconnect logic (R05)" +``` + +--- + +### Task 6: R06 — Remove Redundant `Promise.resolve()` Wrappers + +**Files:** +- Modify: `lib/auth.js` + +- [ ] **Step 1: Fix `_notifyAndResolve` (line 133)** + +Change: +```js +return Promise.resolve(newOauth); +``` +to: +```js +return newOauth; +``` + +- [ ] **Step 2: Fix `authenticate` (line 187)** + +Change: +```js +return Promise.resolve(newOauth); +``` +to: +```js +return newOauth; +``` + +- [ ] **Step 3: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 4: Commit** + +```bash +git add lib/auth.js +git commit -m "refactor: remove redundant Promise.resolve() wrappers in auth.js (R06)" +``` + +--- + +### Task 7: R11 — Inline `rec` Temp Variable in `createSObject` + +**Files:** +- Modify: `index.js:85-86` + +- [ ] **Step 1: Inline the variable** + +Replace: +```js +const rec = new Record(data); +return rec; +``` +with: +```js +return new Record(data); +``` + +- [ ] **Step 2: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 3: Commit** + +```bash +git add index.js +git commit -m "refactor: inline temp variable in createSObject (R11)" +``` + +--- + +### Task 8: R08 — Hoist Inline `require('events')` in Mock Server + +**Files:** +- Modify: `test/mock/cometd-server.js` + +- [ ] **Step 1: Add top-level import** + +Add after existing requires: +```js +const EventEmitter = require('events'); +``` + +- [ ] **Step 2: Replace inline require** + +Change line ~278: +```js +const emitter = new (require('events').EventEmitter)(); +``` +to: +```js +const emitter = new EventEmitter(); +``` + +- [ ] **Step 3: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 4: Commit** + +```bash +git add test/mock/cometd-server.js +git commit -m "refactor: hoist inline require to top-level import (R08)" +``` + +--- + +## Phase 3 — Design Improvements + +### Task 9: R12 — Named Constant for ID Field Variants + +**Files:** +- Modify: `lib/util.js` + +- [ ] **Step 1: Add constant and refactor loop** + +Add near the top of the file (after the `checkHeaderCaseInsensitive` function): +```js +const ID_FIELD_VARIANTS = ['Id', 'id', 'ID']; +``` + +In `findId`, replace: +```js +const flavors = ['Id', 'id', 'ID']; + +for (let flavor of flavors) { + if (data[flavor]) { + return data[flavor]; + } +} +``` +with: +```js +for (const variant of ID_FIELD_VARIANTS) { + if (data[variant] !== undefined) { + return data[variant]; + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 3: Commit** + +```bash +git add lib/util.js +git commit -m "refactor: extract ID_FIELD_VARIANTS constant in util.js (R12)" +``` + +--- + +### Task 10: R13 — Rename `checkHeaderCaseInsensitive` to `headerContains` + +**Files:** +- Modify: `lib/util.js` + +- [ ] **Step 1: Rename function and parameter** + +Change: +```js +const checkHeaderCaseInsensitive = (headers, key, searchfor) => { +``` +to: +```js +const headerContains = (headers, key, substring) => { +``` + +Update the `return` line: +```js +return headerContent ? headerContent.includes(searchfor) : false; +``` +to: +```js +return headerContent ? headerContent.includes(substring) : false; +``` + +Update the JSDoc `@param` tag for `searchfor` to `substring`. + +- [ ] **Step 2: Update caller in `isJsonResponse`** + +Change: +```js +checkHeaderCaseInsensitive(res.headers, 'content-type', 'application/json') +``` +to: +```js +headerContains(res.headers, 'content-type', 'application/json') +``` + +- [ ] **Step 3: Verify function is not exported** + +Run: `grep 'checkHeaderCaseInsensitive' lib/util.js` +Expected: No remaining occurrences + +- [ ] **Step 4: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 5: Commit** + +```bash +git add lib/util.js +git commit -m "refactor: rename checkHeaderCaseInsensitive to headerContains (R13)" +``` + +--- + +### Task 11: R15 — Rename `getFullUri` to `buildUrl` + +**Files:** +- Modify: `lib/optionhelper.js:87,98` +- Modify: `lib/http.js:158` + +- [ ] **Step 1: Rename in optionhelper.js** + +Change function name: +```js +function getFullUri(opts) { +``` +to: +```js +function buildUrl(opts) { +``` + +Update export: +```js +module.exports = { getApiRequestOptions, getFullUri }; +``` +to: +```js +module.exports = { getApiRequestOptions, buildUrl }; +``` + +- [ ] **Step 2: Update caller in http.js** + +Change: +```js +const uri = optionHelper.getFullUri(ropts); +``` +to: +```js +const uri = optionHelper.buildUrl(ropts); +``` + +- [ ] **Step 3: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 4: Commit** + +```bash +git add lib/optionhelper.js lib/http.js +git commit -m "refactor: rename getFullUri to buildUrl (R15)" +``` + +--- + +### Task 12: R10 — Decompose `getAuthUri` Conditionals + +**Files:** +- Modify: `lib/auth.js:67-115` + +- [ ] **Step 1: Refactor `getAuthUri`** + +Replace the body of the function (lines 68-114) with: + +```js +const getAuthUri = function (opts = {}) { + const urlOpts = { + response_type: opts.responseType || 'code', + client_id: this.clientId, + redirect_uri: this.redirectUri, + }; + + if (opts.display) urlOpts.display = opts.display.toLowerCase(); + if (opts.immediate !== undefined) urlOpts.immediate = opts.immediate; + if (opts.state !== undefined) urlOpts.state = opts.state; + if (opts.nonce !== undefined) urlOpts.nonce = opts.nonce; + if (opts.loginHint) urlOpts.login_hint = opts.loginHint; + + const spaceJoinFields = ['scope', 'prompt']; + for (const field of spaceJoinFields) { + if (opts[field] !== undefined) { + urlOpts[field] = Array.isArray(opts[field]) + ? opts[field].join(' ') + : opts[field]; + } + } + + if (opts.urlOpts) Object.assign(urlOpts, opts.urlOpts); + + return this._authEndpoint(opts) + '?' + new URLSearchParams(urlOpts).toString(); +}; +``` + +- [ ] **Step 2: Run tests** + +Run: `npm test` +Expected: All passing (existing getAuthUri tests in test/connection.js cover scope, display, state, etc.) + +- [ ] **Step 3: Commit** + +```bash +git add lib/auth.js +git commit -m "refactor: simplify getAuthUri conditional blocks (R10)" +``` + +--- + +### Task 13: R16 — Propagate Errors from `_connectLoop` Catch + +**Files:** +- Modify: `lib/cometd.js` + +- [ ] **Step 1: Update the catch block in `_connectLoop`** + +Change: +```js +} catch { + if (this._disconnecting) return; + this._connected = false; + this.emit('transport:down'); +``` +to: +```js +} catch (err) { + if (this._disconnecting) return; + this._connected = false; + this.emit('transport:down', err); +``` + +- [ ] **Step 2: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 3: Commit** + +```bash +git add lib/cometd.js +git commit -m "refactor: propagate error details to transport:down event (R16)" +``` + +--- + +### Task 14: R17 — Clean Up Dead Code in `test/integration.js` + +**Files:** +- Modify: `test/integration.js` + +- [ ] **Step 1: Clean up** + +1. Change `let client = undefined;` to `let client;` +2. Remove the commented-out `// Mocha.suite.skip();` line +3. Since `describe.skip` handles the false case, simplify the `before()` block: + +```js +before(() => { + const creds = checkEnvCredentials(); + client = nforce.createConnection(creds); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 3: Commit** + +```bash +git add test/integration.js +git commit -m "refactor: remove dead code in integration.js (R17)" +``` + +--- + +## Phase 4 — Architectural Improvements + +### Task 15: R09 — Modernize `onRefresh` to Accept Promises + +**Files:** +- Modify: `lib/auth.js:124-134` +- Test: `test/connection.js` (add async onRefresh test) + +- [ ] **Step 1: Write the failing test** + +Add to `test/connection.js` inside the `#_notifyAndResolve` describe block: + +```js +it('should accept a promise-returning onRefresh function', function () { + let refreshCalled = false; + let org = makeOrg({ + onRefresh: async function (newOauth, oldOauth) { + refreshCalled = true; + newOauth.access_token.should.equal('new_token'); + oldOauth.access_token.should.equal('old_token'); + } + }); + let newOauth = { access_token: 'new_token' }; + let oldOauth = { access_token: 'old_token' }; + return org._notifyAndResolve(newOauth, oldOauth).then((result) => { + refreshCalled.should.be.true(); + result.access_token.should.equal('new_token'); + }); +}); + +it('should reject when async onRefresh throws', function () { + let org = makeOrg({ + onRefresh: async function () { + throw new Error('async refresh failed'); + } + }); + return org._notifyAndResolve({ access_token: 'test' }, {}).then( + () => { throw new Error('should have rejected'); }, + (err) => { err.message.should.equal('async refresh failed'); } + ); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx mocha test/connection.js --grep "promise-returning"` +Expected: FAIL — async function with arity 0 gets no `cb` argument, calls it as `undefined()` + +- [ ] **Step 3: Update `_notifyAndResolve` in `lib/auth.js`** + +Replace the function with: + +```js +const _notifyAndResolve = function (newOauth, oldOauth) { + if (this.onRefresh) { + if (this.onRefresh.length >= 3) { + // Legacy callback path + return new Promise((resolve, reject) => { + this.onRefresh.call(this, newOauth, oldOauth, (err) => { + if (err) reject(err); + else resolve(newOauth); + }); + }); + } + // Modern path: onRefresh returns a value or Promise + return Promise.resolve(this.onRefresh.call(this, newOauth, oldOauth)) + .then(() => newOauth); + } + return newOauth; +}; +``` + +- [ ] **Step 4: Run tests** + +Run: `npm test` +Expected: All passing (including new async tests and existing callback tests) + +- [ ] **Step 5: Commit** + +```bash +git add lib/auth.js test/connection.js +git commit -m "feat: onRefresh now accepts async/promise-returning functions (R09)" +``` + +--- + +### Task 16: R18 — Convert Mock Server to Class-Based Instance + +**Files:** +- Modify: `test/mock/sfdc-rest-api.js` +- Modify: `test/crud.js` +- Modify: `test/query.js` +- Modify: `test/errors.js` (if it uses the mock) +- Modify: `test/plugin.js` (if it uses the mock) + +- [ ] **Step 1: Identify all files using the mock** + +Run: `grep -rl "sfdc-rest-api" test/` +Note which files import and use the mock module. + +- [ ] **Step 2: Rewrite `test/mock/sfdc-rest-api.js` as a class** + +```js +'use strict'; + +const http = require('http'); +const CONST = require('../../lib/constants'); +const apiVersion = CONST.API; + +class MockSfdcApi { + constructor() { + this._port = process.env.PORT || 33333; + this._serverStack = []; + this._requestStack = []; + this._defaultResponse = { + code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Status: 'OK' }) + }; + } + + reset() { + this._requestStack.length = 0; + } + + getLastRequest() { + return this._requestStack[0]; + } + + clearServerStack() { + const allPromises = []; + let curServer = this._serverStack.pop(); + while (curServer) { + allPromises.push(new Promise((resolve) => curServer.close(resolve))); + curServer = this._serverStack.pop(); + } + return Promise.all(allPromises); + } + + getServerInstance(serverListener) { + return new Promise((resolve, reject) => { + this.clearServerStack() + .then(() => { + let server = http.createServer(serverListener); + server.listen(this._port, (err) => { + if (err) { + reject(err); + } else { + this._serverStack.push(server); + resolve(server); + } + }); + }) + .catch(reject); + }); + } + + getGoodServerInstance(response) { + const resp = response || this._defaultResponse; + const self = this; + const serverListener = (req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + req.body = Buffer.concat(chunks).toString(); + self._requestStack.push(req); + const headers = Object.assign({ Connection: 'close' }, resp.headers); + res.writeHead(resp.code, headers); + if (resp.body) { + res.end(resp.body, 'utf8'); + } else { + res.end(); + } + }); + }; + return this.getServerInstance(serverListener); + } + + getClosedServerInstance() { + const serverListener = (req) => { + const fatError = new Error('ECONNRESET'); + fatError.type = 'system'; + fatError.errno = 'ECONNRESET'; + req.destroy(fatError); + }; + return this.getServerInstance(serverListener); + } + + getClient(opts) { + opts = opts || {}; + return { + clientId: 'ADFJSD234ADF765SFG55FD54S', + clientSecret: 'adsfkdsalfajdskfa', + redirectUri: 'http://localhost:' + this._port + '/oauth/_callback', + loginUri: 'http://localhost:' + this._port + '/login/uri', + apiVersion: opts.apiVersion || apiVersion, + mode: opts.mode || 'multi', + autoRefresh: opts.autoRefresh || false, + onRefresh: opts.onRefresh || undefined + }; + } + + getOAuth() { + return { + id: 'http://localhost:' + this._port + '/id/00Dd0000000fOlWEAU/005d00000014XTPAA2', + issued_at: '1362448234803', + instance_url: 'http://localhost:' + this._port, + signature: 'djaflkdjfdalkjfdalksjfalkfjlsdj', + access_token: 'aflkdsjfdlashfadhfladskfjlajfalskjfldsakjf' + }; + } + + start(incomingPort, cb) { + this._port = incomingPort; + this.getGoodServerInstance() + .then(() => cb()) + .catch((err) => { + console.error(err); + cb(err); + }); + } + + stop(cb) { + this.clearServerStack() + .catch(console.error) + .finally(() => cb()); + } +} + +module.exports = { MockSfdcApi }; +``` + +- [ ] **Step 3: Update all test files to use class instances** + +In each test file that uses `require('./mock/sfdc-rest-api')`, change from: +```js +const api = require('./mock/sfdc-rest-api'); +``` +to: +```js +const { MockSfdcApi } = require('./mock/sfdc-rest-api'); +const api = new MockSfdcApi(); +``` + +The rest of the code stays the same since the method names are identical. + +- [ ] **Step 4: Run tests after each file update** + +Run: `npm test` +Expected: All passing + +- [ ] **Step 5: Commit** + +```bash +git add test/mock/sfdc-rest-api.js test/crud.js test/query.js test/errors.js test/plugin.js +git commit -m "refactor: convert mock server to class-based instance (R18)" +``` + +--- + +## Final Verification + +- [ ] **Run full test suite**: `npm test` — all tests passing +- [ ] **Run linter**: `npx eslint .` — no errors +- [ ] **Review all commits**: `git log --oneline` — one commit per recommendation diff --git a/index.js b/index.js index bdfb8be..bbfc7a0 100644 --- a/index.js +++ b/index.js @@ -82,8 +82,7 @@ const createSObject = (type, fields) => { data.attributes = { type: type, }; - const rec = new Record(data); - return rec; + return new Record(data); }; const version = require('./package.json').version; diff --git a/lib/api.js b/lib/api.js index fe38b84..7cadf84 100644 --- a/lib/api.js +++ b/lib/api.js @@ -257,7 +257,7 @@ const upsert = function (data) { const extId =opts.sobject.getExternalId(); opts.resource = sobjectPath(type, extIdField, extId); opts.method = 'PATCH'; - opts.body = JSON.stringify(opts.sobject.toPayload()); + applyBody(opts, type, () => opts.sobject.toPayload()); return this._apiRequest(opts); }; diff --git a/lib/auth.js b/lib/auth.js index 40f6cdd..bcd3b4b 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -65,70 +65,52 @@ const _revokeEndpoint = function () { * @returns {string} The complete authorization URL with query parameters. */ const getAuthUri = function (opts = {}) { - let urlOpts = { + const urlOpts = { response_type: opts.responseType || 'code', client_id: this.clientId, redirect_uri: this.redirectUri, }; - if (opts.display) { - urlOpts.display = opts.display.toLowerCase(); - } - - if (opts.immediate) { - urlOpts.immediate = opts.immediate; - } - - if (opts.scope) { - if (Array.isArray(opts.scope)) { - urlOpts.scope = opts.scope.join(' '); - } else { - urlOpts.scope = opts.scope; + if (opts.display) urlOpts.display = opts.display.toLowerCase(); + if (opts.immediate !== undefined) urlOpts.immediate = opts.immediate; + if (opts.state !== undefined) urlOpts.state = opts.state; + if (opts.nonce !== undefined) urlOpts.nonce = opts.nonce; + if (opts.loginHint) urlOpts.login_hint = opts.loginHint; + + for (const field of ['scope', 'prompt']) { + if (opts[field] !== undefined) { + urlOpts[field] = Array.isArray(opts[field]) + ? opts[field].join(' ') + : opts[field]; } } - if (opts.state) { - urlOpts.state = opts.state; - } - - if (opts.nonce) { - urlOpts.nonce = opts.nonce; - } - - if (opts.prompt) { - if (Array.isArray(opts.prompt)) { - urlOpts.prompt = opts.prompt.join(' '); - } else { - urlOpts.prompt = opts.prompt; - } - } - - if (opts.loginHint) { - urlOpts.login_hint = opts.loginHint; - } - - if (opts.urlOpts) { - Object.assign(urlOpts, opts.urlOpts); - } + if (opts.urlOpts) Object.assign(urlOpts, opts.urlOpts); return this._authEndpoint(opts) + '?' + new URLSearchParams(urlOpts).toString(); }; /** - * Notify the onRefresh callback if configured, then resolve with the updated OAuth. - * Used after a token refresh operation. + * Notify the onRefresh handler if configured, then resolve with the updated OAuth. + * Supports both legacy callback-style (arity >= 3) and modern async/promise-returning handlers. * @param {object} newOauth - The newly obtained OAuth credentials. * @param {object} oldOauth - The previous OAuth credentials (passed to onRefresh). * @returns {Promise} Resolves with `newOauth`. */ const _notifyAndResolve = function (newOauth, oldOauth) { if (this.onRefresh) { - return new Promise((resolve, reject) => { - this.onRefresh.call(this, newOauth, oldOauth, (err) => { - if (err) reject(err); - else resolve(newOauth); + if (this.onRefresh.length >= 3) { + // Legacy callback path + return new Promise((resolve, reject) => { + this.onRefresh.call(this, newOauth, oldOauth, (err) => { + if (err) reject(err); + else resolve(newOauth); + }); }); - }); + } + // Modern path: onRefresh returns a value or Promise + return Promise.resolve(this.onRefresh.call(this, newOauth, oldOauth)) + .then(() => newOauth); } return Promise.resolve(newOauth); }; @@ -184,7 +166,7 @@ const authenticate = function (data) { return this._apiAuthRequest(opts).then((res) => { const newOauth = { ...opts.oauth, ...res }; if (opts.assertion) newOauth.assertion = opts.assertion; - return Promise.resolve(newOauth); + return newOauth; }); }; diff --git a/lib/cometd.js b/lib/cometd.js new file mode 100644 index 0000000..eef4405 --- /dev/null +++ b/lib/cometd.js @@ -0,0 +1,538 @@ +'use strict'; + +const EventEmitter = require('events'); + +const BAYEUX_VERSION = '1.0'; +const MINIMUM_VERSION = '1.0'; +const DEFAULT_TIMEOUT = 110000; // Salesforce default long-poll timeout +const DEFAULT_RETRY_INTERVAL = 1000; +const DEFAULT_WS_RESPONSE_TIMEOUT = 10000; +const DEFAULT_MAX_RETRY_INTERVAL = 30000; +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 10; + +/** + * Lightweight CometD/Bayeux client for Salesforce Streaming API. + * Supports long-polling (fetch) and WebSocket transports. + * @extends EventEmitter + */ +class CometDClient extends EventEmitter { + /** + * @param {string} endpoint - The CometD endpoint URL (e.g. https://instance.salesforce.com/cometd/58.0). + * @param {object} [opts] - Options: timeout, retry. + */ + constructor(endpoint, opts = {}) { + super(); + this._endpoint = endpoint; + this._timeout = opts.timeout || DEFAULT_TIMEOUT; + this._retryInterval = opts.retry || DEFAULT_RETRY_INTERVAL; + this._wsResponseTimeout = opts.wsResponseTimeout || DEFAULT_WS_RESPONSE_TIMEOUT; + this._maxRetryInterval = opts.maxRetryInterval || DEFAULT_MAX_RETRY_INTERVAL; + this._maxReconnectAttempts = opts.maxReconnectAttempts || DEFAULT_MAX_RECONNECT_ATTEMPTS; + this._reconnectAttempts = 0; + this._headers = { 'Content-Type': 'application/json' }; + this._extensions = []; + this._subscriptions = new Map(); // topic → callback + this._clientId = null; + this._messageId = 0; + this._advice = { reconnect: 'retry', interval: 0, timeout: this._timeout }; + this._connected = false; + this._disconnecting = false; + this._transport = null; // 'long-polling' or 'websocket' + this._ws = null; + this._connectTimer = null; + this._pendingConnectResolve = null; + this._wsMessageBuffer = []; // buffered responses for WebSocket + } + + /** + * Set an HTTP header sent with every request. + * @param {string} name - Header name. + * @param {string} value - Header value. + */ + setHeader(name, value) { + this._headers[name] = value; + } + + /** + * Add a Bayeux extension for message processing. + * @param {{incoming?: Function, outgoing?: Function}} extension + */ + addExtension(extension) { + this._extensions.push(extension); + } + + /** @returns {number} Next unique message ID. */ + _nextId() { + return String(++this._messageId); + } + + /** + * Run outgoing extensions on a message. + * @param {object} message - Bayeux message. + * @returns {Promise} Processed message. + */ + async _applyOutgoing(message) { + let msg = message; + for (const ext of this._extensions) { + if (ext.outgoing) { + msg = await new Promise((resolve) => ext.outgoing(msg, resolve)); + } + } + return msg; + } + + /** + * Run incoming extensions on a message. + * @param {object} message - Bayeux message. + * @returns {Promise} Processed message. + */ + async _applyIncoming(message) { + let msg = message; + for (const ext of this._extensions) { + if (ext.incoming) { + msg = await new Promise((resolve) => ext.incoming(msg, resolve)); + } + } + return msg; + } + + /** + * Send a Bayeux message via the active transport. + * @param {object|object[]} messages - One or more Bayeux messages. + * @returns {Promise} Response messages. + */ + async _send(messages) { + const msgs = Array.isArray(messages) ? messages : [messages]; + const processed = []; + for (const m of msgs) { + processed.push(await this._applyOutgoing(m)); + } + + if ( + this._transport === 'websocket' && + this._ws && + this._ws.readyState === WebSocket.OPEN + ) { + return this._sendWs(processed); + } + return this._sendHttp(processed); + } + + /** + * Send messages via HTTP long-polling (fetch POST). + * @param {object[]} messages + * @returns {Promise} + */ + async _sendHttp(messages) { + const res = await fetch(this._endpoint, { + method: 'POST', + headers: this._headers, + body: JSON.stringify(messages), + }); + if (!res.ok) { + throw new Error('CometD HTTP error: ' + res.status); + } + const responses = await res.json(); + const incoming = []; + for (const r of responses) { + incoming.push(await this._applyIncoming(r)); + } + return incoming; + } + + /** + * Send messages via WebSocket. + * @param {object[]} messages + * @returns {Promise} + */ + _sendWs(messages) { + return new Promise((resolve, reject) => { + const expectedId = messages[0].id; + const isConnect = messages[0].channel === '/meta/connect'; + + // For /meta/connect, the response is deferred (long-poll style) + if (isConnect) { + this._pendingConnectResolve = resolve; + } + + this._ws.send(JSON.stringify(messages)); + + if (!isConnect) { + // Non-connect messages get immediate responses + const handler = async (event) => { + const data = JSON.parse(event.data); + const responses = Array.isArray(data) ? data : [data]; + const matching = responses.filter( + (r) => r.id === expectedId || !r.id, + ); + if (matching.length > 0) { + this._ws.removeEventListener('message', handler); + const incoming = []; + for (const r of responses) { + incoming.push(await this._applyIncoming(r)); + } + resolve(incoming); + } + }; + this._ws.addEventListener('message', handler); + + // Timeout safety + setTimeout(() => { + this._ws.removeEventListener('message', handler); + reject(new Error('CometD WebSocket response timeout')); + }, this._wsResponseTimeout); + } + }); + } + + /** + * Perform the Bayeux handshake to obtain a clientId and negotiate transport. + * @returns {Promise} + */ + async handshake() { + // Always handshake over HTTP + const msg = { + channel: '/meta/handshake', + version: BAYEUX_VERSION, + minimumVersion: MINIMUM_VERSION, + supportedConnectionTypes: ['long-polling', 'websocket'], + id: this._nextId(), + }; + + const responses = await this._sendHttp([await this._applyOutgoing(msg)]); + const response = responses.find((r) => r.channel === '/meta/handshake'); + + if (!response || !response.successful) { + const errMsg = response ? response.error : 'No handshake response'; + throw new Error('CometD handshake failed: ' + errMsg); + } + + this._clientId = response.clientId; + + if (response.advice) { + Object.assign(this._advice, response.advice); + } + + // Negotiate transport: prefer websocket if server supports it + const serverTypes = response.supportedConnectionTypes || ['long-polling']; + if (serverTypes.includes('websocket')) { + this._transport = 'websocket'; + } else { + this._transport = 'long-polling'; + } + } + + /** + * Establish WebSocket connection to the CometD endpoint. + * @returns {Promise} + */ + _connectWebSocket() { + return new Promise((resolve) => { + const wsUrl = this._endpoint.replace(/^http/, 'ws'); + this._ws = new WebSocket(wsUrl, ['cometd'], { + headers: this._headers, + }); + + this._ws.addEventListener('open', () => { + resolve(); + }); + + this._ws.addEventListener('close', () => { + if (!this._disconnecting) { + this._connected = false; + this.emit('transport:down'); + this._scheduleReconnect(); + } + }); + + this._ws.addEventListener('error', () => { + if (!this._connected) { + // Failed to connect — fall back to long-polling + this._transport = 'long-polling'; + this._ws = null; + resolve(); + } + }); + + this._ws.addEventListener('message', async (event) => { + const data = JSON.parse(event.data); + const messages = Array.isArray(data) ? data : [data]; + + for (const msg of messages) { + const processed = await this._applyIncoming(msg); + this._handleMessage(processed); + } + }); + }); + } + + /** + * Handle an incoming Bayeux message — dispatch events or resolve pending connect. + * @param {object} msg - Processed Bayeux message. + */ + _handleMessage(msg) { + if (msg.channel === '/meta/connect') { + if (msg.advice) { + Object.assign(this._advice, msg.advice); + } + if (this._pendingConnectResolve) { + const resolve = this._pendingConnectResolve; + this._pendingConnectResolve = null; + resolve([msg]); + } + return; + } + + // Data messages — dispatch to subscription callback + if (msg.data !== undefined && msg.channel) { + const callback = this._subscriptions.get(msg.channel); + if (callback && typeof callback === 'function') { + callback(msg.data); + } + } + } + + /** + * Start the CometD connect loop (long-polling or WebSocket). + * Must call handshake() first. + * @returns {Promise} + */ + async connect() { + if (this._transport === 'websocket') { + try { + await this._connectWebSocket(); + } catch { + // Fall back to long-polling + this._transport = 'long-polling'; + this._ws = null; + } + } + + this._connected = true; + this._reconnectAttempts = 0; + this.emit('transport:up'); + this._connectLoop(); + } + + /** + * The persistent connect loop — sends /meta/connect and waits for response. + */ + async _connectLoop() { + while (this._connected && !this._disconnecting) { + try { + const msg = { + channel: '/meta/connect', + clientId: this._clientId, + connectionType: this._transport, + id: this._nextId(), + }; + + const responses = await this._send(msg); + const response = responses.find((r) => r.channel === '/meta/connect'); + + if (response) { + if (response.advice) { + Object.assign(this._advice, response.advice); + } + + if (!response.successful) { + if (this._advice.reconnect === 'handshake') { + await this._rehandshake(); + continue; + } + if (this._advice.reconnect === 'none') { + this._connected = false; + this.emit('transport:down'); + return; + } + } + + // Dispatch any data messages piggybacked on the connect response + for (const r of responses) { + if (r.data !== undefined && r.channel) { + const callback = this._subscriptions.get(r.channel); + if (callback) { + callback(r.data); + } + } + } + } + + // Apply advice interval before next connect + if (this._advice.interval > 0) { + await this._delay(this._advice.interval); + } + } catch (err) { + if (this._disconnecting) return; + this._connected = false; + this.emit('transport:down', err); + this._scheduleReconnect(); + return; + } + } + } + + /** + * Re-handshake after server requests it, then resume connect loop. + */ + async _rehandshake() { + try { + await this.handshake(); + await this._resubscribeAll(); + } catch { + this._connected = false; + this.emit('transport:down'); + this._scheduleReconnect(); + } + } + + /** + * Schedule a reconnection attempt with exponential backoff. + */ + _scheduleReconnect() { + if (this._disconnecting) return; + + if (this._reconnectAttempts >= this._maxReconnectAttempts) { + if (this._connectTimer) { + clearTimeout(this._connectTimer); + this._connectTimer = null; + } + this.emit('error', new Error('CometD max reconnect attempts reached')); + return; + } + + const delay = Math.min( + this._retryInterval * Math.pow(2, this._reconnectAttempts), + this._maxRetryInterval, + ); + this._reconnectAttempts++; + + this._connectTimer = setTimeout(async () => { + try { + await this.handshake(); + this._reconnectAttempts = 0; + await this._resubscribeAll(); + await this.connect(); + } catch { + this._scheduleReconnect(); + } + }, delay); + } + + /** + * Re-subscribe all active topics after a handshake. + */ + async _resubscribeAll() { + for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); + } + } + + /** + * Send a /meta/subscribe message for a topic. + * @param {string} topic + * @returns {Promise} Subscribe response. + */ + async _sendSubscribe(topic) { + const msg = { + channel: '/meta/subscribe', + clientId: this._clientId, + subscription: topic, + id: this._nextId(), + }; + + const responses = await this._send(msg); + return responses.find((r) => r.channel === '/meta/subscribe'); + } + + /** + * Subscribe to a CometD channel. + * @param {string} topic - Channel path (e.g. '/topic/MyPushTopic'). + * @param {Function} callback - Called with event data for each message. + * @returns {Promise<{successful: boolean, cancel: Function}>} + */ + async subscribe(topic, callback) { + this._subscriptions.set(topic, callback); + const response = await this._sendSubscribe(topic); + + if (!response || !response.successful) { + this._subscriptions.delete(topic); + const errMsg = response ? response.error : 'No subscribe response'; + throw new Error('CometD subscribe failed: ' + errMsg); + } + + return { + successful: true, + cancel: () => this.unsubscribe(topic), + }; + } + + /** + * Unsubscribe from a CometD channel. + * @param {string} topic - Channel path. + * @returns {Promise} + */ + async unsubscribe(topic) { + this._subscriptions.delete(topic); + + if (!this._connected || this._disconnecting) return; + + const msg = { + channel: '/meta/unsubscribe', + clientId: this._clientId, + subscription: topic, + id: this._nextId(), + }; + + await this._send(msg); + } + + /** + * Disconnect from the CometD server and clean up resources. + * @returns {Promise} + */ + async disconnect() { + this._disconnecting = true; + this._connected = false; + + if (this._connectTimer) { + clearTimeout(this._connectTimer); + this._connectTimer = null; + } + + if (this._clientId) { + try { + const msg = { + channel: '/meta/disconnect', + clientId: this._clientId, + id: this._nextId(), + }; + + if (this._transport === 'long-polling') { + await this._sendHttp([await this._applyOutgoing(msg)]); + } + } catch { + // Best-effort disconnect + } + } + + if (this._ws) { + this._ws.close(); + this._ws = null; + } + + this._clientId = null; + this._subscriptions.clear(); + } + + /** + * Delay for a specified number of milliseconds. + * @param {number} ms + * @returns {Promise} + */ + _delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +module.exports = CometDClient; diff --git a/lib/fdcstream.js b/lib/fdcstream.js index 30e6577..c1557c7 100644 --- a/lib/fdcstream.js +++ b/lib/fdcstream.js @@ -1,7 +1,7 @@ 'use strict'; const EventEmitter = require('events'); -const faye = require('faye'); +const CometDClient = require('./cometd'); /** * A Streaming API subscription that emits 'data', 'connect', and 'error' events. @@ -17,24 +17,27 @@ class Subscription extends EventEmitter { this.client = client; opts = opts || {}; - // Our version requires a full topic this._topic = opts.topic; if (opts.replayId) { this.client.addReplayId(this._topic, opts.replayId); } - this._sub = client._fayeClient.subscribe(this._topic, (d) => { - this.emit('data', d); - }); + // Subscribe asynchronously and emit events + this._initSubscription(); + } - this._sub.callback(() => { + async _initSubscription() { + try { + // Wait for the Client to finish handshake+connect + await this.client._ready; + this._sub = await this.client._cometd.subscribe(this._topic, (d) => { + this.emit('data', d); + }); this.emit('connect'); - }); - - this._sub.errback((err) => { + } catch (err) { this.emit('error', err); - }); + } } /** Cancel this subscription and stop receiving events. */ @@ -46,7 +49,7 @@ class Subscription extends EventEmitter { } /** - * Faye-based Streaming API client with replay support. + * CometD-based Streaming API client with replay support. * Emits 'connect' and 'disconnect' events for transport state changes. * @extends EventEmitter */ @@ -61,20 +64,20 @@ class Client extends EventEmitter { this._endpoint = opts.oauth.instance_url + '/cometd/' + opts.apiVersion.substring(1); - this._fayeClient = new faye.Client(this._endpoint, { + this._cometd = new CometDClient(this._endpoint, { timeout: opts.timeout, retry: opts.retry }); - this._fayeClient.setHeader( + this._cometd.setHeader( 'Authorization', 'Bearer ' + opts.oauth.access_token ); - this._fayeClient.on('transport:up', () => { + this._cometd.on('transport:up', () => { this.emit('connect'); }); - this._fayeClient.on('transport:down', () => { + this._cometd.on('transport:down', () => { this.emit('disconnect'); }); @@ -92,7 +95,15 @@ class Client extends EventEmitter { } }; - this._fayeClient.addExtension(replayExtension); + this._cometd.addExtension(replayExtension); + + // Auto-handshake and connect + this._ready = this._init(); + } + + async _init() { + await this._cometd.handshake(); + await this._cometd.connect(); } /** @@ -105,9 +116,9 @@ class Client extends EventEmitter { return new Subscription(opts, this); } - /** Disconnect the Faye client and close the CometD connection. */ - disconnect(/*opts*/) { - this._fayeClient.disconnect(); + /** Disconnect and close the CometD connection. */ + disconnect() { + return this._cometd.disconnect(); } /** diff --git a/lib/http.js b/lib/http.js index 1271c0f..4ea1f20 100644 --- a/lib/http.js +++ b/lib/http.js @@ -155,7 +155,7 @@ const _apiRequest = function (opts) { ropts.signal = buildSignal(ropts.signal, this.timeout); - const uri = optionHelper.getFullUri(ropts); + const uri = optionHelper.buildUrl(ropts); const sobject = opts.sobject; return fetch(uri, ropts) diff --git a/lib/optionhelper.js b/lib/optionhelper.js index 017b5ec..68ade79 100644 --- a/lib/optionhelper.js +++ b/lib/optionhelper.js @@ -84,7 +84,7 @@ function getApiRequestOptions(opts) { * @param {Object.} [opts.qs] - Key/value pairs to append as query parameters. * @returns {URL} The constructed URL with query parameters applied. */ -function getFullUri(opts) { +function buildUrl(opts) { let result = new URL(opts.uri); if (opts.qs) { let params = opts.qs; @@ -95,4 +95,4 @@ function getFullUri(opts) { return result; } -module.exports = { getApiRequestOptions, getFullUri }; +module.exports = { getApiRequestOptions, buildUrl }; diff --git a/lib/util.js b/lib/util.js index 29e9c1e..03fe12a 100644 --- a/lib/util.js +++ b/lib/util.js @@ -5,10 +5,10 @@ * Works with both Fetch API Headers objects and plain objects. * @param {Headers|object} headers - Headers collection. * @param {string} key - Header name to look up. - * @param {string} searchfor - Substring to search for in the header value. + * @param {string} substring - Substring to search for in the header value. * @returns {boolean} True if the header contains the substring. */ -const checkHeaderCaseInsensitive = (headers, key, searchfor) => { +const headerContains = (headers, key, substring) => { if (!headers) return false; const lower = key.toLowerCase(); let headerContent; @@ -18,7 +18,7 @@ const checkHeaderCaseInsensitive = (headers, key, searchfor) => { const k = Object.keys(headers).find((x) => x.toLowerCase() === lower); headerContent = k ? headers[k] : undefined; } - return headerContent ? headerContent.includes(searchfor) : false; + return headerContent ? headerContent.includes(substring) : false; }; /** @@ -29,7 +29,7 @@ const checkHeaderCaseInsensitive = (headers, key, searchfor) => { const isJsonResponse = (res) => { return ( res.headers && - checkHeaderCaseInsensitive(res.headers, 'content-type', 'application/json') + headerContains(res.headers, 'content-type', 'application/json') ); }; @@ -43,6 +43,8 @@ const isNumber = (candidate) => typeof candidate === 'number'; const isObject = (candidate) => candidate !== null && typeof candidate === 'object'; +const ID_FIELD_VARIANTS = ['Id', 'id', 'ID']; + /** * Extract a Salesforce record ID from various sources. * Handles getId() methods and Id/id/ID property variants. @@ -55,11 +57,9 @@ const findId = (data) => { return data.getId(); } - const flavors = ['Id', 'id', 'ID']; - - for (let flavor of flavors) { - if (data[flavor]) { - return data[flavor]; + for (const variant of ID_FIELD_VARIANTS) { + if (data[variant] !== undefined) { + return data[variant]; } } } diff --git a/package-lock.json b/package-lock.json index 3b94de4..b699694 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "nforce8", - "version": "3.1.1", + "version": "4.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nforce8", - "version": "3.1.1", + "version": "4.0.0", "license": "MIT", "dependencies": { - "faye": "^1.4.1", "mime-types": "^3.0.2" }, "devDependencies": { @@ -921,7 +920,8 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true }, "node_modules/asn1": { "version": "0.2.6", @@ -1470,17 +1470,6 @@ "node": ">= 8" } }, - "node_modules/csprng": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/csprng/-/csprng-0.1.2.tgz", - "integrity": "sha512-D3WAbvvgUVIqSxUfdvLeGjuotsB32bvfVPd+AaaTWMtyUeC9zgCnw5xs94no89yFLVsafvY9dMZEhTwsY/ZecA==", - "dependencies": { - "sequin": "*" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1993,34 +1982,6 @@ "dev": true, "license": "MIT" }, - "node_modules/faye": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/faye/-/faye-1.4.1.tgz", - "integrity": "sha512-Cg/khikhqlvumHO3efwx2tps2ZgQRjUMrO24G0quz7MMzRYYaEjU224YFXOeuPIvanRegIchVxj6pmHK1W0ikA==", - "license": "Apache-2.0", - "dependencies": { - "asap": "*", - "csprng": "*", - "faye-websocket": ">=0.9.1", - "safe-buffer": "*", - "tough-cookie": "*", - "tunnel-agent": "*" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2499,11 +2460,6 @@ "node": ">= 0.8" } }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==" - }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -3808,7 +3764,8 @@ "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true }, "node_modules/pug": { "version": "3.0.4", @@ -3943,15 +3900,11 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.2.0.tgz", "integrity": "sha512-LN6QV1IJ9ZhxWTNdktaPClrNfp8xdSAYS0Zk2ddX7XsXZAxckMHPCBcHRo0cTcEIgYPRiGEkmji3Idkh2yFtYw==", + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -4137,11 +4090,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -4354,6 +4302,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -4385,14 +4334,6 @@ "semver": "bin/semver.js" } }, - "node_modules/sequin": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/sequin/-/sequin-0.1.1.tgz", - "integrity": "sha512-hJWMZRwP75ocoBM+1/YaCsvS0j5MTPeBHJkS2/wruehl9xwtX30HlDF1Gt6UZ8HHHY8SJa2/IL+jo+JJCd59rA==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -4883,24 +4824,11 @@ "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", "dev": true }, - "node_modules/tough-cookie": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", - "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -4945,14 +4873,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5002,15 +4922,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -5045,27 +4956,6 @@ "node": ">=0.10.0" } }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5948,7 +5838,8 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true }, "asn1": { "version": "0.2.6", @@ -6337,14 +6228,6 @@ "which": "^2.0.1" } }, - "csprng": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/csprng/-/csprng-0.1.2.tgz", - "integrity": "sha512-D3WAbvvgUVIqSxUfdvLeGjuotsB32bvfVPd+AaaTWMtyUeC9zgCnw5xs94no89yFLVsafvY9dMZEhTwsY/ZecA==", - "requires": { - "sequin": "*" - } - }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -6692,27 +6575,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "faye": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/faye/-/faye-1.4.1.tgz", - "integrity": "sha512-Cg/khikhqlvumHO3efwx2tps2ZgQRjUMrO24G0quz7MMzRYYaEjU224YFXOeuPIvanRegIchVxj6pmHK1W0ikA==", - "requires": { - "asap": "*", - "csprng": "*", - "faye-websocket": ">=0.9.1", - "safe-buffer": "*", - "tough-cookie": "*", - "tunnel-agent": "*" - } - }, - "faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "requires": { - "websocket-driver": ">=0.5.1" - } - }, "file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7032,11 +6894,6 @@ "toidentifier": "1.0.1" } }, - "http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==" - }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -7989,7 +7846,8 @@ "psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true }, "pug": { "version": "3.0.4", @@ -8118,12 +7976,8 @@ "punycode": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.2.0.tgz", - "integrity": "sha512-LN6QV1IJ9ZhxWTNdktaPClrNfp8xdSAYS0Zk2ddX7XsXZAxckMHPCBcHRo0cTcEIgYPRiGEkmji3Idkh2yFtYw==" - }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + "integrity": "sha512-LN6QV1IJ9ZhxWTNdktaPClrNfp8xdSAYS0Zk2ddX7XsXZAxckMHPCBcHRo0cTcEIgYPRiGEkmji3Idkh2yFtYw==", + "dev": true }, "randombytes": { "version": "2.1.0", @@ -8261,11 +8115,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -8408,7 +8257,8 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -8422,11 +8272,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, - "sequin": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/sequin/-/sequin-0.1.1.tgz", - "integrity": "sha512-hJWMZRwP75ocoBM+1/YaCsvS0j5MTPeBHJkS2/wruehl9xwtX30HlDF1Gt6UZ8HHHY8SJa2/IL+jo+JJCd59rA==" - }, "serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -8782,21 +8627,11 @@ "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", "dev": true }, - "tough-cookie": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", - "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", - "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - } - }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -8831,11 +8666,6 @@ "is-typedarray": "^1.0.0" } }, - "universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==" - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8861,15 +8691,6 @@ "punycode": "^2.1.0" } }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -8893,21 +8714,6 @@ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", "dev": true }, - "websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "requires": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - } - }, - "websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9be3e28..1c7892b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nforce8", "description": "Forked from nforce by Kevin O'Hara (http://kevinmohara.com) for use in NodeRED", - "version": "3.1.1", + "version": "4.0.0", "author": "Stephan H. Wissel (https://wissel.net)", "contributors": [ { @@ -38,7 +38,6 @@ }, "main": "index.js", "dependencies": { - "faye": "^1.4.1", "mime-types": "^3.0.2" }, "devDependencies": { @@ -56,7 +55,7 @@ "should": "^13.2.3" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.4.0" }, "bugs": { "url": "http://github.com/stwissel/nforce8/issues" diff --git a/refactoring-expert-data.json b/refactoring-expert-data.json deleted file mode 100644 index a80e968..0000000 --- a/refactoring-expert-data.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "total_recommendations": 22, - "priority_matrix": [ - {"item": "R01: Remove dead opts._refreshResult write in lib/http.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R02: Remove commented-out credential block in test/integration.js", "impact": "L", "complexity": "L", "risk": "L"}, - {"item": "R03: Fix fallacious #getUrl test description in test/record.js", "impact": "L", "complexity": "L", "risk": "L"}, - {"item": "R04: Eliminate duplicate package.json read in index.js — use CONST.API", "impact": "L", "complexity": "L", "risk": "L"}, - {"item": "R05: Add err.type = 'empty-response' to emptyResponse() in lib/errors.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R06: Extract buildSignal() helper to remove duplicated AbortSignal/timeout setup in lib/http.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R07: Extract applyBody() to unify duplicated multipart/JSON body logic in lib/api.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R08: Extract resolveEndpoint() to unify three environment-conditional endpoint functions in lib/auth.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R09: Inline _resolveOAuth — replace trivial wrapper with Promise.resolve() directly", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R10: Add fail-fast guard for single-mode missing OAuth in _getOpts", "impact": "H", "complexity": "L", "risk": "L"}, - {"item": "R11: Extract makeOrg() test helper to eliminate repeated connection boilerplate in test/connection.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R12: Replace magic strings 'sandbox' and 'single' with named constants from constants.js", "impact": "M", "complexity": "L", "risk": "L"}, - {"item": "R13: Rename _getOpts parameter 'd' to 'input' for clarity", "impact": "L", "complexity": "L", "risk": "L"}, - {"item": "R14: Rename API method getBody to getBinaryContent to resolve name collision with Record.getBody", "impact": "M", "complexity": "M", "risk": "M"}, - {"item": "R15: Apply eslint --fix to correct spacing inconsistencies in lib/api.js", "impact": "L", "complexity": "L", "risk": "L"}, - {"item": "R17: Move multipart form-building into Record.toMultipartForm — restore Information Expert principle", "impact": "H", "complexity": "M", "risk": "M"}, - {"item": "R18: Separate private helpers from module.exports in auth.js, api.js, http.js", "impact": "H", "complexity": "H", "risk": "H"}, - {"item": "R19: Sub-divide lib/api.js into domain modules: crud, query, metadata, blob, url, apexrest, streaming", "impact": "H", "complexity": "H", "risk": "H"}, - {"item": "R20: Introduce typed request value objects to replace the pervasive opts bag pattern", "impact": "H", "complexity": "H", "risk": "H"}, - {"item": "R21: Separate retry state from opts bag — pass _retryCount as explicit parameter to _apiRequest", "impact": "M", "complexity": "M", "risk": "M"}, - {"item": "R22: Standardize on ES6 class syntax for Connection and Record", "impact": "M", "complexity": "H", "risk": "M"}, - {"item": "R16: (See R15) Apply ESLint fix — consolidated into R15", "impact": "L", "complexity": "L", "risk": "L"} - ], - "risk_distribution": { - "low": 15, - "medium": 5, - "high": 3 - }, - "category_distribution": { - "Composing Methods": 6, - "Moving Features": 2, - "Organizing Data": 3, - "Simplifying Conditionals": 2, - "Simplifying Method Calls": 6, - "Dealing with Generalization": 1, - "Dead Code Removal": 2 - }, - "implementation_sequence": [ - {"order": 1, "item": "R15: Apply eslint --fix for spacing inconsistencies", "rationale": "Zero-risk tooling fix; normalizes code before any manual edits begin"}, - {"order": 2, "item": "R01: Remove dead opts._refreshResult write", "rationale": "Pure deletion of a confirmed dead write; no functional change, no dependencies"}, - {"order": 3, "item": "R02: Remove commented-out credential block in test/integration.js", "rationale": "Pure deletion of stale dead code; zero risk"}, - {"order": 4, "item": "R03: Fix fallacious #getUrl test description", "rationale": "One-word string change in a test file; zero risk"}, - {"order": 5, "item": "R04: Inline CONST.API in index.js", "rationale": "Single-line substitution; CONST is already imported, value is identical"}, - {"order": 6, "item": "R05: Add err.type = 'empty-response' to emptyResponse()", "rationale": "Additive change only; no existing callers break, new capability added"}, - {"order": 7, "item": "R06: Extract buildSignal() helper in lib/http.js", "rationale": "Pure Extract Method on duplicate code; behaviour identical, tests unchanged"}, - {"order": 8, "item": "R13: Rename _getOpts parameter 'd' to 'input'", "rationale": "Internal rename; no external callers affected; trivial with search-replace"}, - {"order": 9, "item": "R11: Extract makeOrg() test helper in test/connection.js", "rationale": "Test-only change; reduces boilerplate before further test modifications in Phase 2"}, - {"order": 10, "item": "R08: Extract resolveEndpoint() in lib/auth.js", "rationale": "Extract Method on pure module-private logic; prerequisite for R12 to be consistent"}, - {"order": 11, "item": "R12: Add SANDBOX/SINGLE_MODE named constants and replace magic strings", "rationale": "Depends on R08 being in place so all endpoint functions are already simplified; additive to constants.js"}, - {"order": 12, "item": "R09: Inline _resolveOAuth", "rationale": "Removes one exported private symbol; update one test; independent of R07 and R08"}, - {"order": 13, "item": "R07: Extract applyBody() in lib/api.js", "rationale": "Prerequisite for R17 (multipart move into Record); prepares the boundary where multipart logic lives"}, - {"order": 14, "item": "R10: Add fail-fast guard for single-mode OAuth in _getOpts", "rationale": "High developer experience impact; safe additive change; implement after module is stable from R07/R08"}, - {"order": 15, "item": "R14: Rename getBody to getBinaryContent", "rationale": "Public API change; implement in Phase 2 so a deprecation shim can be included before Phase 3 module splits"}, - {"order": 16, "item": "R21: Separate retry state from opts bag in _apiRequest", "rationale": "Must precede R20 (typed objects); removes opts._retryCount mutation which is the simplest first step toward immutable request objects"}, - {"order": 17, "item": "R17: Move toMultipartForm into Record", "rationale": "Depends on R07 (applyBody) being in place; establishes correct ownership before api.js is split in R19"}, - {"order": 18, "item": "R19: Sub-divide lib/api.js into domain modules", "rationale": "Depends on R17 (multipart in Record) and R07 (applyBody); extract streaming first as most isolated, then query, blob, crud, metadata"}, - {"order": 19, "item": "R18: Separate private helpers from module.exports", "rationale": "Must follow R19 so the final module boundaries are stable before restructuring exports; highest-risk change"}, - {"order": 20, "item": "R20: Introduce typed request value objects", "rationale": "Incremental; begins with RetryContext (already done via R21); full typed objects follow after module structure stabilised by R18/R19"}, - {"order": 21, "item": "R22: Standardize on ES6 class syntax for Connection and Record", "rationale": "Purely cosmetic; implement last so it does not create merge conflicts with structural changes in R18-R20"}, - {"order": 22, "item": "R16: (See R15) ESLint fix — consolidated", "rationale": "Already completed as R15 at sequence position 1"} - ] -} diff --git a/tasks/code-refactoring-report.md b/tasks/code-refactoring-report.md new file mode 100644 index 0000000..ec24170 --- /dev/null +++ b/tasks/code-refactoring-report.md @@ -0,0 +1,934 @@ +# Code Refactoring Report — nforce8 + +**Project**: nforce8 — Node.js REST API wrapper for Salesforce +**Analysis Date**: 2026-03-30 +**Based On**: code-smell-detector-report.md (30 issues: 3 High, 11 Medium, 16 Low) +**Refactoring Techniques Applied**: From the complete Fowler / refactoring.guru catalog + +--- + +## Executive Summary + +The nforce8 codebase receives a **B-grade** overall and is in genuinely good health. No god-objects, no deep inheritance chains, no callback hell in production code. The refactoring work falls into three clear buckets: + +1. **Bug-Fix Refactorings (3)** — Issues that can cause silent test failures or incorrect runtime behaviour. Fix these first, unconditionally. +2. **Design Refactorings (11)** — Smell-driven improvements that reduce future maintenance cost without changing the public API. +3. **Style / Hygiene Refactorings (16)** — Mechanical, low-risk changes that improve readability and lint compliance. + +Total recommendations: **18** (some smells are resolved by the same refactoring; style items are grouped). + +--- + +## Refactoring Recommendations + +--- + +### R01 — Add Missing `require('crypto')` Import + +| Attribute | Value | +|-----------|-------| +| File | `test/mock/cometd-server.js` lines 201–206 | +| Smell | Hidden Dependency / Latent Bug (High) | +| Technique | **Introduce Foreign Method** (repair the missing module import) | +| Priority | **Critical** | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +`cometd-server.js` calls `crypto.createHash('sha1')` but never imports the `crypto` module. In Node.js >= 22 the `globalThis.crypto` Web Crypto API exists, but `globalThis.crypto.createHash` does not. Any test path that triggers a WebSocket upgrade will throw `TypeError: crypto.createHash is not a function`. + +**Before** + +```js +// test/mock/cometd-server.js — no crypto import at top +const http = require('http'); +... +const acceptKey = crypto.createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-5AB5DC65C97B') + .digest('base64'); +``` + +**After** + +```js +const http = require('http'); +const crypto = require('crypto'); +... +const acceptKey = crypto.createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-5AB5DC65C97B') + .digest('base64'); +``` + +**Steps** + +1. Open `test/mock/cometd-server.js`. +2. Add `const crypto = require('crypto');` after the existing `require('http')` line. +3. Run the full test suite (`npm test`) to confirm no regressions. + +--- + +### R02 — Fix Silent Error Swallowing in Test Promise Chains + +| Attribute | Value | +|-----------|-------| +| Files | `test/crud.js` (4 occurrences), `test/query.js` (9 occurrences) | +| Smell | Afraid to Fail (High — Test Smell) | +| Technique | **Substitute Algorithm** (replace flawed pattern with correct Mocha idiom) | +| Priority | **Critical** | +| Complexity | Simple | +| Risk | Low — test-only change | + +**Problem** + +Thirteen tests use this pattern: + +```js +.catch((err) => should.not.exist(err)) +.finally(() => done()); +``` + +The intent is to fail the test on unexpected errors, but the pattern is broken. When `should.not.exist(err)` throws an assertion error, the `.finally()` block calls `done()` unconditionally, which Mocha interprets as a passing test. Tests that should fail can silently pass. + +**Before** (representative example from `test/crud.js`) + +```js +it('should create a proper request on insert', (done) => { + org.insert({ sobject: obj, oauth: oauth }) + .then((res) => { + should.exist(res); + api.getLastRequest().url.should.equal('/services/data/...'); + }) + .catch((err) => { + should.not.exist(err); // assertion error here is swallowed + }) + .finally(() => done()); // done() called regardless +}); +``` + +**After** — Option A (preferred for Mocha 6+): return the promise, remove the callback + +```js +it('should create a proper request on insert', () => { + return org.insert({ sobject: obj, oauth: oauth }) + .then((res) => { + should.exist(res); + api.getLastRequest().url.should.equal('/services/data/...'); + }); + // Mocha handles promise rejections automatically — no .catch needed +}); +``` + +**After** — Option B: pass error to `done()` when using callback style + +```js +it('should create a proper request on insert', (done) => { + org.insert({ sobject: obj, oauth: oauth }) + .then((res) => { + should.exist(res); + api.getLastRequest().url.should.equal('/services/data/...'); + done(); + }) + .catch(done); // passes the error to Mocha, which marks the test as failed +}); +``` + +**Steps** + +1. In each of the 13 affected tests, choose Option A (return the promise) where the test uses no other side effects requiring explicit teardown. +2. Where explicit teardown is needed, choose Option B and replace `.finally(() => done())` with `.catch(done)` and add an explicit `done()` call at the end of the `.then()` block. +3. Run `npm test` — the test count should be unchanged, and any newly surfaced failures represent real bugs previously masked. + +**Sequencing Note**: Run this refactoring after R03 to ensure any bugs it reveals in production code are distinguishable. + +--- + +### R03 — Fix `upsert()` to Use `applyBody` Helper + +| Attribute | Value | +|-----------|-------| +| File | `lib/api.js` lines 253–262 | +| Smell | Oddball Solution / Bug Risk (Medium, effective High) | +| Technique | **Substitute Algorithm** | +| Priority | **High** | +| Complexity | Simple | +| Risk | Low (functional improvement, no API surface change) | + +**Problem** + +`insert()` and `update()` both route through `applyBody(opts, type, payloadFn)` which correctly detects Document/Attachment/ContentVersion SObjects and builds a multipart body. `upsert()` skips `applyBody` and directly sets `opts.body = JSON.stringify(...)`, producing a JSON-only body for binary SObjects instead of the required multipart request. This is a silent functional bug. + +**Before** + +```js +const upsert = function (data) { + const opts = this._getOpts(data); + const type = opts.sobject.getType(); + const extIdField = opts.sobject.getExternalIdField(); + const extId = opts.sobject.getExternalId(); + opts.resource = sobjectPath(type, extIdField, extId); + opts.method = 'PATCH'; + opts.body = JSON.stringify(opts.sobject.toPayload()); // bypasses applyBody + return this._apiRequest(opts); +}; +``` + +**After** + +```js +const upsert = function (data) { + const opts = this._getOpts(data); + const type = opts.sobject.getType(); + const extIdField = opts.sobject.getExternalIdField(); + const extId = opts.sobject.getExternalId(); + opts.resource = sobjectPath(type, extIdField, extId); + opts.method = 'PATCH'; + applyBody(opts, type, () => opts.sobject.toPayload()); // consistent with insert/update + return this._apiRequest(opts); +}; +``` + +**Steps** + +1. In `lib/api.js`, locate the `upsert` function (lines 253–262). +2. Replace `opts.body = JSON.stringify(opts.sobject.toPayload());` with `applyBody(opts, type, () => opts.sobject.toPayload());`. +3. Verify that `applyBody` is already in scope at this point in the file (it is — defined above the CRUD section). +4. Add a test case for upserting a ContentVersion SObject with binary data to `test/crud.js` to cover the multipart path for upsert. + +--- + +### R04 — Fix Assignment Spacing in `lib/api.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/api.js` lines 226, 240, 241, 255, 257, 271, 272 | +| Smell | Inconsistent Style (Medium) | +| Technique | **Rename Method** analogue — mechanical formatting correction | +| Priority | Medium | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +Seven assignment statements are missing a space between the variable name and `=`: + +```js +const type =opts.sobject.getType(); // should be: const type = opts.sobject.getType(); +const id =opts.sobject.getId(); // should be: const id = opts.sobject.getId(); +const extId =opts.sobject.getExternalId(); +``` + +This appears in `insert`, `update`, `upsert`, and `_delete` — all four core CRUD functions — suggesting copy-paste construction without a final formatting pass. + +**Steps** + +1. Run `npx eslint --fix lib/api.js` (the `space-infix-ops` rule will auto-correct these). +2. If the ESLint rule is not enabled, manually add the spaces using search-and-replace with regex `=opts` → `= opts`. +3. Verify no logic change: `git diff lib/api.js` should show whitespace-only changes. + +**Note**: R03 also touches these lines for `upsert`, so apply R03 first to avoid conflicts. + +--- + +### R05 — Extract `_resubscribeAll()` Method in `lib/cometd.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/cometd.js` lines 382–384 and 417–419 | +| Smell | Duplicated Code (Low) | +| Technique | **Extract Method** | +| Priority | Medium | +| Complexity | Simple | +| Risk | Low | + +**Problem** + +The identical re-subscription loop appears in two separate async methods: + +```js +// _rehandshake() — lines 382–384 +for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); +} + +// _scheduleReconnect() — lines 417–419 +for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); +} +``` + +Any change to re-subscription logic (e.g., error handling per topic, replay extension state) must be made in two places. + +**After** + +```js +/** + * Re-subscribe all active topics after a handshake. + */ +async _resubscribeAll() { + for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); + } +} + +// In _rehandshake(): +await this.handshake(); +await this._resubscribeAll(); + +// In _scheduleReconnect(): +await this.handshake(); +this._reconnectAttempts = 0; +await this._resubscribeAll(); +await this.connect(); +``` + +**Steps** + +1. Create the `_resubscribeAll()` async method in `CometDClient` (place it near `_sendSubscribe`). +2. Replace both inline loops with `await this._resubscribeAll()`. +3. Run `npm test` to confirm behaviour is unchanged. + +--- + +### R06 — Remove Redundant `Promise.resolve()` Wrappers in `lib/auth.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/auth.js` lines 133, 187 | +| Smell | Dispensable Code (Medium) | +| Technique | **Inline Temp** | +| Priority | Low | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +Inside `.then()` callbacks, `return Promise.resolve(value)` is functionally identical to `return value`. The `.then()` handler's return value is automatically wrapped in a resolved promise by the Promises/A+ specification. The `Promise.resolve()` call adds noise without behaviour change. + +**Before** + +```js +// lib/auth.js line 133 (_notifyAndResolve) +return Promise.resolve(newOauth); + +// lib/auth.js line 187 (authenticate) +return Promise.resolve(newOauth); +``` + +**After** + +```js +return newOauth; +``` + +**Steps** + +1. In `lib/auth.js`, find the two occurrences of `return Promise.resolve(newOauth);`. +2. Replace both with `return newOauth;`. +3. Run `npm test` to verify no behaviour change. + +--- + +### R07 — Fix Quote Style in `lib/cometd.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/cometd.js` — all string literals | +| Smell | Inconsistent Style (Medium) | +| Technique | **Substitute Algorithm** (mechanical style normalization) | +| Priority | Medium | +| Complexity | Simple | +| Risk | Low | + +**Problem** + +`lib/cometd.js` uses double-quoted strings throughout (`"use strict"`, `"Content-Type"`, `"websocket"`) while the ESLint config enforces `quotes: ['error', 'single']` across the rest of the codebase. Running `npx eslint lib/cometd.js` likely reports errors on every string literal in the file. + +**Steps** + +1. Run `npx eslint --fix lib/cometd.js`. +2. Review the diff to confirm only quote characters changed — no logic was modified. +3. Run `npm test`. + +**Note**: This is a pure style change. If the file was intentionally excluded from ESLint (unlikely), document that decision instead. + +--- + +### R08 — Replace Inline `require` with Top-Level Import in `test/mock/cometd-server.js` + +| Attribute | Value | +|-----------|-------| +| File | `test/mock/cometd-server.js` line 277 | +| Smell | Clever Code (Low) | +| Technique | **Inline Method** analogue — hoist the require | +| Priority | Low | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +```js +const emitter = new (require('events').EventEmitter)(); +``` + +An inline `require()` inside a method body is unusual and adds visual noise. `require()` calls are conventionally placed at the top of the file. + +**After** + +```js +// At top of file with other requires: +const EventEmitter = require('events'); + +// In the method: +const emitter = new EventEmitter(); +``` + +**Steps** + +1. Add `const EventEmitter = require('events');` to the top of `test/mock/cometd-server.js` with the other requires (after R01 adds `const crypto = require('crypto')`). +2. Replace the inline `new (require('events').EventEmitter)()` with `new EventEmitter()`. + +--- + +### R09 — Modernise `onRefresh` to Accept Promises in `lib/auth.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/auth.js` lines 117–135 (`_notifyAndResolve`) | +| Smell | Callback Hell / Mixed Paradigm (Medium) | +| Technique | **Replace Parameter with Method Call** + **Substitute Algorithm** | +| Priority | Medium | +| Complexity | Moderate | +| Risk | Medium — changes the documented `onRefresh` contract | + +**Problem** + +`_notifyAndResolve` wraps the `onRefresh` callback in a `Promise` constructor, forcing library users to write old-style callbacks inside an otherwise fully promise-based library: + +```js +const _notifyAndResolve = function (newOauth, oldOauth) { + if (this.onRefresh) { + return new Promise((resolve, reject) => { + this.onRefresh.call(this, newOauth, oldOauth, (err) => { + if (err) reject(err); + else resolve(newOauth); + }); + }); + } + return Promise.resolve(newOauth); +}; +``` + +Users must write `onRefresh: (newOauth, oldOauth, done) => { ...; done(); }` when `onRefresh: async (newOauth, oldOauth) => { ... }` would be far more natural. + +**After** + +```js +const _notifyAndResolve = function (newOauth, oldOauth) { + if (this.onRefresh) { + // Accept both: callback-style (arity=3) for backwards compatibility, + // and promise-returning or async functions (arity<3). + if (this.onRefresh.length >= 3) { + // Legacy callback path — wrap for backward compatibility + return new Promise((resolve, reject) => { + this.onRefresh.call(this, newOauth, oldOauth, (err) => { + if (err) reject(err); + else resolve(newOauth); + }); + }); + } + // Modern path: onRefresh returns a value or a Promise + return Promise.resolve(this.onRefresh.call(this, newOauth, oldOauth)) + .then(() => newOauth); + } + return newOauth; +}; +``` + +**Steps** + +1. In `lib/auth.js`, update `_notifyAndResolve` as shown above. +2. Update the JSDoc to document both signatures. +3. Add a test to `test/connection.js` for the async `onRefresh` path. +4. Keep the existing callback test to confirm backward compatibility. + +**Risk Mitigation**: The `function.length` check preserves full backward compatibility for existing `onRefresh` implementations. The breaking behaviour (accepting a Promise) is additive only. + +--- + +### R10 — Decompose `getAuthUri` Conditional Blocks in `lib/auth.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/auth.js` lines 67–115 | +| Smell | Conditional Complexity (Medium) | +| Technique | **Substitute Algorithm** + **Extract Method** | +| Priority | Low | +| Complexity | Simple | +| Risk | Low | + +**Problem** + +Eight consecutive `if` blocks all perform the same operation: conditionally copy an option from `opts` to `urlOpts`, with optional array-join for `scope` and `prompt`. The pattern is highly regular but verbose. + +**Before** (pattern repeated 8 times) + +```js +if (opts.display) { + urlOpts.display = opts.display.toLowerCase(); +} +if (opts.scope) { + if (Array.isArray(opts.scope)) { + urlOpts.scope = opts.scope.join(' '); + } else { + urlOpts.scope = opts.scope; + } +} +// ... 6 more identical if-blocks +``` + +**After** + +```js +const getAuthUri = function (opts = {}) { + const urlOpts = { + response_type: opts.responseType || 'code', + client_id: this.clientId, + redirect_uri: this.redirectUri, + }; + + // Simple copy: include field if present in opts + const simpleCopyFields = ['immediate', 'state', 'nonce']; + for (const field of simpleCopyFields) { + if (opts[field] !== undefined) urlOpts[field] = opts[field]; + } + + // Transformed copy: apply value transform before including + if (opts.display) urlOpts.display = opts.display.toLowerCase(); + if (opts.loginHint) urlOpts.login_hint = opts.loginHint; + + // Array-or-string fields (Salesforce uses space-delimited values) + const spaceJoinFields = ['scope', 'prompt']; + for (const field of spaceJoinFields) { + if (opts[field] !== undefined) { + urlOpts[field] = Array.isArray(opts[field]) + ? opts[field].join(' ') + : opts[field]; + } + } + + if (opts.urlOpts) Object.assign(urlOpts, opts.urlOpts); + + return this._authEndpoint(opts) + '?' + new URLSearchParams(urlOpts).toString(); +}; +``` + +**Steps** + +1. Replace the body of `getAuthUri` in `lib/auth.js` with the refactored version. +2. Run `npm test` to confirm `test/connection.js` tests for `getAuthUri` still pass (especially the scope/prompt array encoding tests). + +--- + +### R11 — Remove Redundant Intermediate Variable in `createSObject` (`index.js`) + +| Attribute | Value | +|-----------|-------| +| File | `index.js` lines 85–86 | +| Smell | Lazy Element (Low) | +| Technique | **Inline Temp** | +| Priority | Low | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +```js +const createSObject = (type, fields) => { + const data = fields || {}; + data.attributes = { type: type }; + const rec = new Record(data); + return rec; // rec used only to be returned immediately +}; +``` + +The `rec` variable is assigned solely to be returned on the next line. It adds no explanatory value. + +**After** + +```js +const createSObject = (type, fields) => { + const data = fields || {}; + data.attributes = { type: type }; + return new Record(data); +}; +``` + +**Steps** + +1. In `index.js`, remove the `const rec = new Record(data);` line and replace `return rec;` with `return new Record(data);`. + +--- + +### R12 — Replace Magic Array in `findId` with Named Constant (`lib/util.js`) + +| Attribute | Value | +|-----------|-------| +| File | `lib/util.js` lines 58–63 | +| Smell | Magic Number / Primitive Obsession (Low) | +| Technique | **Replace Magic Number with Symbolic Constant** | +| Priority | Low | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +```js +const flavors = ['Id', 'id', 'ID']; +``` + +The name `flavors` is whimsical. The inline array is an unnamed magic literal — its meaning is "the valid case variants of the Salesforce ID field name". Additionally, `if (data[flavor])` is a falsy check; while practically safe for Salesforce IDs, `!== undefined` is semantically more precise. + +**After** + +```js +const ID_FIELD_VARIANTS = ['Id', 'id', 'ID']; + +... + +for (const variant of ID_FIELD_VARIANTS) { + if (data[variant] !== undefined) { + return data[variant]; + } +} +``` + +**Steps** + +1. Declare `const ID_FIELD_VARIANTS = ['Id', 'id', 'ID'];` as a module-level constant near the top of `lib/util.js`. +2. Rename the loop variable from `flavor` to `variant` (more descriptive). +3. Change the condition from `if (data[flavor])` to `if (data[variant] !== undefined)`. +4. Run `npm test`. + +--- + +### R13 — Rename `checkHeaderCaseInsensitive` to `headerContains` (`lib/util.js`) + +| Attribute | Value | +|-----------|-------| +| File | `lib/util.js` line 11 | +| Smell | Uncommunicative Name (Low) | +| Technique | **Rename Method** | +| Priority | Low | +| Complexity | Trivial | +| Risk | None (module-private function) | + +**Problem** + +`checkHeaderCaseInsensitive(headers, key, searchfor)` is verbose and the parameter `searchfor` is non-standard casing. The function performs a case-insensitive substring search on an HTTP header value — `headerContains` communicates this more precisely. Since the function is module-private (not exported), the rename affects only `lib/util.js` and its callers within the module. + +**After** + +```js +const headerContains = (headers, key, substring) => { + ... +}; +``` + +**Steps** + +1. Rename the function and parameter in `lib/util.js`. +2. Update all callers within `lib/util.js`. +3. Confirm no external exports need updating (the function is not in `module.exports`). + +--- + +### R14 — Replace `let` with `const` in `lib/optionhelper.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/optionhelper.js` lines 88, 90 | +| Smell | Inconsistent Style / `let` used for non-reassigned variables (Low) | +| Technique | **Remove Assignments to Parameters** analogue — use proper binding keyword | +| Priority | Low | +| Complexity | Trivial | +| Risk | None | + +**Before** + +```js +let result = new URL(opts.uri); // never reassigned +let params = opts.qs; // never reassigned +``` + +**After** + +```js +const result = new URL(opts.uri); +const params = opts.qs; +``` + +**Steps** + +1. Change `let` to `const` on lines 88 and 90 of `lib/optionhelper.js`. +2. Verify ESLint passes with no `prefer-const` warnings. + +--- + +### R15 — Rename `getFullUri` to `buildUrl` in `lib/optionhelper.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/optionhelper.js` lines 87–96; `lib/http.js` line 158 | +| Smell | Uncommunicative Name (Low) | +| Technique | **Rename Method** | +| Priority | Low | +| Complexity | Trivial | +| Risk | Low (internal module API) | + +**Problem** + +`getFullUri` returns a `URL` object (not a string URI), making the name misleading. The word "URI" implies a string. `buildUrl` accurately describes both the action (construction) and the return type. + +**Steps** + +1. Rename `getFullUri` to `buildUrl` in `lib/optionhelper.js`. +2. Update the caller in `lib/http.js`: `const uri = optionHelper.buildUrl(ropts);`. +3. Update any JSDoc comments referencing the old name. + +--- + +### R16 — Extract `_resubscribeAll` and Propagate Errors in `lib/cometd.js` + +| Attribute | Value | +|-----------|-------| +| File | `lib/cometd.js` lines 365–370 | +| Smell | Afraid to Fail (Low) | +| Technique | **Introduce Assertion** + improve error propagation | +| Priority | Low | +| Complexity | Moderate | +| Risk | Low | + +**Problem** + +The `_connectLoop` `catch` block silently swallows all errors — no error detail is surfaced to event consumers. The `transport:down` event fires with no information about why the connection dropped. + +**Before** + +```js +} catch { + if (this._disconnecting) return; + this._connected = false; + this.emit('transport:down'); + this._scheduleReconnect(); + return; +} +``` + +**After** + +```js +} catch (err) { + if (this._disconnecting) return; + this._connected = false; + this.emit('transport:down', err); // forward error for diagnostics + this._scheduleReconnect(); + return; +} +``` + +**Steps** + +1. Change `catch {` to `catch (err) {` in `_connectLoop`. +2. Change `this.emit('transport:down')` to `this.emit('transport:down', err)`. +3. Update the JSDoc for the `transport:down` event to document the optional error argument. +4. Update `_connectWebSocket` similarly to emit a non-fatal warning event when WebSocket fallback occurs (optional, lower priority). + +--- + +### R17 — Clean Up Dead Code in `test/integration.js` + +| Attribute | Value | +|-----------|-------| +| File | `test/integration.js` lines 7–21 | +| Smell | Dead Code (Low) | +| Technique | Remove dead code | +| Priority | Low | +| Complexity | Trivial | +| Risk | None | + +**Problem** + +1. `let client = undefined;` — explicit `undefined` initialization is redundant. +2. `checkEnvCredentials()` is called twice — once to gate the `describe` block and again inside `before()`. +3. `// Mocha.suite.skip();` is a commented-out dead code line. + +**After** + +```js +let client; // implicit undefined + +(checkEnvCredentials() ? describe : describe.skip)( + 'Integration Test against an actual Salesforce instance', + () => { + before(() => { + const creds = checkEnvCredentials(); + // creds is guaranteed truthy here — describe.skip handled the false case + client = nforce.createConnection({ ... }); + }); + ... + } +); +``` + +**Steps** + +1. Change `let client = undefined;` to `let client;`. +2. Remove the commented-out `// Mocha.suite.skip()` line. +3. Keep the second `checkEnvCredentials()` call if the `before()` block uses its return value; otherwise remove the redundant call. + +--- + +### R18 — Convert Mock Server to Class-Based Instance (Long-Term) + +| Attribute | Value | +|-----------|-------| +| File | `test/mock/sfdc-rest-api.js` lines 1–50 | +| Smell | Global Data / Shared Mutable State (High) | +| Technique | **Extract Class** | +| Priority | Medium (Long-term) | +| Complexity | Moderate | +| Risk | Medium — touches all test files | + +**Problem** + +`serverStack` and `requestStack` are module-level mutable arrays. All test files that `require` this mock share the same state. `reset()` only clears `requestStack`, not `serverStack`. If a test file fails to call `reset()`, later tests may observe stale request data. + +**After (conceptual)** + +```js +class MockSfdcApi { + constructor() { + this._serverStack = []; + this._requestStack = []; + } + + reset() { + this._requestStack.length = 0; + } + + getLastRequest() { + return this._requestStack[0]; + } + + async getServerInstance(serverListener) { ... } + async clearServerStack() { ... } +} + +module.exports = { MockSfdcApi }; +``` + +Each test file creates its own `MockSfdcApi` instance in `before()` and closes it in `after()`, eliminating all shared state. + +**Steps** + +1. Wrap the existing module-level state and functions in a `MockSfdcApi` class. +2. Update all test files (`crud.js`, `query.js`, `auth.js`, etc.) to instantiate `new MockSfdcApi()` in their `before()` blocks. +3. Update `reset()` to also clear `_serverStack`. +4. Run the full test suite after each file update. + +**Why deferred to long-term**: This change touches every test file. The risk of accidentally breaking tests is moderate. The existing tests pass reliably today, so this is a quality-of-life improvement rather than a bug fix. + +--- + +## Risk Assessment Summary + +| Recommendation | Risk | Rationale | +|----------------|------|-----------| +| R01 — Add `require('crypto')` | None | One-line addition with no logic change | +| R02 — Fix test error swallowing | Low | Test-only change; may reveal real bugs | +| R03 — Fix `upsert` applyBody | Low | Corrects incorrect behaviour; no API change | +| R04 — Spacing in api.js | None | Whitespace-only diff | +| R05 — Extract `_resubscribeAll` | Low | Extracts identical code; no behaviour change | +| R06 — Remove `Promise.resolve()` wrappers | None | Spec-equivalent simplification | +| R07 — Fix quote style in cometd.js | Low | ESLint auto-fix; no logic change | +| R08 — Hoist inline `require` | None | Module loading is identical | +| R09 — `onRefresh` Promise support | Medium | Additive with backward-compat guard; update docs | +| R10 — Decompose `getAuthUri` conditionals | Low | Algorithm refactor; existing tests validate | +| R11 — Inline `rec` temp variable | None | Pure simplification | +| R12 — Named constant for ID variants | None | Readability only | +| R13 — Rename `checkHeaderCaseInsensitive` | None | Module-private, no external callers | +| R14 — `let` → `const` in optionhelper | None | Communicates intent, no logic change | +| R15 — Rename `getFullUri` → `buildUrl` | Low | Internal API; two-file change | +| R16 — Propagate errors from `catch` | Low | Adds error argument to existing event | +| R17 — Clean up dead code in integration.js | None | Removes noise | +| R18 — Class-based mock server | Medium | Broad test refactor; schedule carefully | + +--- + +## Sequencing and Dependencies + +The recommended implementation order respects these dependencies: + +### Phase 1 — Critical Bug Fixes (apply immediately, in order) + +1. **R01** (crypto import) — prerequisite for reliable WebSocket test paths +2. **R03** (upsert applyBody) — fixes silent functional bug before R02 exposes masked failures +3. **R02** (fix test error swallowing) — apply after R03 so revealed failures are distinguishable + +### Phase 2 — Code Quality Improvements (short sprint) + +4. **R04** (spacing in api.js) — no-risk ESLint fix +5. **R07** (quote style in cometd.js) — no-risk ESLint fix +6. **R14** (`let` → `const` in optionhelper) — no-risk ESLint fix +7. **R05** (extract `_resubscribeAll`) — Extract Method with clear scope +8. **R06** (remove `Promise.resolve()` wrappers) — trivial inline +9. **R11** (inline `rec` in createSObject) — trivial inline +10. **R08** (hoist inline `require`) — best done after R01 adds the first hoist + +### Phase 3 — Design Improvements (medium sprint) + +11. **R12** (named constant for ID variants) +12. **R13** (rename `checkHeaderCaseInsensitive`) +13. **R15** (rename `getFullUri` → `buildUrl`) +14. **R10** (decompose `getAuthUri` conditionals) +15. **R16** (propagate errors from `_connectLoop` catch) +16. **R17** (clean up integration.js dead code) + +### Phase 4 — Architectural Improvements (planned work) + +17. **R09** (`onRefresh` Promise support) — coordinate with documentation update +18. **R18** (class-based mock server) — schedule as a dedicated test-infrastructure task + +--- + +## SOLID Principle Improvements Expected + +| Principle | Before | After | Improvement | +|-----------|--------|-------|-------------| +| SRP | 7/10 | 8/10 | R10 reduces `getAuthUri` complexity; R05 isolates reconnect logic | +| OCP | 7/10 | 8/10 | R09 opens `onRefresh` to Promise implementations without modification | +| LSP | 9/10 | 9/10 | No inheritance changes recommended | +| ISP | 6/10 | 6/10 | R18 (if applied) reduces test surface coupling; prototype mixin remains | +| DIP | 7/10 | 7/10 | No inversion changes in scope | + +--- + +## Before/After Code Summary + +| File | Lines Changed | Type | +|------|--------------|------| +| `test/mock/cometd-server.js` | +2 | Import addition | +| `test/crud.js` | ~12 | Pattern replacement (4 tests) | +| `test/query.js` | ~27 | Pattern replacement (9 tests) | +| `lib/api.js` | 1 functional + 7 whitespace | Bug fix + style | +| `lib/auth.js` | ~15 | Simplification + modernisation | +| `lib/cometd.js` | ~20 | Style + dedup + error propagation | +| `lib/util.js` | ~5 | Rename + constant extraction | +| `lib/optionhelper.js` | 4 | `let`→`const` + rename | +| `index.js` | 2 | Inline temp removal | +| `test/integration.js` | 3 | Dead code removal | + +--- + +*Generated by the Refactoring Expert Agent — nforce8 project — 2026-03-30* diff --git a/tasks/code-refactoring-summary.md b/tasks/code-refactoring-summary.md new file mode 100644 index 0000000..c81cb33 --- /dev/null +++ b/tasks/code-refactoring-summary.md @@ -0,0 +1,145 @@ +# Code Refactoring Summary — nforce8 + +**Date**: 2026-03-30 +**Input**: code-smell-detector-report.md (30 smells) +**Output**: 18 refactoring recommendations + +--- + +## Quick Reference + +| ID | Refactoring | File | Technique | Impact | Complexity | Risk | Phase | +|----|------------|------|-----------|--------|------------|------|-------| +| R01 | Add missing `require('crypto')` | `test/mock/cometd-server.js` | Introduce Foreign Method | H | L | None | 1 | +| R02 | Fix silent error swallowing in tests | `test/crud.js`, `test/query.js` | Substitute Algorithm | H | L | L | 1 | +| R03 | Fix `upsert()` to use `applyBody` | `lib/api.js` | Substitute Algorithm | H | L | L | 1 | +| R04 | Fix spacing after `=` in api.js | `lib/api.js` | Style fix (ESLint) | L | L | None | 2 | +| R05 | Extract `_resubscribeAll()` method | `lib/cometd.js` | Extract Method | M | L | L | 2 | +| R06 | Remove redundant `Promise.resolve()` | `lib/auth.js` | Inline Temp | L | L | None | 2 | +| R07 | Fix double-quote style in cometd.js | `lib/cometd.js` | Style fix (ESLint) | L | L | None | 2 | +| R08 | Hoist inline `require` to top | `test/mock/cometd-server.js` | Inline Method analogue | L | L | None | 2 | +| R09 | `onRefresh` support for Promises | `lib/auth.js` | Replace Parameter with Method Call | M | M | M | 4 | +| R10 | Decompose `getAuthUri` conditionals | `lib/auth.js` | Substitute Algorithm + Extract Method | M | L | L | 3 | +| R11 | Inline redundant `rec` variable | `index.js` | Inline Temp | L | L | None | 2 | +| R12 | Named constant for ID field variants | `lib/util.js` | Replace Magic Number with Symbolic Constant | L | L | None | 3 | +| R13 | Rename `checkHeaderCaseInsensitive` | `lib/util.js` | Rename Method | L | L | None | 3 | +| R14 | Replace `let` with `const` | `lib/optionhelper.js` | Style fix | L | L | None | 2 | +| R15 | Rename `getFullUri` → `buildUrl` | `lib/optionhelper.js` | Rename Method | L | L | L | 3 | +| R16 | Propagate errors in `_connectLoop` | `lib/cometd.js` | Introduce Assertion | M | L | L | 3 | +| R17 | Remove dead code in integration.js | `test/integration.js` | Dead code removal | L | L | None | 3 | +| R18 | Convert mock to class-based instance | `test/mock/sfdc-rest-api.js` | Extract Class | M | M | M | 4 | + +--- + +## Priority Matrix + +### High Impact + +| Recommendation | Complexity | Risk | +|----------------|------------|------| +| R01 — Add `require('crypto')` | Low | None | +| R02 — Fix test error swallowing | Low | Low | +| R03 — Fix `upsert()` applyBody | Low | Low | + +### Medium Impact + +| Recommendation | Complexity | Risk | +|----------------|------------|------| +| R05 — Extract `_resubscribeAll()` | Low | Low | +| R09 — `onRefresh` Promise support | Medium | Medium | +| R10 — Decompose `getAuthUri` | Low | Low | +| R16 — Propagate connect errors | Low | Low | +| R18 — Class-based mock server | Medium | Medium | + +### Low Impact (Hygiene) + +| Recommendation | Complexity | Risk | +|----------------|------------|------| +| R04 — Spacing fix (ESLint auto) | Low | None | +| R06 — Remove Promise.resolve() | Low | None | +| R07 — Fix quote style (ESLint auto) | Low | None | +| R08 — Hoist inline require | Low | None | +| R11 — Inline rec temp | Low | None | +| R12 — Named ID constant | Low | None | +| R13 — Rename checkHeaderCaseInsensitive | Low | None | +| R14 — let → const | Low | None | +| R15 — Rename getFullUri | Low | Low | +| R17 — Dead code cleanup | Low | None | + +--- + +## Risk Distribution + +| Risk Level | Count | Recommendations | +|------------|-------|-----------------| +| None | 10 | R01, R04, R06, R07, R08, R11, R12, R13, R14, R17 | +| Low | 6 | R02, R03, R05, R10, R15, R16 | +| Medium | 2 | R09, R18 | +| High | 0 | — | + +--- + +## Implementation Sequence + +### Phase 1 — Critical (implement this week) + +1. **R01**: `const crypto = require('crypto');` at top of `test/mock/cometd-server.js` — prevents latent `TypeError` on WebSocket upgrade path +2. **R03**: `upsert()` → use `applyBody` helper — silently incorrect for binary SObjects today +3. **R02**: Replace 13 `.catch(should.not.exist).finally(done)` patterns — exposes any real assertion failures these were masking + +### Phase 2 — Quick Wins (implement this sprint) + +4. **R04** + **R07** + **R14**: Run `npx eslint --fix` on `lib/api.js`, `lib/cometd.js`, `lib/optionhelper.js` — zero-risk mechanical fixes +5. **R05**: Extract `_resubscribeAll()` in `lib/cometd.js` — eliminates duplicate loop in two reconnect methods +6. **R06**: Remove `Promise.resolve()` wrappers in `lib/auth.js` — two-line cleanup +7. **R11**: Inline `rec` temp in `index.js` — one-line cleanup +8. **R08**: Hoist inline `require('events')` in mock server — do alongside R01 + +### Phase 3 — Design Tidying (next sprint) + +9. **R12**: Extract `ID_FIELD_VARIANTS` constant in `lib/util.js` +10. **R13**: Rename `checkHeaderCaseInsensitive` → `headerContains` +11. **R15**: Rename `getFullUri` → `buildUrl` in optionhelper + http +12. **R10**: Refactor `getAuthUri` conditional blocks in `lib/auth.js` +13. **R16**: Forward error to `transport:down` event in `lib/cometd.js` +14. **R17**: Clean up dead code in `test/integration.js` + +### Phase 4 — Planned Improvements (next major version planning) + +15. **R09**: Accept Promise-returning `onRefresh` (backward-compatible, requires documentation update) +16. **R18**: Convert `test/mock/sfdc-rest-api.js` to class-based instance (planned dedicated sprint) + +--- + +## Key Benefits After Implementation + +| Benefit | From | +|---------|------| +| WebSocket test path no longer crashes | R01 | +| Assertion failures surface correctly in 13 tests | R02 | +| Binary SObject upsert produces correct multipart requests | R03 | +| ESLint passes cleanly on all source files | R04, R07, R14 | +| Reconnection logic has single source of truth | R05 | +| `auth.js` is more idiomatic (no spurious Promise wrapping) | R06 | +| Module dependency intent clear at file top | R08 | +| `onRefresh` works with async/await handlers | R09 | +| `getAuthUri` readable without counting 8 if-blocks | R10 | +| Self-documenting ID variant handling | R12 | +| Tests isolated from cross-contamination | R18 | + +--- + +## Refactoring Technique Distribution + +| Category | Techniques Used | Count | +|----------|----------------|-------| +| Composing Methods | Extract Method (R05), Inline Temp (R06, R11), Substitute Algorithm (R02, R03, R07, R10) | 7 | +| Simplifying Method Calls | Rename Method (R13, R15), Remove Parameter / Replace Parameter with Method Call (R09) | 3 | +| Organizing Data | Replace Magic Number with Symbolic Constant (R12) | 1 | +| Moving Features | Extract Class (R18) | 1 | +| Simplifying Conditionals | Decompose Conditional / Extract Method (R10) | 1 | +| Style / Mechanical | Formatting corrections (R04, R07, R08, R14), Dead code (R01, R17) | 5 | + +--- + +*Full technical analysis with before/after code examples: `code-refactoring-report.md`* diff --git a/tasks/code-smell-detector-data.json b/tasks/code-smell-detector-data.json new file mode 100644 index 0000000..912a2f8 --- /dev/null +++ b/tasks/code-smell-detector-data.json @@ -0,0 +1,89 @@ +{ + "grade": "B", + "total_issues": 30, + "severity_distribution": { + "high": 3, + "medium": 11, + "low": 16 + }, + "category_distribution": { + "Lexical Abusers": 7, + "Data Dealers": 5, + "Dispensables": 5, + "Obfuscators": 3, + "Functional Abusers": 3, + "Object-Oriented Abusers": 2, + "Change Preventers": 2, + "Couplers": 2, + "Other": 1 + }, + "solid_compliance": { + "SRP": 7, + "OCP": 7, + "LSP": 9, + "ISP": 6, + "DIP": 7 + }, + "top_issues": [ + { + "file": "test/mock/cometd-server.js", + "issue": "Missing require('crypto') import — crypto.createHash() used as implicit global, causing ReferenceError at runtime on WebSocket upgrade path", + "severity": "high", + "category": "Functional Abusers" + }, + { + "file": "test/crud.js", + "issue": "Flawed error-catch test pattern: .catch((err) => should.not.exist(err)).finally(done) silently swallows assertion failures in 6 tests", + "severity": "high", + "category": "Other" + }, + { + "file": "test/query.js", + "issue": "Flawed error-catch test pattern: .catch((err) => should.not.exist(err)).finally(done) silently swallows assertion failures in 9 tests", + "severity": "high", + "category": "Other" + }, + { + "file": "lib/api.js", + "issue": "upsert() bypasses applyBody helper and directly serializes JSON body, silently producing incorrect requests when upserting binary SObjects (Document, ContentVersion)", + "severity": "medium", + "category": "Other" + }, + { + "file": "test/mock/sfdc-rest-api.js", + "issue": "Module-level mutable arrays (serverStack, requestStack) shared across all test imports — cross-test state contamination risk", + "severity": "high", + "category": "Data Dealers" + }, + { + "file": "index.js", + "issue": "Prototype mixin via Object.assign(Connection.prototype, httpMethods, authMethods, apiMethods) creates 35+ method flat surface and Divergent Change risk", + "severity": "medium", + "category": "Object-Oriented Abusers" + }, + { + "file": "lib/api.js", + "issue": "Seven assignment statements missing space after = operator (lines 226, 240, 241, 255, 257, 271, 272) — inconsistent style across file", + "severity": "medium", + "category": "Lexical Abusers" + }, + { + "file": "lib/auth.js", + "issue": "_notifyAndResolve uses callback-based onRefresh API inside Promise constructor while all other library APIs are Promise-only", + "severity": "medium", + "category": "Change Preventers" + }, + { + "file": "lib/cometd.js", + "issue": "Entire file uses double-quoted strings while rest of codebase enforces single quotes — violates ESLint quotes rule", + "severity": "medium", + "category": "Lexical Abusers" + }, + { + "file": "lib/cometd.js", + "issue": "Duplicated re-subscription loop in both _rehandshake() and _scheduleReconnect() — should be extracted to _resubscribeAll()", + "severity": "low", + "category": "Dispensables" + } + ] +} diff --git a/tasks/code-smell-detector-report.md b/tasks/code-smell-detector-report.md new file mode 100644 index 0000000..4458818 --- /dev/null +++ b/tasks/code-smell-detector-report.md @@ -0,0 +1,856 @@ +# Code Smell Detection Report + +## Executive Summary + +**Project**: nforce8 — Node.js REST API wrapper for Salesforce +**Analysis Date**: 2026-03-30 +**Languages**: JavaScript (Node.js, CommonJS modules) +**Scope**: `lib/` (13 files), `index.js`, `test/` (9 files), `test/mock/` (2 files) +**Total Lines Analyzed**: ~4,515 (source + test) + +**Overall Assessment**: The codebase is in good health for its domain. It is well-structured, uses modern JavaScript idioms, and has solid test coverage. The majority of issues are low-to-medium severity style and design concerns rather than architectural problems. + +| Severity | Count | +|----------|-------| +| High (Architectural) | 3 | +| Medium (Design) | 11 | +| Low (Readability/Style) | 16 | +| **Total** | **30** | + +--- + +## Project Analysis + +**Languages & Frameworks Detected** +- JavaScript (ES2022+, `'use strict'`, CommonJS `require/module.exports`) +- Node.js >= 22.4.0 (native `fetch`, `WebSocket`, `AbortSignal`) +- Test framework: Mocha + should.js +- Coverage: NYC (Istanbul) +- Lint: ESLint 10 flat config + +**Project Structure** +- `index.js` — Public API surface (99 lines, entry point) +- `lib/api.js` — All Salesforce REST API methods (649 lines) +- `lib/auth.js` — OAuth flows (300 lines) +- `lib/http.js` — HTTP layer using native `fetch` (200 lines) +- `lib/cometd.js` — CometD/Bayeux streaming client (535 lines) +- `lib/fdcstream.js` — High-level FDC streaming wrapper (137 lines) +- `lib/record.js` — SObject record with change tracking (233 lines) +- `lib/optionhelper.js` — Request options builder (98 lines) +- `lib/connection.js` — Options validation (93 lines) +- `lib/constants.js` — URLs, version constants (56 lines) +- `lib/util.js` — Type/header utilities (106 lines) +- `lib/multipart.js` — FormData builder (67 lines) +- `lib/plugin.js` — Plugin registration system (52 lines) +- `lib/errors.js` — Error factory functions (23 lines) + +--- + +## High Severity Issues (Architectural Impact) + +### 1. Global Mutable State in Mock Server — Data Dealers: Global Data + +**File**: `test/mock/sfdc-rest-api.js` — Lines 5–6 +**Category**: Data Dealers +**Smell**: Global Data / Mutable Data + +```js +let serverStack = []; +let requestStack = []; +``` + +Both `serverStack` and `requestStack` are module-level mutable arrays shared across all tests that import this module. Because they are module-level (persisted via `require` cache), all test files sharing this mock are implicitly coupled through shared state. The `reset()` function only clears `requestStack`, not `serverStack`. A test that forgets to call `afterEach(() => api.reset())` can corrupt the state of the next test. + +**SOLID Violation**: Single Responsibility Principle — the mock module conflates server lifecycle management with request capture and response configuration. + +**Refactoring**: Convert to a class-based mock where each `before()` creates a fresh instance. This eliminates cross-test contamination and makes teardown explicit. + +--- + +### 2. Implicit Prototype Mixin Architecture — Object-Oriented Abusers: Inappropriate Static / Divergent Change + +**File**: `index.js` — Line 52 +**Category**: Object-Oriented Abusers, Change Preventers +**Smell**: Divergent Change / Inappropriate use of prototype augmentation + +```js +Object.assign(Connection.prototype, httpMethods, authMethods, apiMethods); +``` + +The `Connection` constructor function has its prototype augmented by three separate modules containing 30+ methods. This means any method in any of the three source modules becomes a public method of `Connection`. Changes to `auth.js`, `api.js`, or `http.js` all change the `Connection` surface area, which is a Divergent Change smell applied in reverse — the single class changes for three different reasons. The pattern also makes it impossible to know the complete interface of `Connection` without reading four files. + +**SOLID Violations**: +- **Open/Closed Principle**: Adding new API methods requires modifying `api.js` and ensuring no name collisions with `http.js` or `auth.js` methods. +- **Interface Segregation Principle**: Callers receive a single object with 35+ methods across authentication, CRUD, query, streaming, and HTTP utilities. There is no ability to depend on a smaller interface. + +**GRASP Violation**: Low Coupling — all three modules directly use `this._getOpts` and `this._apiRequest` from each other's domains, creating invisible runtime dependencies. + +**Refactoring**: Consider the Facade pattern — expose sub-objects like `connection.auth`, `connection.api`, etc. Or document the intentional design as a deliberate API surface constraint and add an explicit module interface map. + +--- + +### 3. Missing Error on `crypto` Global in Mock — Dead Code / Hidden Dependency + +**File**: `test/mock/cometd-server.js` — Lines 201–206 +**Category**: Functional Abusers: Hidden Dependencies +**Smell**: Hidden Dependency / Implicit Global + +```js +const acceptKey = crypto + .createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-5AB5DC65C97B') + .digest('base64'); +``` + +`crypto` is used as a global variable but is never imported. The file only has `require('http')` at the top. In Node.js >= 22, the `crypto` module is available as a Web Crypto global (`globalThis.crypto`), but `globalThis.crypto.createHash` does not exist — only `globalThis.crypto.subtle` exists. The traditional Node.js `require('crypto').createHash` is a different API. This code will throw `ReferenceError: crypto is not defined` (or `TypeError: crypto.createHash is not a function`) at runtime whenever the WebSocket upgrade code path is exercised. + +This is a latent bug currently hidden because the WebSocket code path requires a WebSocket client to initiate the upgrade, which is not always exercised in the test suite. + +**Refactoring**: Add `const crypto = require('crypto');` at the top of the file. + +--- + +## Medium Severity Issues (Design Problems) + +### 4. Whitespace Missing After Assignment Operator — Inconsistent Style + +**File**: `lib/api.js` — Lines 226, 240–241, 255–257, 271–272 +**Category**: Lexical Abusers: Inconsistent Style +**Smell**: Inconsistent Style / Formatting + +```js +const type =opts.sobject.getType(); // Line 226 +const type =opts.sobject.getType(); // Line 240 +const id =opts.sobject.getId(); // Line 241 +const type =opts.sobject.getType(); // Line 255 +const extId =opts.sobject.getExternalId(); // Line 257 +const type =opts.sobject.getType(); // Line 271 +const id =opts.sobject.getId(); // Line 272 +``` + +Seven consecutive assignments are missing a space between `=` and the left-hand side token. All other assignments in the file use standard spacing (`const type = opts.sobject.getType()`). This is an inconsistent style smell that suggests copy-paste construction without a final formatting pass. + +**Note**: ESLint `space-around-ops` or Prettier would catch this automatically. + +--- + +### 5. Redundant Intermediate Variable in `createSObject` — Dispensables: Lazy Element + +**File**: `index.js` — Lines 81–87 +**Category**: Dispensables +**Smell**: Lazy Element (unnecessary intermediate variable) + +```js +const createSObject = (type, fields) => { + const data = fields || {}; + data.attributes = { + type: type, + }; + const rec = new Record(data); + return rec; // <-- rec is only used to return it +}; +``` + +The variable `rec` is declared solely to be returned on the very next line. It adds no clarifying value and is a classic Lazy Element. The simpler form is `return new Record(data)`. + +--- + +### 6. Unnecessary `Promise.resolve()` Wrapping — Functional Abusers: Dispensable Code + +**File**: `lib/auth.js` — Lines 187, 133 +**Category**: Dispensables +**Smell**: Redundant `Promise.resolve()` in a `.then()` chain + +```js +// Line 187 (authenticate) +return Promise.resolve(newOauth); + +// Line 133 (_notifyAndResolve) +return Promise.resolve(newOauth); +``` + +Both occurrences are inside `.then()` callbacks. A `.then()` callback's return value is already automatically wrapped in a resolved promise if it is not a promise itself. `return Promise.resolve(newOauth)` is functionally identical to `return newOauth` in this context but adds noise. + +--- + +### 7. Duplicated Request-Building Logic — Duplicated Code + +**File**: `lib/api.js` — Lines 85–92 (updatePassword), 221–231 (insert), 238–246 (update), 253–262 (upsert), 269–276 (delete) +**Category**: Dispensables +**Smell**: Duplicated Code + +The pattern of `this._getOpts(data)`, resolve type/id, set `opts.resource`, set `opts.method`, call `this._apiRequest(opts)` repeats in nearly identical form across 15+ functions. While each function has a unique resource path and method, the structural scaffolding is boilerplate. The `applyBody` helper already partially addresses this for CRUD methods. The `_urlRequest` private helper already demonstrates that this refactoring can be applied. Consider a more generalized request-builder that composes resource, method, and body from declarative specs. + +--- + +### 8. `_queryHandler` Closes Over Mutable External Array — Data Dealers: Mutable Data + +**File**: `lib/api.js` — Lines 429–460 +**Category**: Data Dealers +**Smell**: Mutable Data / Side Effects + +```js +const _queryHandler = function (data) { + const recs = []; // mutable accumulator + ... + const handleResponse = (respCandidate) => { + ... + resp.records.forEach((r) => { + recs.push(opts.raw ? r : Record.fromResponse(r)); // mutates outer array + }); + ... + resp.records = recs; // mutates the API response object too + return resp; + }; +``` + +The `recs` array is declared in the outer function and mutated by the inner `handleResponse` closure across potentially multiple recursive calls (when `fetchAll: true`). Mutating `resp.records` also modifies the API response object in place rather than returning a new object. For single-page queries this is harmless, but the pattern is fragile: if `handleResponse` is ever called concurrently or reused, the accumulated `recs` state would be incorrect. + +**Refactoring**: Carry the accumulator as an explicit parameter to `handleResponse` rather than closing over a mutable variable. + +--- + +### 9. `_notifyAndResolve` Uses Callback-in-Promise Anti-Pattern — Change Preventers: Callback Hell + +**File**: `lib/auth.js` — Lines 124–134 +**Category**: Change Preventers +**Smell**: Callback Hell (mixing callback and Promise styles within async API) + +```js +const _notifyAndResolve = function (newOauth, oldOauth) { + if (this.onRefresh) { + return new Promise((resolve, reject) => { + this.onRefresh.call(this, newOauth, oldOauth, (err) => { // callback inside Promise + if (err) reject(err); + else resolve(newOauth); + }); + }); + } + return Promise.resolve(newOauth); +}; +``` + +The `onRefresh` API surface is callback-based (the function receives `(newOauth, oldOauth, callback)`) while the rest of the library is promise-based. This is an inconsistency that forces users who implement `onRefresh` to use old-style callbacks, and creates a seam where callback errors must be manually promisified. The test for this in `test/connection.js` lines 284–297 confirms the callback contract is tested and intentional, but it represents a mixed paradigm in an otherwise promise-only library. + +**Refactoring**: Accept `onRefresh` as either a callback or a function returning a Promise, using `Promise.resolve(this.onRefresh(...))` and gracefully handling both patterns. + +--- + +### 10. `upsert` Does Not Use `applyBody` Helper — Oddball Solution + +**File**: `lib/api.js` — Lines 253–262 +**Category**: Other: Oddball Solution +**Smell**: Inconsistent approach to the same problem + +```js +const upsert = function (data) { + const opts = this._getOpts(data); + ... + opts.body = JSON.stringify(opts.sobject.toPayload()); // direct serialization + return this._apiRequest(opts); +}; +``` + +The `insert` and `update` functions use the `applyBody(opts, type, payloadFn)` helper to correctly handle multipart content types for Document/Attachment/ContentVersion SObjects. The `upsert` function bypasses `applyBody` and directly sets `opts.body`, meaning that upserting a Document or ContentVersion with binary data would silently produce an incorrect JSON-only request body instead of the required multipart form. + +**Impact**: Functional bug risk for multipart upsert operations. + +--- + +### 11. `getAuthUri` Builds Options Object with Conditional Property Mutation — Conditional Complexity + +**File**: `lib/auth.js` — Lines 67–115 +**Category**: Obfuscators: Conditional Complexity +**Smell**: Conditional Complexity (8 sequential if-blocks) + +```js +const getAuthUri = function (opts = {}) { + let urlOpts = { response_type, client_id, redirect_uri }; + if (opts.display) { ... } + if (opts.immediate) { ... } + if (opts.scope) { if (Array.isArray) ... else ... } + if (opts.state) { ... } + if (opts.nonce) { ... } + if (opts.prompt) { if (Array.isArray) ... else ... } + if (opts.loginHint) { ... } + if (opts.urlOpts) { ... } + return endpoint + '?' + new URLSearchParams(urlOpts).toString(); +}; +``` + +Eight consecutive conditional blocks all perform the same operation (conditionally copying a property from `opts` to `urlOpts`). The pattern is highly regular and can be collapsed using a declarative mapping: + +```js +const copyIfPresent = ['display', 'immediate', 'scope', 'state', 'nonce', 'prompt']; +``` + +Combined with `URLSearchParams` which already handles arrays, this could reduce to a few lines. + +--- + +### 12. Mixed Quote Styles in `cometd.js` vs Rest of Codebase — Inconsistent Style + +**File**: `lib/cometd.js` — Multiple lines +**Category**: Lexical Abusers: Inconsistent Style +**Smell**: Inconsistent Style + +The file `lib/cometd.js` uses double-quoted strings (`"use strict"`, `"Content-Type"`, `"websocket"`, `"long-polling"`) throughout, while all other library files consistently use single quotes (`'use strict'`, `'content-type'`). The ESLint config enforces single quotes (`quotes: ['error', 'single']`). This suggests `cometd.js` was written or ported with different style settings and may not be passing lint cleanly. + +--- + +### 13. `_connectWebSocket` Promise Never Rejects on Error — Afraid to Fail + +**File**: `lib/cometd.js` — Lines 229–267 +**Category**: Other: Afraid to Fail +**Smell**: Silent error absorption + +```js +_connectWebSocket() { + return new Promise((resolve) => { // no reject parameter + ... + this._ws.addEventListener("error", () => { + if (!this._connected) { + // Failed to connect — fall back to long-polling + this._transport = "long-polling"; + this._ws = null; + resolve(); // <-- resolves silently even on error + } + }); + }); +} +``` + +The `error` event handler silently falls back to long-polling and resolves the promise. The caller in `connect()` also wraps this in a try/catch that similarly swallows errors and falls back. While graceful degradation is intentional, there is no mechanism for callers to know that WebSocket negotiation failed — the `transport:up` event fires whether or not WebSocket was actually used, and no `warning` or `info` event is emitted. + +--- + +### 14. `respToJson` is a Private Helper Exported by Naming Convention Only — Indecent Exposure + +**File**: `lib/api.js` — Lines 417–426 +**Category**: Object-Oriented Abusers: Indecent Exposure +**Smell**: Internal implementation detail not protected from external access + +```js +const respToJson = (respCandidate) => { ... }; +``` + +`respToJson` is not exported, which is correct. However, several internal helpers follow no consistent naming convention. Some private methods are prefixed with `_` (e.g., `_getOpts`, `_apiRequest`, `_apiAuthRequest`, `_queryHandler`) while others are not (`respToJson`, `resolveId`, `resolveType`, `sobjectPath`, `applyBody`, `requireForwardSlash`). These module-private functions are not accessible externally due to CommonJS scoping, but the inconsistency makes it unclear which helpers are candidates for future export. + +--- + +## Low Severity Issues (Readability / Maintenance) + +### 15. `let` Used for Variables That Are Never Reassigned — Uncommunicative Name / Style + +**File**: `lib/cometd.js` — Lines 75, 90 +**File**: `lib/api.js` — Lines 19, 444 +**File**: `lib/optionhelper.js` — Lines 88, 90 +**File**: `lib/auth.js` — Line 68 +**File**: `lib/util.js` — Line 14 +**Category**: Lexical Abusers: Inconsistent Style + +```js +// lib/cometd.js:75 +let msg = message; // msg is reassigned in the for-loop below — correct use of let + +// lib/api.js:19 +let data = {}; // data IS reassigned (line 23 or 25) — correct use of let + +// lib/optionhelper.js:88 +let result = new URL(opts.uri); // result is never reassigned — should be const + +// lib/optionhelper.js:90 +let params = opts.qs; // params is never reassigned — should be const + +// lib/auth.js:68 +let urlOpts = { ... }; // urlOpts IS mutated with property assignment — acceptable + +// lib/util.js:14 +let headerContent; // headerContent IS conditionally reassigned — correct +``` + +`lib/optionhelper.js` lines 88 and 90 use `let` for values that are never reassigned, which should be `const`. Using `const` where possible communicates immutability intent clearly. + +--- + +### 16. `What Comment` — Comments Explaining "What" Instead of "Why" + +**File**: `lib/api.js` — Lines 196–198 +**File**: `lib/cometd.js` — Lines 350–351, 361–362 +**Category**: Other: What Comment + +```js +/* + * CRUD methods + */ + +// Apply advice interval before next connect + +// Dispatch any data messages piggybacked on the connect response +``` + +These comments describe the obvious code operation ("CRUD methods" before CRUD methods, "apply advice interval" before a delay call). More valuable comments would explain *why* this code structure was chosen — e.g., why the advice interval must be applied between connect loops, or why data messages may be piggybacked on connect responses (CometD protocol reasoning). + +--- + +### 17. `Fallacious Comment` — Doc Comment Says "discovered on the header" but Is Unclear + +**File**: `lib/api.js` — Lines 413–415 +**Category**: Lexical Abusers: Fallacious Comment + +```js +/** + * If it hasn't been discovered on the header, try to convert it to object here. +``` + +The phrase "discovered on the header" is opaque. The actual intent is: "if the response body was not automatically parsed as JSON (based on Content-Type header), attempt manual parsing." The comment's use of "header" is ambiguous — it could mean response header, file header, or section header. + +--- + +### 18. Test Uses `should.not.exist(err)` in `.catch()` as Error Suppressor — Afraid to Fail (Test Smell) + +**File**: `test/crud.js` — Lines 68–70, 93–95, 123, 146 +**File**: `test/query.js` — Multiple lines +**Category**: Other: Afraid to Fail + +```js +.catch((err) => should.not.exist(err)) +.finally(() => done()); +``` + +This pattern appears 13 times across test files. The intent is to fail the test if an error is thrown, but the pattern is fragile: `.catch()` receives the error and `should.not.exist(err)` throws a new assertion error. However, this new error is swallowed by `.finally()` which unconditionally calls `done()`. In practice, the test will pass even if `should.not.exist` throws, because `done()` is called without the error. + +The correct pattern for Mocha promise-based tests is simply `return promise` (Mocha 6+ handles promise rejections as failures), or `return promise.should.be.fulfilled()`. + +**Impact**: Tests that should fail on unexpected errors may pass silently. + +--- + +### 19. Integration Test Uses `describe.skip` Pattern With Redundant Dead Code + +**File**: `test/integration.js` — Lines 7–21 +**Category**: Dispensables: Dead Code + +```js +let client = undefined; // initialization to undefined is unnecessary + +(checkEnvCredentials() ? describe : describe.skip)( + 'Integration Test against an actual Salesforce instance', + () => { + before(() => { + let creds = checkEnvCredentials(); // checkEnvCredentials called a second time + if (creds == null) { + // Can't run integration tests + // Mocha.suite.skip(); // commented-out dead code +``` + +Issues: +1. `let client = undefined` — explicit `undefined` initialization adds noise; `let client;` suffices +2. `checkEnvCredentials()` is called twice: once at the describe level and once inside `before()`. The second call is redundant since if it returned falsy at describe-time, the entire suite is skipped. +3. The commented-out `// Mocha.suite.skip()` is dead code that should be removed. + +--- + +### 20. `findId` Hard-Codes Three String Variants of "id" — Magic Number / Primitive Obsession + +**File**: `lib/util.js` — Lines 58–63 +**Category**: Lexical Abusers: Magic Number, Data Dealers: Primitive Obsession + +```js +const flavors = ['Id', 'id', 'ID']; + +for (let flavor of flavors) { + if (data[flavor]) { + return data[flavor]; + } +} +``` + +The array `['Id', 'id', 'ID']` is an inline magic literal. The name `flavors` is also slightly uncommunicative — `ID_VARIANTS` or `ID_FIELD_NAMES` would be clearer. Additionally, `if (data[flavor])` is falsy-checking: if a Salesforce ID were somehow `0` or an empty string, it would be skipped. For IDs this is practically impossible, but `data[flavor] !== undefined` would be more semantically precise. + +--- + +### 21. `checkHeaderCaseInsensitive` Name Complexity — Uncommunicative / Long Name + +**File**: `lib/util.js` — Line 11 +**Category**: Lexical Abusers: Uncommunicative Name + +```js +const checkHeaderCaseInsensitive = (headers, key, searchfor) => { +``` + +`checkHeaderCaseInsensitive` is verbose. The parameter `searchfor` is non-standard (typically `searchFor` or `substring`). The function is private and does a substring search — a more descriptive name would be `headerContains(headers, key, substring)`. + +--- + +### 22. `_apiAuthRequest` Directly Embeds OAuth Cache Side Effect — Mutable Data / Side Effects + +**File**: `lib/http.js` — Lines 139–141 +**Category**: Data Dealers: Mutable Data, Functional Abusers: Side Effects + +```js +.then((jBody) => { + if (jBody.access_token && this.mode === CONST.SINGLE_MODE) { + Object.assign(this.oauth || (this.oauth = {}), jBody); // side effect: mutates this.oauth + } + return jBody; +}); +``` + +The HTTP layer (`http.js`) directly modifies `this.oauth` on the connection object as a side effect of any successful auth request. This couples the transport layer to connection state management. The mutation `(this.oauth = {})` inside the `||` expression is particularly obscure — it simultaneously assigns and uses the assignment as a fallback, making the code harder to read. The side effect violates the principle that HTTP request functions should return data and let callers decide what to do with it. + +--- + +### 23. `_connectLoop` Silently Suppresses All Catch Errors — Afraid to Fail + +**File**: `lib/cometd.js` — Lines 365–370 +**Category**: Other: Afraid to Fail + +```js +} catch { + if (this._disconnecting) return; + this._connected = false; + this.emit("transport:down"); + this._scheduleReconnect(); + return; +} +``` + +The `catch` block in `_connectLoop` catches all errors from the connect loop iteration with no logging or error event. The error is completely dropped. While `transport:down` is emitted and reconnection is scheduled, consumers have no way to inspect what error caused the disconnect. Consider emitting the error alongside `transport:down`, or buffering the last error for inspection. + +--- + +### 24. `_scheduleReconnect` Duplicates Subscription Re-Subscribe Logic — Duplicated Code + +**File**: `lib/cometd.js` — Lines 395–425 +**File**: `lib/cometd.js` — Lines 378–390 (`_rehandshake`) + +Both `_scheduleReconnect` (line 416–420) and `_rehandshake` (lines 382–386) contain the exact same loop: + +```js +for (const topic of this._subscriptions.keys()) { + await this._sendSubscribe(topic); +} +``` + +This duplicated re-subscription logic should be extracted into a `_resubscribeAll()` method. + +--- + +### 25. `_handleWsUpgrade` Creates Mock WebSocket Wrapper With Inline `require` — Clever Code + +**File**: `test/mock/cometd-server.js` — Line 277 +**Category**: Obfuscators: Clever Code + +```js +const emitter = new (require('events').EventEmitter)(); +``` + +An inline `require()` inside a method body is unusual. `EventEmitter` should be required once at the top of the file with `const EventEmitter = require('events').EventEmitter` or `const { EventEmitter } = require('events')`. The inline form is a minor cleverness that adds friction when reading. + +--- + +### 26. `getAuthUri` Converts Scope/Prompt Arrays to Space-Joined Strings but URLSearchParams Will Encode Differently + +**File**: `lib/auth.js` — Lines 83–103 +**Category**: Obfuscators: Obscured Intent + +```js +if (opts.scope) { + if (Array.isArray(opts.scope)) { + urlOpts.scope = opts.scope.join(' '); + } else { + urlOpts.scope = opts.scope; + } +} +``` + +The manual `join(' ')` followed by `URLSearchParams` encoding produces `scope=visualforce+web` (plus-encoded space). The test at `test/connection.js:204` confirms this with `uri.should.match(/.*scope=visualforce(\+|%20)web.*/)`. The ambiguity in the regex (`+` or `%20`) hints at uncertainty about the encoding behavior. Consider documenting the encoding contract or using `URLSearchParams`'s built-in array handling if supported. + +--- + +### 27. Test File Accesses Private `_fields`, `_changed`, `_previous` Properties Directly + +**File**: `test/record.js` — Lines 41, 49, 109, 117, 172 +**File**: `test/connection.js` — Line 147–150 +**Category**: Couplers: Insider Trading + +```js +// test/record.js:41 +Object.keys(acc._fields).forEach(function (key) { ... }); + +// test/record.js:49 +acc._changed.size.should.equal(2); + +// test/connection.js:148 +obj._fields.should.have.property('name'); +obj._fields.name.should.equal('Test Me'); +obj._getPayload(false); +``` + +Tests directly access `_fields`, `_changed`, `_previous`, and `_getPayload` (a private-by-convention method). This tightly couples tests to internal implementation details, meaning any refactoring of Record internals — even while preserving the public API — requires updating tests. The `_getPayload` method is also called directly in tests, which is intentional for coverage but means the "private" prefix is effectively ignored. + +--- + +### 28. `plugin.js` Accepts Both String and Object Input — Flag Argument / Inconsistent API + +**File**: `lib/plugin.js` — Lines 35–39 +**Category**: Obfuscators: Flag Argument, Lexical Abusers: Inconsistent Names + +```js +const plugin = (opts) => { + if (typeof opts === 'string') { + opts = { namespace: opts }; + } + ... +}; +``` + +The `plugin()` function accepts either a string (namespace only) or an object with a `namespace` property. This dual-input API creates implicit overloading and can obscure intent at call sites. The test in `test/plugin.js` exercises both forms. A more explicit API would be `plugin(namespace, options = {})`. + +--- + +### 29. `getFullUri` Returns a `URL` Object but Callers Pass It to `fetch` — Implicit Type Contract + +**File**: `lib/optionhelper.js` — Lines 87–96 +**File**: `lib/http.js` — Line 161 +**Category**: Other: Obscured Intent + +```js +// optionhelper.js +function getFullUri(opts) { + let result = new URL(opts.uri); // returns URL object + ... + return result; +} + +// http.js +const uri = optionHelper.getFullUri(ropts); // receives URL object +return fetch(uri, ropts); // passes URL object to fetch +``` + +The function name `getFullUri` implies it returns a string URI, but it returns a `URL` object. While `fetch` accepts both `string` and `URL`, the naming creates a mismatch between expectation and reality. Consider renaming to `buildUrl` or `getFullUrl` to match the actual return type. + +--- + +### 30. `constants.js` Comment Is a Manual Reminder Rather Than Automation + +**File**: `lib/constants.js` — Line 16 +**Category**: Other: What Comment / Technical Debt Marker + +```js +// This needs update for each SFDC release! +const API_PACKAGE_VERSION = require('../package.json').sfdx.api; +``` + +The comment "This needs update for each SFDC release!" is a manual process reminder embedded in code. The `package.json` approach of reading `sfdx.api` and allowing `SFDC_API_VERSION` environment variable override is actually a reasonable automated solution. The comment is therefore misleading — the real update procedure is editing `package.json`, not this line. The comment should be updated or removed. + +--- + +## Detailed Findings by File + +### `lib/api.js` — 7 issues +- **Whitespace missing after `=`** (Lines 226, 240, 241, 255, 257, 271, 272): Medium — Inconsistent Style +- **`respToJson` helper** (Lines 417–426): Low — unclear comment +- **`_queryHandler` mutable closure** (Lines 429–460): Medium — Mutable Data +- **Duplicated request scaffolding** (Multiple): Medium — Duplicated Code +- **`upsert` bypasses `applyBody`** (Lines 253–262): Medium — Oddball Solution / Bug Risk +- **`getUrl`/`putUrl`/`postUrl`/`deleteUrl` as thin wrappers**: Low — Middle Man (minor) +- **`respToJson` naming**: Low — Uncommunicative Name + +### `lib/auth.js` — 4 issues +- **`_notifyAndResolve` mixes callback/Promise** (Lines 124–134): Medium — Callback Hell +- **Redundant `Promise.resolve()`** (Lines 133, 187): Medium — Dispensable Code +- **`getAuthUri` conditional complexity** (Lines 67–115): Medium — Conditional Complexity +- **Scope/prompt array encoding ambiguity** (Lines 83–103): Low — Obscured Intent + +### `lib/http.js` — 2 issues +- **`_apiAuthRequest` OAuth mutation side effect** (Lines 139–141): Medium — Side Effects +- **`responseFailureCheck` placed above doc comment block** (Line 13): Low — formatting + +### `lib/cometd.js` — 4 issues +- **Double-quoted strings** (Multiple): Medium — Inconsistent Style +- **`_connectWebSocket` swallows WebSocket errors** (Lines 247–254): Medium — Afraid to Fail +- **`_connectLoop` catch drops error** (Lines 365–370): Low — Afraid to Fail +- **Duplicated re-subscription loop** (Lines 382–386, 416–420): Low — Duplicated Code + +### `lib/optionhelper.js` — 2 issues +- **`let` instead of `const`** (Lines 88, 90): Low — Inconsistent Style +- **`getFullUri` returns URL but named as URI** (Lines 87–96): Low — Uncommunicative Name + +### `lib/util.js` — 2 issues +- **`checkHeaderCaseInsensitive` verbose name** (Line 11): Low — Uncommunicative Name +- **`findId` inline magic array** (Lines 58–63): Low — Magic Literal + +### `lib/record.js` — 0 significant issues +This file is well-designed. The change-tracking with `Set` and `_previous` object is clean and explicit. + +### `lib/constants.js` — 1 issue +- **Misleading update comment** (Line 16): Low — What Comment + +### `lib/plugin.js` — 1 issue +- **Dual-input API** (Lines 35–39): Low — Flag Argument + +### `lib/connection.js` — 0 significant issues +Clean validation module. + +### `lib/errors.js` — 0 significant issues +Minimal and appropriate. + +### `lib/fdcstream.js` — 0 significant issues +Clean adapter pattern. + +### `lib/multipart.js` — 0 significant issues +Clear and focused. + +### `index.js` — 2 issues +- **Prototype mixin architecture** (Line 52): High — Divergent Change / ISP violation +- **Redundant intermediate variable in `createSObject`** (Lines 85–86): Low — Lazy Element + +### `test/mock/sfdc-rest-api.js` — 1 issue +- **Module-level global mutable state** (Lines 5–6): High — Global Data + +### `test/mock/cometd-server.js` — 2 issues +- **Missing `crypto` import** (Lines 201–206): High — Hidden Dependency / Latent Bug +- **Inline `require` in method** (Line 277): Low — Clever Code + +### `test/crud.js` — 1 issue +- **`.catch((err) => should.not.exist(err))` pattern** (Lines 68, 93, 123, 146): Medium — Afraid to Fail + +### `test/query.js` — 1 issue +- **`.catch((err) => should.not.exist(err))` pattern** (9 occurrences): Medium — Afraid to Fail + +### `test/record.js` — 1 issue +- **Direct access to private `_fields`, `_changed`, `_previous`** (Lines 41, 49, 109): Low — Insider Trading + +### `test/connection.js` — 1 issue +- **Direct access to `_fields` and `_getPayload`** (Lines 147–150): Low — Insider Trading + +### `test/integration.js` — 1 issue +- **Dead code and redundant call** (Lines 7–21): Low — Dead Code + +--- + +## SOLID Principle Compliance + +| Principle | Score (0–10) | Notes | +|-----------|-------------|-------| +| **S** — Single Responsibility | 7/10 | Each module has a clear domain, but `api.js` (649 lines) spans CRUD, query, search, streaming, and URL helpers. The prototype mixin blurs responsibility on `Connection`. | +| **O** — Open/Closed | 7/10 | Plugin system is extensible without modification. Adding new API methods requires touching `api.js`. | +| **L** — Liskov Substitution | 9/10 | No inheritance hierarchies; records behave consistently. | +| **I** — Interface Segregation | 6/10 | `Connection` exposes 35+ methods as a single flat object. Consumers cannot depend on a subset interface. | +| **D** — Dependency Inversion | 7/10 | HTTP layer is reasonably abstracted. `_apiRequest`/`_apiAuthRequest` are injectable via prototype. `cometd.js` uses native `fetch` and `WebSocket` globals directly. | + +--- + +## GRASP Principle Compliance + +| Principle | Assessment | +|-----------|-----------| +| **Information Expert** | Good — `Record` owns its own field logic; `optionhelper` owns URI construction. | +| **Creator** | Acceptable — `createSObject` in `index.js` creates Records; `createStreamClient` creates streaming clients. | +| **Controller** | `Connection` acts as the system controller for all Salesforce operations — clear boundary. | +| **Low Coupling** | Moderate — `api.js` methods implicitly depend on `this._getOpts`, `this._apiRequest` from http module. | +| **High Cohesion** | `api.js` has lower cohesion (CRUD + query + search + streaming). Other modules are cohesive. | +| **Polymorphism** | Limited OOP — no polymorphism needed in this domain-specific library. | +| **Pure Fabrication** | `optionhelper`, `multipart`, `errors` are appropriate pure fabrications without domain coupling. | +| **Indirection** | Good — `http.js` provides indirection over `fetch`. `fdcstream.js` provides indirection over `cometd.js`. | +| **Protected Variations** | Plugin system protects against future extension needs. API version as config protects against API changes. | + +--- + +## Impact Assessment + +**Total Issues**: 30 +**Breakdown by Severity**: +- High Severity: 3 (Architectural / Latent Bug) +- Medium Severity: 11 (Design Impact) +- Low Severity: 16 (Readability/Maintenance) + +**Breakdown by Category**: +- Lexical Abusers (Naming/Style): 7 +- Data Dealers (Mutable/Global Data): 5 +- Dispensables (Dead/Redundant Code): 5 +- Functional Abusers (Side Effects): 3 +- Object-Oriented Abusers: 2 +- Change Preventers: 2 +- Obfuscators: 3 +- Couplers: 2 +- Other: 1 + +--- + +## Recommendations and Refactoring Roadmap + +### Phase 1 — Immediate (Bugs and High Risk) + +1. **Add `const crypto = require('crypto');`** to `test/mock/cometd-server.js` — this is a latent `ReferenceError` waiting to surface when WebSocket tests run in environments where `globalThis.crypto.createHash` is undefined. + +2. **Fix `.catch((err) => should.not.exist(err)).finally(done)` test pattern** in `test/crud.js` and `test/query.js` — replace with `return promise` (Mocha handles promise rejections) or use `.should.be.fulfilled()`. + +3. **Fix `upsert` to use `applyBody` helper** in `lib/api.js` line 260 — this prevents a silent bug where upserting binary SObjects (Document/ContentVersion) produces incorrect JSON-only requests. + +### Phase 2 — Short-Term (Medium Severity Design) + +4. **Fix whitespace in assignments** in `lib/api.js` lines 226, 240–241, 255–257, 271–272 — add spaces after `=`. + +5. **Extract `_resubscribeAll()` method** in `lib/cometd.js` — eliminate the duplicated subscription re-subscription loop. + +6. **Convert `cometd.js` to single-quoted strings** to match the rest of the codebase and satisfy the ESLint `quotes` rule. + +7. **Replace mutable query accumulator** in `_queryHandler` with functional reduce or explicit parameter passing. + +8. **Remove redundant `Promise.resolve(newOauth)` wrapping** in `lib/auth.js` — return `newOauth` directly in `.then()` callbacks. + +### Phase 3 — Long-Term (Architectural Improvements) + +9. **Consider whether `api.js` should be split** — CRUD methods, query/search, streaming setup, and utility URL methods are distinct concerns. A split would improve cohesion even if the public API surface stays the same. + +10. **Migrate `onRefresh` to accept both callback and Promise** — reduces friction for library users who prefer the Promise-only API already offered everywhere else. + +11. **Refactor mock server to class-based isolation** — eliminate module-level global state in `test/mock/sfdc-rest-api.js`. + +--- + +## Prevention Strategies + +- **Formatting**: Add Prettier or enforce `eslint --fix` in pre-commit hooks to prevent spacing inconsistencies. +- **Quote style**: Configure ESLint to auto-fix `cometd.js` quote style. +- **Test patterns**: Add an ESLint custom rule or code review checklist item for the `.catch(should.not.exist)` anti-pattern. +- **API contracts**: Add JSDoc `@returns` type annotations to all public methods, making the `URL` vs `string` return type of `getFullUri` explicit and detectable. + +--- + +## Appendix: Analyzed Files + +| File | Lines | Issues Found | +|------|-------|-------------| +| `index.js` | 99 | 2 | +| `lib/api.js` | 649 | 7 | +| `lib/auth.js` | 300 | 4 | +| `lib/http.js` | 200 | 2 | +| `lib/cometd.js` | 535 | 4 | +| `lib/fdcstream.js` | 137 | 0 | +| `lib/record.js` | 233 | 0 | +| `lib/optionhelper.js` | 98 | 2 | +| `lib/connection.js` | 93 | 0 | +| `lib/constants.js` | 56 | 1 | +| `lib/util.js` | 106 | 2 | +| `lib/multipart.js` | 67 | 0 | +| `lib/plugin.js` | 52 | 1 | +| `lib/errors.js` | 23 | 0 | +| `test/crud.js` | 273 | 1 | +| `test/query.js` | 204 | 1 | +| `test/record.js` | 379 | 1 | +| `test/connection.js` | 324 | 1 | +| `test/errors.js` | 122 | 0 | +| `test/streaming.js` | 355 | 0 | +| `test/plugin.js` | 108 | 0 | +| `test/util.js` | 47 | 0 | +| `test/integration.js` | 55 | 1 | +| `test/mock/sfdc-rest-api.js` | 131 | 1 | +| `test/mock/cometd-server.js` | 466 | 2 | + +**Detection Methodology**: Manual static analysis using the comprehensive code smell catalog from Luzkan (2022), cross-referenced against Martin Fowler (1999/2018), Robert C. Martin (2008), and William C. Wake (2004). Language-specific thresholds applied for JavaScript/Node.js. + +**Excluded**: `examples/` (documented as snippet-style scripts not subject to standard lint rules), `node_modules/`, generated coverage reports. diff --git a/tasks/code-smell-detector-summary.md b/tasks/code-smell-detector-summary.md new file mode 100644 index 0000000..ebf8bbb --- /dev/null +++ b/tasks/code-smell-detector-summary.md @@ -0,0 +1,75 @@ +# Code Quality Summary — nforce8 + +## Critical Issues +**3 high-severity issues found — immediate attention recommended** + +### Top 3 Problems + +1. **Missing `crypto` import in mock server** — `test/mock/cometd-server.js` uses `crypto.createHash()` without importing it, causing a `ReferenceError` when WebSocket upgrade code runs. **Priority: High** + +2. **Silent test failures from flawed error-catch pattern** — 13 tests in `test/crud.js` and `test/query.js` use `.catch((err) => should.not.exist(err)).finally(done)` which calls `done()` unconditionally, masking assertion failures. **Priority: High** + +3. **`upsert()` bypasses multipart body helper** — The upsert method in `lib/api.js` directly serializes to JSON instead of calling the `applyBody` helper, silently producing incorrect requests when upserting binary SObjects (Document, ContentVersion). **Priority: High** + +--- + +## Overall Assessment + +- **Project Size**: 25 files analyzed, ~4,500 lines, 1 language (JavaScript/Node.js) +- **Code Quality Grade**: B +- **Total Issues**: 30 (High: 3 | Medium: 11 | Low: 16) +- **Overall Complexity**: Low-to-Medium + +## Business Impact + +- **Technical Debt**: Low — the codebase is modern, well-structured, and well-commented +- **Maintenance Risk**: Low — clear module boundaries, consistent patterns +- **Development Velocity Impact**: Low — the issues found are contained and well-scoped +- **Recommended Priority**: High for the 3 critical items; Medium for remainder + +--- + +## Quick Wins + +- **Add `require('crypto')` to `test/mock/cometd-server.js`**: Priority: High — prevents a latent test crash with one line +- **Fix formatting in `lib/api.js` (missing spaces after `=`)**: Priority: Low — ESLint auto-fixable, no logic change +- **Remove redundant `Promise.resolve()` wrappers in `lib/auth.js`**: Priority: Low — simplification with zero risk +- **Replace `let` with `const` in `lib/optionhelper.js`**: Priority: Low — communicates immutability, ESLint auto-fixable +- **Extract `_resubscribeAll()` method in `lib/cometd.js`**: Priority: Medium — eliminates duplicated loop in 2 places + +## Major Refactoring Needed + +- **`api.js` (649 lines) — consider splitting by concern**: Priority: Low — CRUD, query/search, streaming, and URL utilities are independent concerns that could be separate modules without changing the public API. This would improve long-term maintainability. +- **Test mock server — eliminate global mutable state**: Priority: Medium — `test/mock/sfdc-rest-api.js` uses module-level arrays shared across all tests. Converting to a class-based instance would prevent cross-test contamination. + +--- + +## Recommended Action Plan + +### Phase 1 (Immediate — Days) +- Add missing `crypto` import to `test/mock/cometd-server.js` +- Fix `upsert()` to use `applyBody` helper in `lib/api.js` +- Replace the `.catch(should.not.exist).finally(done)` anti-pattern in test files + +### Phase 2 (Short-term — Weeks) +- Run `eslint --fix` to resolve quote style in `cometd.js` and spacing in `api.js` +- Extract `_resubscribeAll()` method in `cometd.js` +- Remove redundant intermediate variables and `Promise.resolve()` wrappers + +### Phase 3 (Long-term — Next major version) +- Evaluate splitting `api.js` into cohesive sub-modules +- Migrate `onRefresh` callback to accept Promises for consistency with the rest of the API +- Convert mock server to class-based isolation to remove shared state between tests + +--- + +## Key Takeaways + +- The codebase is in good health overall — no god objects, no sprawling class hierarchies, no deep callback nesting in production code +- The highest-impact issue is a latent bug in test infrastructure (`crypto` not imported) that could cause confusing test failures +- The test error-handling pattern is the most widespread quality issue: 13 tests silently swallow assertion failures +- Style inconsistencies (quote style in `cometd.js`, spacing in `api.js`) are mechanical fixes that ESLint can auto-resolve + +--- + +*Detailed technical analysis with file-by-file findings available in `code-smell-detector-report.md`* diff --git a/tasks/refactoring-expert-data.json b/tasks/refactoring-expert-data.json new file mode 100644 index 0000000..5fa3dbd --- /dev/null +++ b/tasks/refactoring-expert-data.json @@ -0,0 +1,56 @@ +{ + "total_recommendations": 18, + "priority_matrix": [ + {"item": "R01 — Add missing require('crypto') to test/mock/cometd-server.js", "impact": "H", "complexity": "L", "risk": "L"}, + {"item": "R02 — Fix silent error swallowing in test promise chains (13 occurrences in crud.js and query.js)", "impact": "H", "complexity": "L", "risk": "L"}, + {"item": "R03 — Fix upsert() to use applyBody helper instead of direct JSON.stringify", "impact": "H", "complexity": "L", "risk": "L"}, + {"item": "R04 — Fix missing space after = operator in lib/api.js (7 occurrences)", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R05 — Extract _resubscribeAll() method in lib/cometd.js to eliminate duplicated loop", "impact": "M", "complexity": "L", "risk": "L"}, + {"item": "R06 — Remove redundant Promise.resolve() wrappers in lib/auth.js", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R07 — Fix double-quote style in lib/cometd.js to match single-quote ESLint rule", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R08 — Hoist inline require('events') to top-level import in test/mock/cometd-server.js", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R09 — Modernise onRefresh in lib/auth.js to accept Promise-returning functions", "impact": "M", "complexity": "M", "risk": "M"}, + {"item": "R10 — Decompose getAuthUri 8-block conditional in lib/auth.js using declarative mapping", "impact": "M", "complexity": "L", "risk": "L"}, + {"item": "R11 — Inline redundant rec intermediate variable in createSObject (index.js)", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R12 — Replace inline ['Id','id','ID'] magic array with named constant ID_FIELD_VARIANTS in lib/util.js", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R13 — Rename checkHeaderCaseInsensitive to headerContains in lib/util.js", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R14 — Replace let with const for non-reassigned variables in lib/optionhelper.js", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R15 — Rename getFullUri to buildUrl in lib/optionhelper.js to match URL object return type", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R16 — Propagate caught errors to transport:down event in lib/cometd.js _connectLoop", "impact": "M", "complexity": "L", "risk": "L"}, + {"item": "R17 — Remove dead code in test/integration.js (undefined init, redundant call, commented code)", "impact": "L", "complexity": "L", "risk": "L"}, + {"item": "R18 — Convert test/mock/sfdc-rest-api.js global mutable state to class-based instance", "impact": "M", "complexity": "M", "risk": "M"} + ], + "risk_distribution": { + "low": 16, + "medium": 2, + "high": 0 + }, + "category_distribution": { + "Composing Methods": 7, + "Simplifying Method Calls": 3, + "Organizing Data": 1, + "Moving Features": 1, + "Simplifying Conditionals": 1, + "Style and Mechanical": 5 + }, + "implementation_sequence": [ + {"order": 1, "item": "R01 — Add missing require('crypto') to test/mock/cometd-server.js", "rationale": "Latent crash on WebSocket upgrade path; one-line fix with zero risk that unblocks reliable test execution"}, + {"order": 2, "item": "R03 — Fix upsert() to use applyBody helper instead of direct JSON.stringify", "rationale": "Correct functional bug before fixing test error masking, so any newly surfaced test failures are distinguishable as pre-existing production bugs"}, + {"order": 3, "item": "R02 — Fix silent error swallowing in test promise chains (13 occurrences)", "rationale": "Apply after R03 so failures revealed by this fix map to real production bugs rather than test infrastructure issues"}, + {"order": 4, "item": "R04 — Fix missing space after = operator in lib/api.js (7 occurrences)", "rationale": "Zero-risk ESLint auto-fix; bundle with other ESLint passes in same session"}, + {"order": 5, "item": "R07 — Fix double-quote style in lib/cometd.js to match single-quote ESLint rule", "rationale": "Zero-risk ESLint auto-fix; bundle with R04 pass"}, + {"order": 6, "item": "R14 — Replace let with const for non-reassigned variables in lib/optionhelper.js", "rationale": "Zero-risk ESLint auto-fix; bundle with R04 and R07 pass to complete all ESLint fixable items in one go"}, + {"order": 7, "item": "R05 — Extract _resubscribeAll() method in lib/cometd.js", "rationale": "Extract Method with clear scope and existing tests; eliminates duplication before any further cometd.js changes"}, + {"order": 8, "item": "R08 — Hoist inline require('events') to top-level import in test/mock/cometd-server.js", "rationale": "Best applied alongside R01 which already touches the same file's import section"}, + {"order": 9, "item": "R06 — Remove redundant Promise.resolve() wrappers in lib/auth.js", "rationale": "Trivial inline; apply before R09 and R10 which also touch auth.js to keep that file's diff coherent"}, + {"order": 10, "item": "R11 — Inline redundant rec intermediate variable in createSObject (index.js)", "rationale": "Trivial one-line cleanup with no dependencies; low cognitive load"}, + {"order": 11, "item": "R12 — Replace inline ['Id','id','ID'] magic array with named constant in lib/util.js", "rationale": "Bundle util.js changes together; apply before R13 which also touches util.js"}, + {"order": 12, "item": "R13 — Rename checkHeaderCaseInsensitive to headerContains in lib/util.js", "rationale": "Apply after R12 to complete all util.js changes in one file session"}, + {"order": 13, "item": "R15 — Rename getFullUri to buildUrl in lib/optionhelper.js", "rationale": "Two-file rename (optionhelper + http); apply in its own focused change to keep diff reviewable"}, + {"order": 14, "item": "R10 — Decompose getAuthUri 8-block conditional in lib/auth.js", "rationale": "Algorithm refactor with existing test coverage; apply after R06 cleans up the same file"}, + {"order": 15, "item": "R16 — Propagate caught errors to transport:down event in lib/cometd.js _connectLoop", "rationale": "Apply after cometd.js style fixes (R05, R07) are in; adds error argument to existing event emission"}, + {"order": 16, "item": "R17 — Remove dead code in test/integration.js", "rationale": "Low-priority cleanup; apply in final housekeeping pass to avoid conflating with functional changes"}, + {"order": 17, "item": "R09 — Modernise onRefresh in lib/auth.js to accept Promise-returning functions", "rationale": "Additive API change requiring documentation update; schedule with next version planning to coordinate release notes"}, + {"order": 18, "item": "R18 — Convert test/mock/sfdc-rest-api.js global mutable state to class-based instance", "rationale": "Broad test infrastructure change touching every test file; schedule as dedicated sprint to allow focused review and rollback if needed"} + ] +} diff --git a/test/connection.js b/test/connection.js index abc794f..615f82f 100644 --- a/test/connection.js +++ b/test/connection.js @@ -310,6 +310,49 @@ describe('index', function () { (err) => { err.message.should.equal('refresh failed'); } ); }); + + it('should accept a promise-returning onRefresh function', function () { + let refreshCalled = false; + let org = makeOrg({ + onRefresh: async function (newOauth, oldOauth) { + refreshCalled = true; + newOauth.access_token.should.equal('new_token'); + oldOauth.access_token.should.equal('old_token'); + } + }); + let newOauth = { access_token: 'new_token' }; + let oldOauth = { access_token: 'old_token' }; + return org._notifyAndResolve(newOauth, oldOauth).then((result) => { + refreshCalled.should.be.true(); + result.access_token.should.equal('new_token'); + }); + }); + + it('should reject when async onRefresh throws', function () { + let org = makeOrg({ + onRefresh: async function () { + throw new Error('async refresh failed'); + } + }); + return org._notifyAndResolve({ access_token: 'test' }, {}).then( + () => { throw new Error('should have rejected'); }, + (err) => { err.message.should.equal('async refresh failed'); } + ); + }); + + it('should accept a sync non-callback onRefresh function', function () { + let refreshCalled = false; + let org = makeOrg({ + onRefresh: function (newOauth) { + refreshCalled = true; + newOauth.access_token.should.equal('new_token'); + } + }); + return org._notifyAndResolve({ access_token: 'new_token' }, {}).then((result) => { + refreshCalled.should.be.true(); + result.access_token.should.equal('new_token'); + }); + }); }); describe('#single-mode OAuth guard', function () { diff --git a/test/crud.js b/test/crud.js index 9fc96f2..0324ff3 100644 --- a/test/crud.js +++ b/test/crud.js @@ -3,8 +3,9 @@ const should = require('should'); const CONST = require('../lib/constants'); const apiVersion = CONST.API; -const api = require('./mock/sfdc-rest-api'); +const { MockSfdcApi } = require('./mock/sfdc-rest-api'); const port = process.env.PORT || 33333; +const api = new MockSfdcApi(port); let org = nforce.createConnection(api.getClient()); @@ -40,7 +41,7 @@ describe('api-mock-crud', () => { (() => org.insert({ oauth: oauth })).should.throw(/requires opts\.sobject/); }); - it('should create a proper request on insert', (done) => { + it('should create a proper request on insert', () => { let obj = nforce.createSObject('Account', { Name: 'Test Account', Test_Field__c: 'blah' @@ -48,7 +49,7 @@ describe('api-mock-crud', () => { let hs = { 'sforce-auto-assign': '1' }; - org + return org .insert({ sobject: obj, oauth: oauth, headers: hs }) .then((res) => { should.exist(res); @@ -64,22 +65,18 @@ describe('api-mock-crud', () => { let hKey = Object.keys(hs)[0]; should.exist(api.getLastRequest().headers[hKey]); api.getLastRequest().headers[hKey].should.equal(hs[hKey]); - }) - .catch((err) => { - should.not.exist(err); - }) - .finally(() => done()); + }); }); }); describe('#update', () => { - it('should create a proper request on update', (done) => { + it('should create a proper request on update', () => { let obj = nforce.createSObject('Account', { Name: 'Test Account', Test_Field__c: 'blah' }); obj.setId('someid'); - org + return org .update({ sobject: obj, oauth: oauth }) .then((res) => { should.exist(res); @@ -89,22 +86,18 @@ describe('api-mock-crud', () => { '/services/data/' + apiVersion + '/sobjects/account/someid' ); api.getLastRequest().method.should.equal('PATCH'); - }) - .catch((err) => { - should.not.exist(err); - }) - .finally(() => done()); + }); }); }); describe('#upsert', () => { - it('should create a proper request on upsert', (done) => { + it('should create a proper request on upsert', () => { let obj = nforce.createSObject('Account', { Name: 'Test Account', Test_Field__c: 'blah' }); obj.setExternalId('My_Ext_Id__c', 'abc123'); - org + return org .upsert({ sobject: obj, oauth: oauth }) .then((res) => { should.exist(res); @@ -119,20 +112,45 @@ describe('api-mock-crud', () => { '/sobjects/account/my_ext_id__c/abc123' ); api.getLastRequest().method.should.equal('PATCH'); + }); + }); + + it('should send multipart/form-data for ContentVersion upsert', (done) => { + let upsertResponse = { + code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: '068DEADBEEF', success: true }) + }; + let obj = nforce.createSObject('ContentVersion', { + Title: 'TestFile', + PathOnClient: 'test.txt' + }); + obj.setAttachment('test.txt', Buffer.from('binary content')); + obj.setExternalId('My_Ext_Id__c', 'ext123'); + api + .getGoodServerInstance(upsertResponse) + .then(() => org.upsert({ sobject: obj, oauth: oauth })) + .then((res) => { + should.exist(res); + res.id.should.equal('068DEADBEEF'); + let ct = api.getLastRequest().headers['content-type']; + ct.should.startWith('multipart/form-data'); + ct.should.containEql('boundary'); + api.getLastRequest().method.should.equal('PATCH'); }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + .then(() => done()) + .catch((err) => done(err)); }); }); describe('#delete', () => { - it('should create a proper request on delete', (done) => { + it('should create a proper request on delete', () => { let obj = nforce.createSObject('Account', { Name: 'Test Account', Test_Field__c: 'blah' }); obj.setId('someid'); - org + return org .delete({ sobject: obj, oauth: oauth }) .then((res) => { should.exist(res); @@ -142,9 +160,7 @@ describe('api-mock-crud', () => { '/services/data/' + apiVersion + '/sobjects/account/someid' ); api.getLastRequest().method.should.equal('DELETE'); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); }); @@ -210,27 +226,23 @@ describe('api-mock-crud', () => { }); describe('#apexRest', () => { - it('should create a proper request for a custom Apex REST endpoint', (done) => { - org + it('should create a proper request for a custom Apex REST endpoint', () => { + return org .apexRest({ uri: 'sample', oauth: oauth }) .then((res) => { should.exist(res); api.getLastRequest().url.should.equal('/services/apexrest/sample'); api.getLastRequest().method.should.equal('GET'); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should strip leading slash from uri', (done) => { - org + it('should strip leading slash from uri', () => { + return org .apexRest({ uri: '/sample', oauth: oauth }) .then((res) => { should.exist(res); api.getLastRequest().url.should.equal('/services/apexrest/sample'); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); }); diff --git a/test/errors.js b/test/errors.js index 78dcae3..827fb32 100644 --- a/test/errors.js +++ b/test/errors.js @@ -1,5 +1,6 @@ const nforce = require('../'); -const api = require('./mock/sfdc-rest-api'); +const { MockSfdcApi } = require('./mock/sfdc-rest-api'); +const api = new MockSfdcApi(33335); const should = require('should'); const errors = require('../lib/errors'); const { _buildSignal: buildSignal } = require('../lib/http'); @@ -17,6 +18,8 @@ const jsonResponse = (body, code = 200) => { }; describe('api-mock-errors', () => { + after((done) => api.stop(done)); + describe('invalid json errors', () => { it('should return invalid json error on bad json from authenticate', (done) => { let body = jsonResponse('{myproperty: \'invalid json\'$$$$'); diff --git a/test/integration.js b/test/integration.js index 119373d..a2a3f3d 100644 --- a/test/integration.js +++ b/test/integration.js @@ -3,20 +3,14 @@ const nforce = require('../'); const should = require('should'); -// The SFDC Client instance -let client = undefined; +let client; (checkEnvCredentials() ? describe : describe.skip)( 'Integration Test against an actual Salesforce instance', () => { before(() => { - let creds = checkEnvCredentials(); - if (creds == null) { - // Can't run integration tests - // Mocha.suite.skip(); - } else { - client = nforce.createConnection(creds); - } + const creds = checkEnvCredentials(); + client = nforce.createConnection(creds); }); after(() => { diff --git a/test/mock/cometd-server.js b/test/mock/cometd-server.js new file mode 100644 index 0000000..5d0660e --- /dev/null +++ b/test/mock/cometd-server.js @@ -0,0 +1,467 @@ +'use strict'; + +const EventEmitter = require('events'); +const http = require('http'); + +const DEFAULT_PORT = 34444; + +/** + * Mock CometD/Bayeux server for testing the CometD client. + * Supports both long-polling (HTTP POST) and WebSocket transports. + */ +class MockCometDServer { + constructor(port = DEFAULT_PORT) { + this.port = port; + this.server = null; + this.wss = null; + this._clientIdCounter = 0; + this._clients = new Map(); // clientId → { subscriptions: Set, ws: WebSocket|null } + this._pendingConnects = new Map(); // clientId → { res, timer } + this._advice = { reconnect: 'retry', interval: 0, timeout: 5000 }; + this._supportedTypes = ['long-polling', 'websocket']; + this._wsClients = new Set(); + } + + /** + * Start the mock server. + * @returns {Promise} + */ + start() { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => this._handleHttp(req, res)); + + // WebSocket upgrade handling + this.server.on('upgrade', (req, socket, head) => { + this._handleWsUpgrade(req, socket, head); + }); + + this.server.listen(this.port, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + } + + /** + * Stop the mock server and clean up. + * @returns {Promise} + */ + stop() { + return new Promise((resolve) => { + // Clear any pending long-poll connections + for (const [, pending] of this._pendingConnects) { + clearTimeout(pending.timer); + pending.res.end(JSON.stringify([{ + channel: '/meta/connect', + successful: false, + error: 'server shutting down' + }])); + } + this._pendingConnects.clear(); + + // Close WebSocket connections + for (const ws of this._wsClients) { + ws.close(); + } + this._wsClients.clear(); + + if (this.server) { + this.server.closeAllConnections(); + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + /** @returns {string} The base endpoint URL. */ + get endpoint() { + return `http://localhost:${this.port}/cometd`; + } + + /** + * Push an event to all clients subscribed to a topic. + * @param {string} topic - Channel path. + * @param {object} data - Event data payload. + */ + pushEvent(topic, data) { + const eventMsg = { + channel: topic, + data: data, + }; + + // Push to pending long-poll connections + for (const [clientId, pending] of this._pendingConnects) { + const client = this._clients.get(clientId); + if (client && client.subscriptions.has(topic)) { + clearTimeout(pending.timer); + this._pendingConnects.delete(clientId); + + const connectResponse = { + channel: '/meta/connect', + clientId: clientId, + successful: true, + advice: this._advice, + }; + pending.res.writeHead(200, { 'Content-Type': 'application/json' }); + pending.res.end(JSON.stringify([connectResponse, eventMsg])); + } + } + + // Push to WebSocket clients + for (const [, client] of this._clients) { + if (client.subscriptions.has(topic) && client.ws) { + client.ws.send(JSON.stringify([eventMsg])); + } + } + } + + /** + * Override the server advice sent in handshake/connect responses. + * @param {object} advice + */ + setAdvice(advice) { + Object.assign(this._advice, advice); + } + + /** + * Set the supported connection types returned in handshake. + * @param {string[]} types + */ + setSupportedTypes(types) { + this._supportedTypes = types; + } + + /** + * Handle an incoming HTTP request (Bayeux over long-polling). + */ + _handleHttp(req, res) { + if (req.method !== 'POST') { + res.writeHead(405); + res.end(); + return; + } + + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let messages; + try { + messages = JSON.parse(body); + } catch { + res.writeHead(400); + res.end('Invalid JSON'); + return; + } + + if (!Array.isArray(messages)) messages = [messages]; + + const responses = []; + let holdForConnect = false; + + for (const msg of messages) { + const result = this._processMessage(msg); + if (result === 'hold') { + // Long-poll: hold the connection until we have data + holdForConnect = true; + const clientId = msg.clientId; + const timer = setTimeout(() => { + this._pendingConnects.delete(clientId); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify([{ + channel: '/meta/connect', + clientId: clientId, + successful: true, + advice: this._advice, + }])); + }, this._advice.timeout || 30000); + this._pendingConnects.set(clientId, { res, timer }); + } else if (result) { + responses.push(result); + } + } + + if (!holdForConnect && responses.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(responses)); + } + }); + } + + /** + * Handle WebSocket upgrade. + */ + _handleWsUpgrade(req, socket) { + // Minimal WebSocket handshake + const key = req.headers['sec-websocket-key']; + if (!key) { + socket.destroy(); + return; + } + + const acceptKey = crypto + .createHash('sha1') + .update(key + '258EAFA5-E914-47DA-95CA-5AB5DC65C97B') + .digest('base64'); + + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${acceptKey}\r\n` + + '\r\n' + ); + + // Create a minimal WebSocket wrapper over the raw socket + const ws = this._createWsWrapper(socket); + this._wsClients.add(ws); + + ws.on('message', (data) => { + let messages; + try { + messages = JSON.parse(data); + } catch { + return; + } + if (!Array.isArray(messages)) messages = [messages]; + + const responses = []; + for (const msg of messages) { + // Track which client this WebSocket belongs to + if (msg.channel === '/meta/handshake' || (msg.clientId && this._clients.has(msg.clientId))) { + const client = this._clients.get(msg.clientId); + if (client) client.ws = ws; + } + + const result = this._processMessage(msg); + if (result === 'hold') { + // For WebSocket, just wait — we'll push events when they arrive + const clientId = msg.clientId; + // Store a pending resolve + this._pendingConnects.set(clientId, { + res: { + writeHead: () => {}, + end: (body) => ws.send(body), + }, + timer: setTimeout(() => { + this._pendingConnects.delete(clientId); + ws.send(JSON.stringify([{ + channel: '/meta/connect', + clientId: clientId, + successful: true, + advice: this._advice, + }])); + }, this._advice.timeout || 30000), + }); + } else if (result) { + responses.push(result); + } + } + + if (responses.length > 0) { + ws.send(JSON.stringify(responses)); + } + }); + + ws.on('close', () => { + this._wsClients.delete(ws); + }); + } + + /** + * Create a minimal WebSocket frame wrapper around a raw TCP socket. + * Handles text frames only (opcode 0x1) — sufficient for CometD. + * @param {net.Socket} socket + * @returns {EventEmitter} + */ + _createWsWrapper(socket) { + const emitter = new EventEmitter(); + let buffer = Buffer.alloc(0); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 2) { + const secondByte = buffer[1]; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7f; + let offset = 2; + + if (payloadLen === 126) { + if (buffer.length < 4) return; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return; + payloadLen = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + + const maskSize = masked ? 4 : 0; + const totalLen = offset + maskSize + payloadLen; + if (buffer.length < totalLen) return; + + let payload = buffer.subarray(offset + maskSize, totalLen); + if (masked) { + const mask = buffer.subarray(offset, offset + maskSize); + payload = Buffer.from(payload); + for (let i = 0; i < payload.length; i++) { + payload[i] ^= mask[i % 4]; + } + } + + const opcode = buffer[0] & 0x0f; + buffer = buffer.subarray(totalLen); + + if (opcode === 0x1) { + emitter.emit('message', payload.toString('utf8')); + } else if (opcode === 0x8) { + emitter.emit('close'); + socket.end(); + return; + } + } + }); + + socket.on('close', () => emitter.emit('close')); + socket.on('error', () => emitter.emit('close')); + + emitter.send = (data) => { + if (socket.destroyed) return; + const payload = Buffer.from(data, 'utf8'); + let header; + if (payload.length < 126) { + header = Buffer.alloc(2); + header[0] = 0x81; // FIN + text + header[1] = payload.length; + } else if (payload.length < 65536) { + header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(payload.length), 2); + } + socket.write(Buffer.concat([header, payload])); + }; + + emitter.close = () => { + if (!socket.destroyed) { + const closeFrame = Buffer.alloc(2); + closeFrame[0] = 0x88; // FIN + close + closeFrame[1] = 0; + socket.write(closeFrame); + socket.end(); + } + }; + + return emitter; + } + + /** + * Process a single Bayeux message and return the response (or 'hold' for connect). + * @param {object} msg - Bayeux message. + * @returns {object|string|null} Response message, 'hold', or null. + */ + _processMessage(msg) { + switch (msg.channel) { + case '/meta/handshake': + return this._handleHandshake(msg); + case '/meta/connect': + return this._handleConnect(msg); + case '/meta/subscribe': + return this._handleSubscribe(msg); + case '/meta/unsubscribe': + return this._handleUnsubscribe(msg); + case '/meta/disconnect': + return this._handleDisconnect(msg); + default: + return null; + } + } + + _handleHandshake(msg) { + const clientId = 'mock-client-' + (++this._clientIdCounter); + this._clients.set(clientId, { subscriptions: new Set(), ws: null }); + + return { + channel: '/meta/handshake', + version: '1.0', + supportedConnectionTypes: this._supportedTypes, + clientId: clientId, + successful: true, + id: msg.id, + advice: this._advice, + }; + } + + _handleConnect(msg) { + if (!this._clients.has(msg.clientId)) { + return { + channel: '/meta/connect', + successful: false, + error: 'Unknown client', + id: msg.id, + advice: { reconnect: 'handshake' }, + }; + } + // Hold the connection (long-poll behavior) + return 'hold'; + } + + _handleSubscribe(msg) { + const client = this._clients.get(msg.clientId); + if (!client) { + return { + channel: '/meta/subscribe', + successful: false, + error: 'Unknown client', + id: msg.id, + }; + } + client.subscriptions.add(msg.subscription); + + return { + channel: '/meta/subscribe', + clientId: msg.clientId, + subscription: msg.subscription, + successful: true, + id: msg.id, + }; + } + + _handleUnsubscribe(msg) { + const client = this._clients.get(msg.clientId); + if (client) { + client.subscriptions.delete(msg.subscription); + } + + return { + channel: '/meta/unsubscribe', + clientId: msg.clientId, + subscription: msg.subscription, + successful: true, + id: msg.id, + }; + } + + _handleDisconnect(msg) { + this._clients.delete(msg.clientId); + const pending = this._pendingConnects.get(msg.clientId); + if (pending) { + clearTimeout(pending.timer); + this._pendingConnects.delete(msg.clientId); + } + + return { + channel: '/meta/disconnect', + clientId: msg.clientId, + successful: true, + id: msg.id, + }; + } +} + +module.exports = MockCometDServer; diff --git a/test/mock/sfdc-rest-api.js b/test/mock/sfdc-rest-api.js index da12307..5a2edf6 100644 --- a/test/mock/sfdc-rest-api.js +++ b/test/mock/sfdc-rest-api.js @@ -1,131 +1,126 @@ +'use strict'; + const http = require('http'); const CONST = require('../../lib/constants'); const apiVersion = CONST.API; -let port = process.env.PORT || 33333; -let serverStack = []; -let requestStack = []; -const reset = () => { - requestStack.length = 0; -}; +class MockSfdcApi { + constructor(port) { + this._port = port || process.env.PORT || 33333; + this._serverStack = []; + this._requestStack = []; + this._defaultResponse = { + code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Status: 'OK' }) + }; + } -const getLastRequest = () => requestStack[0]; + reset() { + this._requestStack.length = 0; + } -// Default answer, when none provided -const defaultResponse = { - code: 200, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ Status: 'OK' }) -}; + getLastRequest() { + return this._requestStack[0]; + } -// Clear out the server -const clearServerStack = () => { - const allPromises = []; - let curServer = serverStack.pop(); - while (curServer) { - allPromises.push(new Promise((resolve) => curServer.close(resolve))); - curServer = serverStack.pop(); + clearServerStack() { + const allPromises = []; + let curServer = this._serverStack.pop(); + while (curServer) { + curServer.closeAllConnections(); + allPromises.push(new Promise((resolve) => curServer.close(resolve))); + curServer = this._serverStack.pop(); + } + return Promise.all(allPromises); } - return Promise.all(allPromises); -}; -// Returns a server instance with a predefinded answer -const getServerInstance = (serverListener) => { - return new Promise((resolve, reject) => { - clearServerStack() - .then(() => { - let server = http.createServer(serverListener); - server.listen(port, (err) => { + getServerInstance(serverListener) { + return this.clearServerStack().then(() => { + return new Promise((resolve, reject) => { + const server = http.createServer(serverListener); + server.listen(this._port, (err) => { if (err) { reject(err); } else { - serverStack.push(server); + this._serverStack.push(server); resolve(server); } }); - }) - .catch(reject); - }); -}; - -const getGoodServerInstance = (response = defaultResponse) => { - const serverListener = (req, res) => { - const chunks = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => { - req.body = Buffer.concat(chunks).toString(); - requestStack.push(req); - const headers = Object.assign({ Connection: 'close' }, response.headers); - res.writeHead(response.code, headers); - if (response.body) { - res.end(response.body, 'utf8'); - } else { - res.end(); - } + }); }); - }; - return getServerInstance(serverListener); -}; + } -const getClosedServerInstance = () => { - const serverListener = (req) => { - const fatError = new Error('ECONNRESET'); - fatError.type = 'system'; - fatError.errno = 'ECONNRESET'; - req.destroy(fatError); - }; - return getServerInstance(serverListener); -}; + getGoodServerInstance(response) { + const resp = response || this._defaultResponse; + const self = this; + const serverListener = (req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + req.body = Buffer.concat(chunks).toString(); + self._requestStack.push(req); + const headers = Object.assign({ Connection: 'close' }, resp.headers); + res.writeHead(resp.code, headers); + if (resp.body) { + res.end(resp.body, 'utf8'); + } else { + res.end(); + } + }); + }; + return this.getServerInstance(serverListener); + } -// return an example client -const getClient = (opts) => { - opts = opts || {}; - return { - clientId: 'ADFJSD234ADF765SFG55FD54S', - clientSecret: 'adsfkdsalfajdskfa', - redirectUri: 'http://localhost:' + port + '/oauth/_callback', - loginUri: 'http://localhost:' + port + '/login/uri', - apiVersion: opts.apiVersion || apiVersion, - mode: opts.mode || 'multi', - autoRefresh: opts.autoRefresh || false, - onRefresh: opts.onRefresh || undefined - }; -}; + getClosedServerInstance() { + const serverListener = (req) => { + const fatError = new Error('ECONNRESET'); + fatError.type = 'system'; + fatError.errno = 'ECONNRESET'; + req.destroy(fatError); + }; + return this.getServerInstance(serverListener); + } -// return an example oauth -const getOAuth = () => { - return { - id: - 'http://localhost:' + port + '/id/00Dd0000000fOlWEAU/005d00000014XTPAA2', - issued_at: '1362448234803', - instance_url: 'http://localhost:' + port, - signature: 'djaflkdjfdalkjfdalksjfalkfjlsdj', - access_token: 'aflkdsjfdlashfadhfladskfjlajfalskjfldsakjf' - }; -}; + getClient(opts) { + opts = opts || {}; + return { + clientId: 'ADFJSD234ADF765SFG55FD54S', + clientSecret: 'adsfkdsalfajdskfa', + redirectUri: 'http://localhost:' + this._port + '/oauth/_callback', + loginUri: 'http://localhost:' + this._port + '/login/uri', + apiVersion: opts.apiVersion || apiVersion, + mode: opts.mode || 'multi', + autoRefresh: opts.autoRefresh || false, + onRefresh: opts.onRefresh || undefined + }; + } -const start = (incomingPort, cb) => { - port = incomingPort; - getGoodServerInstance() - .then(() => cb()) - .catch((err) => { - console.error(err); - cb(err); - }); -}; -const stop = (cb) => { - clearServerStack() - .catch(console.error) - .finally(() => cb()); -}; + getOAuth() { + return { + id: 'http://localhost:' + this._port + '/id/00Dd0000000fOlWEAU/005d00000014XTPAA2', + issued_at: '1362448234803', + instance_url: 'http://localhost:' + this._port, + signature: 'djaflkdjfdalkjfdalksjfalkfjlsdj', + access_token: 'aflkdsjfdlashfadhfladskfjlajfalskjfldsakjf' + }; + } + + start(incomingPort, cb) { + this._port = incomingPort; + this.getGoodServerInstance() + .then(() => cb()) + .catch((err) => { + console.error(err); + cb(err); + }); + } + + stop(cb) { + this.clearServerStack() + .catch(console.error) + .finally(() => cb()); + } +} -module.exports = { - getGoodServerInstance: getGoodServerInstance, - getClosedServerInstance: getClosedServerInstance, - getClient: getClient, - getOAuth: getOAuth, - getLastRequest: getLastRequest, - reset: reset, - start: start, - stop: stop -}; +module.exports = { MockSfdcApi }; diff --git a/test/query.js b/test/query.js index 25c5574..8805fed 100644 --- a/test/query.js +++ b/test/query.js @@ -2,8 +2,9 @@ const nforce = require('../index'); const should = require('should'); -const api = require('./mock/sfdc-rest-api'); -const port = process.env.PORT || 33333; +const { MockSfdcApi } = require('./mock/sfdc-rest-api'); +const port = 33334; +const api = new MockSfdcApi(port); const CONST = require('../lib/constants'); const apiVersion = CONST.API; @@ -28,12 +29,13 @@ function verifyAccessToken() { describe('query', () => { // set up mock server before((done) => api.start(port, done)); + beforeEach(() => api.reset()); describe('#query', function () { let expected = `/services/data/${apiVersion}/query?q=SELECT+Id+FROM+Account+LIMIT+1`; - it('should work in multi-user mode with promises', (done) => { - orgMulti + it('should work in multi-user mode with promises', () => { + return orgMulti .query({ query: testQuery, oauth: oauth }) .then((res) => { should.exist(res); @@ -44,32 +46,26 @@ describe('query', () => { 'authorization', 'Bearer ' + oauth.access_token ); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should work in single-user mode with promises', (done) => { - orgSingle + it('should work in single-user mode with promises', () => { + return orgSingle .query({ query: testQuery }) .then((res) => { should.exist(res); const lr = api.getLastRequest(); lr.url.should.equal(expected); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should allow a string query in single-user mode', (done) => { - orgSingle + it('should allow a string query in single-user mode', () => { + return orgSingle .query(testQuery) .then((res) => { should.exist(res); api.getLastRequest().url.should.equal(expected); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); }); @@ -79,45 +75,39 @@ describe('query', () => { apiVersion + '/queryAll?q=SELECT+Id+FROM+Account+LIMIT+1'; - it('should work in multi-user mode with promises', (done) => { - orgMulti + it('should work in multi-user mode with promises', () => { + return orgMulti .queryAll({ query: testQuery, oauth: oauth }) .then((res) => { should.exist(res); api.getLastRequest().url.should.equal(expected); verifyAccessToken(); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should work in single-user mode with promises', (done) => { - orgSingle + it('should work in single-user mode with promises', () => { + return orgSingle .queryAll({ query: testQuery }) .then((res) => { should.exist(res); api.getLastRequest().url.should.equal(expected); verifyAccessToken(); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should allow a string query in single-user mode', (done) => { - orgSingle + it('should allow a string query in single-user mode', () => { + return orgSingle .queryAll(testQuery) .then((res) => { should.exist(res); api.getLastRequest().url.should.equal(expected); verifyAccessToken(); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); }); describe('#search', function () { - it('should return Record instances in searchRecords when raw is false', (done) => { + it('should return Record instances in searchRecords when raw is false', () => { let searchResponse = { code: 200, headers: { 'Content-Type': 'application/json' }, @@ -129,7 +119,7 @@ describe('query', () => { totalSize: 2 }) }; - api + return api .getGoodServerInstance(searchResponse) .then(() => orgMulti.search({ search: 'FIND {Acme}', oauth: oauth }) @@ -141,12 +131,10 @@ describe('query', () => { res.searchRecords[0].hasChanged().should.equal(false); res.searchRecords[0].get('name').should.equal('Acme'); res.totalSize.should.equal(2); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should return raw results when raw is true', (done) => { + it('should return raw results when raw is true', () => { let searchResponse = { code: 200, headers: { 'Content-Type': 'application/json' }, @@ -157,7 +145,7 @@ describe('query', () => { totalSize: 1 }) }; - api + return api .getGoodServerInstance(searchResponse) .then(() => orgMulti.search({ search: 'FIND {Acme}', oauth: oauth, raw: true }) @@ -167,12 +155,10 @@ describe('query', () => { res.searchRecords.length.should.equal(1); res.searchRecords[0].should.not.be.instanceOf(nforce.Record); res.searchRecords[0].Name.should.equal('Acme'); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); - it('should return response as-is when searchRecords is empty', (done) => { + it('should return response as-is when searchRecords is empty', () => { let searchResponse = { code: 200, headers: { 'Content-Type': 'application/json' }, @@ -181,7 +167,7 @@ describe('query', () => { totalSize: 0 }) }; - api + return api .getGoodServerInstance(searchResponse) .then(() => orgMulti.search({ search: 'FIND {nothing}', oauth: oauth }) @@ -190,9 +176,7 @@ describe('query', () => { should.exist(res); res.searchRecords.length.should.equal(0); res.totalSize.should.equal(0); - }) - .catch((err) => should.not.exist(err)) - .finally(() => done()); + }); }); }); diff --git a/test/streaming.js b/test/streaming.js new file mode 100644 index 0000000..15ce485 --- /dev/null +++ b/test/streaming.js @@ -0,0 +1,355 @@ +'use strict'; + +const should = require('should'); +const CometDClient = require('../lib/cometd'); +const FDCStream = require('../lib/fdcstream'); +const MockCometDServer = require('./mock/cometd-server'); + +const PORT = 34444; + +/** Poll a condition function until it returns true, with short intervals. */ +async function waitFor(conditionFn, { timeout = 5000, interval = 10 } = {}) { + const start = Date.now(); + while (!conditionFn()) { + if (Date.now() - start > timeout) { + throw new Error('waitFor timed out'); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } +} + +describe('CometD Client', function () { + this.timeout(10000); + + let server; + + before(async () => { + server = new MockCometDServer(PORT); + await server.start(); + }); + + after(async () => { + await server.stop(); + }); + + describe('#handshake (long-polling)', () => { + it('should negotiate a clientId via handshake', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + should.exist(client._clientId); + client._clientId.should.startWith('mock-client-'); + client._transport.should.equal('long-polling'); + await client.disconnect(); + }); + + it('should prefer websocket when server supports it', async () => { + server.setSupportedTypes(['long-polling', 'websocket']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + client._transport.should.equal('websocket'); + await client.disconnect(); + }); + }); + + describe('#setHeader', () => { + it('should include custom headers in requests', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + client.setHeader('Authorization', 'Bearer test-token'); + await client.handshake(); + should.exist(client._clientId); + await client.disconnect(); + }); + }); + + describe('#addExtension', () => { + it('should apply outgoing extensions to messages', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + + let outgoingCalled = false; + client.addExtension({ + outgoing: (msg, cb) => { + if (msg.channel === '/meta/subscribe') { + outgoingCalled = true; + msg.ext = msg.ext || {}; + msg.ext.replay = { '/topic/Test': -1 }; + } + cb(msg); + }, + incoming: (msg, cb) => cb(msg), + }); + + await client.handshake(); + await client.connect(); + + const sub = await client.subscribe('/topic/Test', () => {}); + outgoingCalled.should.be.true(); + + sub.cancel(); + await client.disconnect(); + }); + }); + + describe('#subscribe and event delivery (long-polling)', () => { + it('should receive events pushed to a subscribed topic', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + await client.connect(); + + const received = []; + await client.subscribe('/topic/TestTopic', (data) => { + received.push(data); + }); + + // Push an event from the mock server + server.pushEvent('/topic/TestTopic', { id: '001', name: 'Test' }); + + await waitFor(() => received.length > 0); + + received.length.should.equal(1); + received[0].id.should.equal('001'); + received[0].name.should.equal('Test'); + + await client.disconnect(); + }); + + it('should not receive events after unsubscribe', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + await client.connect(); + + const received = []; + const sub = await client.subscribe('/topic/UnsubTest', (data) => { + received.push(data); + }); + + await sub.cancel(); + + server.pushEvent('/topic/UnsubTest', { id: '002' }); + // Brief wait to confirm no event arrives (no condition to poll for) + await new Promise((resolve) => setTimeout(resolve, 100)); + + received.length.should.equal(0); + + await client.disconnect(); + }); + }); + + describe('#disconnect', () => { + it('should clean up resources on disconnect', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + await client.connect(); + + await client.subscribe('/topic/DisconnectTest', () => {}); + client._subscriptions.size.should.equal(1); + + await client.disconnect(); + should.not.exist(client._clientId); + client._subscriptions.size.should.equal(0); + }); + }); + + describe('transport events', () => { + it('should emit transport:up when connected', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + + let transportUp = false; + client.on('transport:up', () => { transportUp = true; }); + + await client.connect(); + transportUp.should.be.true(); + + await client.disconnect(); + }); + }); + + describe('#subscribe error handling', () => { + it('should throw on subscribe with invalid clientId', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + // Don't handshake — no valid clientId + client._clientId = 'invalid-client'; + client._connected = true; + client._transport = 'long-polling'; + + try { + await client.subscribe('/topic/Fail', () => {}); + throw new Error('should have thrown'); + } catch (err) { + err.message.should.match(/subscribe failed/); + } + + client._connected = false; + }); + }); + + describe('handshake error', () => { + it('should throw when server is unreachable', async () => { + const client = new CometDClient('http://localhost:19999/cometd'); + try { + await client.handshake(); + throw new Error('should have thrown'); + } catch (err) { + should.exist(err); + } + }); + }); + + describe('replay extension', () => { + it('should inject replay IDs on subscribe messages', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + + // Add replay extension like fdcstream does + const replayMap = { '/topic/Replay': -2 }; + client.addExtension({ + incoming: (msg, cb) => cb(msg), + outgoing: (msg, cb) => { + if (msg.channel === '/meta/subscribe') { + msg.ext = msg.ext || {}; + msg.ext.replay = replayMap; + } + cb(msg); + }, + }); + + // Capture extension runs after replay extension + const capturedExt = []; + client.addExtension({ + outgoing: (msg, cb) => { + if (msg.ext) capturedExt.push(msg.ext); + cb(msg); + }, + }); + + await client.handshake(); + await client.connect(); + await client.subscribe('/topic/Replay', () => {}); + + capturedExt.some((ext) => ext.replay && ext.replay['/topic/Replay'] === -2) + .should.be.true(); + + await client.disconnect(); + }); + }); + + describe('multiple subscriptions', () => { + it('should deliver events to the correct subscription', async () => { + server.setSupportedTypes(['long-polling']); + const client = new CometDClient(server.endpoint); + await client.handshake(); + await client.connect(); + + const receivedA = []; + const receivedB = []; + + await client.subscribe('/topic/A', (data) => receivedA.push(data)); + await client.subscribe('/topic/B', (data) => receivedB.push(data)); + + server.pushEvent('/topic/A', { val: 'a1' }); + await waitFor(() => receivedA.length > 0); + + server.pushEvent('/topic/B', { val: 'b1' }); + await waitFor(() => receivedB.length > 0); + + receivedA.length.should.equal(1); + receivedA[0].val.should.equal('a1'); + receivedB.length.should.equal(1); + receivedB[0].val.should.equal('b1'); + + await client.disconnect(); + }); + }); +}); + +describe('FDCStream (fdcstream.js integration)', function () { + this.timeout(10000); + + let server; + + before(async () => { + server = new MockCometDServer(34445); + await server.start(); + }); + + after(async () => { + await server.stop(); + }); + + const mockOAuth = { + instance_url: 'http://localhost:34445', + access_token: 'mock-access-token', + }; + + describe('Client', () => { + it('should create a stream client and emit connect', (done) => { + server.setSupportedTypes(['long-polling']); + const client = new FDCStream.Client({ + oauth: mockOAuth, + apiVersion: 'v58.0', + }); + + client.on('connect', () => { + should.exist(client._cometd); + client.disconnect(); + done(); + }); + }); + }); + + describe('Subscription', () => { + it('should subscribe and receive events', (done) => { + server.setSupportedTypes(['long-polling']); + const client = new FDCStream.Client({ + oauth: mockOAuth, + apiVersion: 'v58.0', + }); + + client.on('connect', () => { + const sub = client.subscribe({ topic: '/topic/FDCTest' }); + + sub.on('data', (data) => { + data.msg.should.equal('hello'); + sub.cancel(); + client.disconnect(); + done(); + }); + + sub.on('connect', () => { + server.pushEvent('/topic/FDCTest', { msg: 'hello' }); + }); + }); + }); + + it('should support replay IDs', (done) => { + server.setSupportedTypes(['long-polling']); + const client = new FDCStream.Client({ + oauth: mockOAuth, + apiVersion: 'v58.0', + }); + + client.on('connect', () => { + const sub = client.subscribe({ + topic: '/topic/ReplayTest', + replayId: -2, + }); + + sub.on('connect', () => { + // Replay ID was registered + client._replayFromMap['/topic/ReplayTest'].should.equal(-2); + sub.cancel(); + client.disconnect(); + done(); + }); + }); + }); + }); +});