From b81a5e499410607de4d02edd3a038bbcf39d455d Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 14 Nov 2025 14:59:30 +0100 Subject: [PATCH 1/2] feat: add Git-Flow, CI/CD, testing infrastructure, and comprehensive documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to project structure and development workflow: Git-Flow & CI/CD: - Implement Git-Flow with develop β†’ staging β†’ main branches - Add GitHub Actions workflows for CI, staging, and production deployments - Configure branch protection rules and environment-based deployments - Add automated release workflow with changelog generation Testing Infrastructure: - Separate tests from source code (tests/ directory) - Add AppSheetClientInterface for polymorphic client usage - Create MockAppSheetClient with in-memory storage for testing - Add comprehensive test suite (48 tests) with JSDoc documentation - Fix Jest/ES module compatibility with uuid mock Documentation: - Add CONTRIBUTING.md with Git-Flow and Semantic Versioning guidelines - Add CHANGELOG.md with version history template - Create .github/GIT-FLOW.md quick reference - Create .github/BRANCH-PROTECTION.md configuration guide - Add comprehensive JSDoc comments to all test files - Update README.md with Git-Flow and versioning sections - Update CLAUDE.md with Git-Flow strategy Semantic Versioning: - Add npm scripts for version management (patch/minor/major/prerelease) - Configure automated release process - Add Conventional Commits guidelines Additional: - Initialize time-tracking configuration - Add .claude/ to .gitignore - Update TypeScript configuration πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/BRANCH-PROTECTION.md | 176 +++++ .github/GIT-FLOW.md | 91 +++ .github/workflows/ci.yml | 71 ++ .github/workflows/deploy-production.yml | 67 ++ .github/workflows/deploy-staging.yml | 58 ++ .github/workflows/release.yml | 97 +++ .gitignore | 3 + CHANGELOG.md | 120 +++ CLAUDE.md | 113 ++- CONTRIBUTING.md | 486 ++++++++++++ README.md | 67 ++ jest.config.js | 6 +- package-lock.json | 25 +- package.json | 9 +- src/client/AppSheetClient.test.ts | 366 --------- src/client/AppSheetClient.ts | 3 +- src/client/MockAppSheetClient.ts | 402 ++++++++++ src/client/__mocks__/MockDatabase.ts | 239 ++++++ src/client/__mocks__/mockData.ts | 251 +++++++ src/client/index.ts | 1 + src/types/client.ts | 139 ++++ src/types/index.ts | 6 + src/types/mock.ts | 142 ++++ tests/__mocks__/uuid.ts | 14 + tests/client/AppSheetClient.test.ts | 794 ++++++++++++++++++++ tests/client/MockAppSheetClient.test.ts | 960 ++++++++++++++++++++++++ tsconfig.json | 2 +- 27 files changed, 4330 insertions(+), 378 deletions(-) create mode 100644 .github/BRANCH-PROTECTION.md create mode 100644 .github/GIT-FLOW.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-production.yml create mode 100644 .github/workflows/deploy-staging.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md delete mode 100644 src/client/AppSheetClient.test.ts create mode 100644 src/client/MockAppSheetClient.ts create mode 100644 src/client/__mocks__/MockDatabase.ts create mode 100644 src/client/__mocks__/mockData.ts create mode 100644 src/types/client.ts create mode 100644 src/types/mock.ts create mode 100644 tests/__mocks__/uuid.ts create mode 100644 tests/client/AppSheetClient.test.ts create mode 100644 tests/client/MockAppSheetClient.test.ts diff --git a/.github/BRANCH-PROTECTION.md b/.github/BRANCH-PROTECTION.md new file mode 100644 index 0000000..79bf06a --- /dev/null +++ b/.github/BRANCH-PROTECTION.md @@ -0,0 +1,176 @@ +# Branch Protection Configuration + +Configure these settings in **GitHub Repository Settings β†’ Branches β†’ Branch protection rules**. + +## `develop` Branch Protection + +1. Go to: Settings β†’ Branches β†’ Add rule +2. Branch name pattern: `develop` +3. Configure: + +``` +βœ… Require a pull request before merging + βœ… Require approvals: 1 + βœ… Dismiss stale pull request approvals when new commits are pushed + βœ… Require approval of the most recent reviewable push + +βœ… Require status checks to pass before merging + βœ… Require branches to be up to date before merging + Status checks: + - test (Node.js 18.x) + - test (Node.js 20.x) + - test (Node.js 22.x) + - coverage + +βœ… Require conversation resolution before merging + +βœ… Include administrators + +❌ Allow force pushes (disabled) +❌ Allow deletions (disabled) +``` + +**Deployment:** ❌ No automatic deployment (CI only) + +--- + +## `staging` Branch Protection + +1. Go to: Settings β†’ Branches β†’ Add rule +2. Branch name pattern: `staging` +3. Configure: + +``` +βœ… Require a pull request before merging + βœ… Require approvals: 1 + βœ… Dismiss stale pull request approvals when new commits are pushed + βœ… Require approval of the most recent reviewable push + +βœ… Require status checks to pass before merging + βœ… Require branches to be up to date before merging + Status checks: + - test (Node.js 18.x) + - test (Node.js 20.x) + - test (Node.js 22.x) + - coverage + +βœ… Require conversation resolution before merging + +βœ… Restrict who can push to matching branches (optional) + Allowed: develop branch only + +βœ… Include administrators + +❌ Allow force pushes (disabled) +❌ Allow deletions (disabled) +``` + +**Deployment:** βœ… Auto-deploy to Staging environment + +--- + +## `main` Branch Protection + +1. Go to: Settings β†’ Branches β†’ Add rule +2. Branch name pattern: `main` +3. Configure: + +``` +βœ… Require a pull request before merging + βœ… Require approvals: 2 (WICHTIG: 2 Approver!) + βœ… Dismiss stale pull request approvals when new commits are pushed + βœ… Require approval of the most recent reviewable push + βœ… Require review from Code Owners (optional) + +βœ… Require status checks to pass before merging + βœ… Require branches to be up to date before merging + Status checks: + - test (Node.js 18.x) + - test (Node.js 20.x) + - test (Node.js 22.x) + - coverage + +βœ… Require conversation resolution before merging + +βœ… Require deployments to succeed before merging + Required deployment environments: + - staging + +βœ… Restrict who can push to matching branches (optional) + Allowed: staging branch, hotfix/* branches + +βœ… Include administrators + +❌ Allow force pushes (disabled) +❌ Allow deletions (disabled) +``` + +**Deployment:** βœ… Auto-deploy to Production environment + +--- + +## Environment Configuration + +Configure these in **Settings β†’ Environments**: + +### Staging Environment + +``` +Name: staging +Protection rules: + βœ… Required reviewers: 1 + βœ… Wait timer: 0 minutes + +Environment secrets: + - STAGING_TOKEN (if needed) +``` + +### Production Environment + +``` +Name: production +Protection rules: + βœ… Required reviewers: 2 + βœ… Wait timer: 5 minutes (optional safety delay) + +Environment secrets: + - NPM_TOKEN (for npm publishing) + - PRODUCTION_TOKEN (if needed) +``` + +--- + +## CODEOWNERS File (Optional) + +Create `.github/CODEOWNERS` to automatically request reviews: + +``` +# Default owners for everything +* @team-lead @senior-dev + +# Specific files +/.github/ @devops-team +/docs/ @documentation-team +``` + +--- + +## Verification + +After configuring, verify: + +1. βœ… Try to push directly to `develop` (should fail) +2. βœ… Try to push directly to `staging` (should fail) +3. βœ… Try to push directly to `main` (should fail) +4. βœ… Create PR without CI passing (should be blocked) +5. βœ… Create PR without reviews (should be blocked) + +--- + +## Quick Reference + +| Branch | Reviewers | Status Checks | Force Push | Deploy | +|-----------|-----------|---------------|------------|--------| +| `develop` | 1 | βœ… | ❌ | ❌ | +| `staging` | 1 | βœ… | ❌ | βœ… | +| `main` | 2 | βœ… | ❌ | βœ… | diff --git a/.github/GIT-FLOW.md b/.github/GIT-FLOW.md new file mode 100644 index 0000000..1b37d01 --- /dev/null +++ b/.github/GIT-FLOW.md @@ -0,0 +1,91 @@ +# Git-Flow Quick Reference + +## Branch Strategy + +``` +develop β†’ CI only (keine Deployments) +staging β†’ Staging Deployment (Pre-Production) +main β†’ Production Deployment +``` + +## Branch Flow + +```mermaid +graph LR + F[feature/*] --> D[develop] + D --> S[staging] + S --> M[main] + M -.hotfix.-> D +``` + +## Quick Commands + +### Start New Feature +```bash +git checkout develop +git pull origin develop +git checkout -b feature/my-feature +# ... work ... +git push origin feature/my-feature +# Create PR: feature/my-feature β†’ develop +``` + +### Promote to Staging +```bash +git checkout staging +git pull origin staging +git merge develop +git push origin staging +# Or: Create PR: develop β†’ staging +# πŸš€ Auto-deploys to staging +``` + +### Promote to Production +```bash +git checkout main +git pull origin main +git merge staging +git push origin main +# Or: Create PR: staging β†’ main +# πŸš€ Auto-deploys to production +``` + +### Hotfix Production +```bash +git checkout main +git pull origin main +git checkout -b hotfix/critical-fix +# ... fix ... +git push origin hotfix/critical-fix +# Create PR: hotfix/critical-fix β†’ main +# Then merge main back to develop +``` + +## Branch Protection + +| Branch | Reviewers | CI Required | Deploy | +|-----------|-----------|-------------|--------| +| `develop` | 1 | βœ… | ❌ | +| `staging` | 1 | βœ… | βœ… | +| `main` | 2 | βœ… | βœ… | + +## Workflow Overview + +``` +1. Feature Development: feature/xxx β†’ develop (CI only) +2. Staging Testing: develop β†’ staging (Deploy + Test) +3. Production Release: staging β†’ main (Deploy to Prod) +``` + +## CI/CD Matrix + +| Event | Branch | CI | Deploy | Environment | +|--------------------|-----------|-----|--------|-------------| +| PR created | Any | βœ… | ❌ | None | +| PR merged | develop | βœ… | ❌ | None | +| PR merged | staging | βœ… | βœ… | Staging | +| PR merged | main | βœ… | βœ… | Production | + +## Need Help? + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for detailed workflow documentation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c0fc4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: [ main, staging, develop ] + pull_request: + branches: [ main, staging, develop ] + +jobs: + test: + name: Test on Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run build + run: npm run build + + - name: Run tests + run: npm test + + - name: Check CLI binary + run: | + chmod +x dist/cli/index.js + node dist/cli/index.js --version || echo "CLI check skipped" + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm test -- --coverage + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: success() + with: + files: ./coverage/lcov.info + fail_ci_if_error: false + continue-on-error: true diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..0fa4790 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,67 @@ +name: Deploy to Production + +on: + push: + branches: + - main + +jobs: + deploy-production: + name: Deploy to Production Environment + runs-on: ubuntu-latest + environment: + name: production + url: https://production.example.com + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Run build + run: npm run build + + - name: Deploy to Production + run: | + echo "Deploying to Production environment..." + # Add your production deployment commands here + # Examples: + # - npm publish --access public + # - Deploy to production server + # - Deploy to production cloud environment + env: + # Add your production environment secrets here + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + PRODUCTION_TOKEN: ${{ secrets.PRODUCTION_TOKEN }} + + - name: Create deployment tag + if: success() + run: | + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + git tag "deploy-prod-${TIMESTAMP}" + git push origin "deploy-prod-${TIMESTAMP}" || true + + - name: Notify deployment + if: success() + run: | + echo "βœ… Successfully deployed to Production" + echo "Branch: ${{ github.ref_name }}" + echo "Commit: ${{ github.sha }}" + + - name: Notify failure + if: failure() + run: | + echo "❌ Production deployment failed" + echo "Check logs for details" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..8e64c9a --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,58 @@ +name: Deploy to Staging + +on: + push: + branches: + - staging + +jobs: + deploy-staging: + name: Deploy to Staging Environment + runs-on: ubuntu-latest + environment: + name: staging + url: https://staging.example.com + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Run build + run: npm run build + + - name: Deploy to Staging + run: | + echo "Deploying to Staging environment..." + # Add your staging deployment commands here + # Examples: + # - npm publish --tag staging + # - Deploy to staging server + # - Deploy to staging cloud environment + env: + # Add your staging environment secrets here + STAGING_TOKEN: ${{ secrets.STAGING_TOKEN }} + + - name: Notify deployment + if: success() + run: | + echo "βœ… Successfully deployed to Staging" + echo "Branch: ${{ github.ref_name }}" + echo "Commit: ${{ github.sha }}" + + - name: Notify failure + if: failure() + run: | + echo "❌ Staging deployment failed" + echo "Check logs for details" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e64845b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Run build + run: npm run build + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Generate changelog + id: changelog + run: | + # Get previous tag + PREVIOUS_TAG=$(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1) 2>/dev/null || echo "") + + if [ -z "$PREVIOUS_TAG" ]; then + # First release - get all commits + CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges) + else + # Get commits since previous tag + CHANGELOG=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) + fi + + # Save to output + echo "CHANGELOG<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ steps.version.outputs.VERSION }} + body: | + ## Changes in this Release + + ${{ steps.changelog.outputs.CHANGELOG }} + + ## Installation + + ```bash + npm install git+ssh://git@github.com:techdivision/appsheet.git#${{ github.ref_name }} + ``` + draft: false + prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') || contains(github.ref, '-rc') }} + + - name: Publish to npm + if: "!contains(github.ref, '-')" + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + continue-on-error: true + + - name: Publish pre-release to npm + if: contains(github.ref, '-alpha') || contains(github.ref, '-beta') || contains(github.ref, '-rc') + run: | + if [[ "${{ github.ref }}" == *"-alpha"* ]]; then + npm publish --access public --tag alpha + elif [[ "${{ github.ref }}" == *"-beta"* ]]; then + npm publish --access public --tag beta + elif [[ "${{ github.ref }}" == *"-rc"* ]]; then + npm publish --access public --tag rc + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + continue-on-error: true diff --git a/.gitignore b/.gitignore index f0e887b..8000fac 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ coverage/ # Misc *.tgz .npm/ + +# Claude Code configuration +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3f0d911 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial project setup +- AppSheetClient with full CRUD operations +- MockAppSheetClient for testing +- Schema-based usage with SchemaLoader and SchemaManager +- DynamicTable with runtime validation +- CLI tool for schema generation (inspect, init, add-table, validate) +- Multi-instance connection management +- runAsUserEmail feature for user context +- Comprehensive JSDoc documentation +- Jest test suite (48 tests) +- GitHub Actions CI workflow +- Semantic versioning setup + +### Documentation +- README.md with usage examples +- CONTRIBUTING.md with versioning guidelines +- CLAUDE.md for Claude Code integration +- TypeDoc API documentation +- Comprehensive test documentation + +## [0.1.0] - 2025-11-14 + +### Added +- Initial release +- Basic AppSheet CRUD operations +- TypeScript support +- Schema management + +--- + +## Version Format + +- **[MAJOR.MINOR.PATCH]** - Released versions +- **[Unreleased]** - Upcoming changes not yet released + +## Change Categories + +- **Added** - New features +- **Changed** - Changes in existing functionality +- **Deprecated** - Soon-to-be removed features +- **Removed** - Removed features +- **Fixed** - Bug fixes +- **Security** - Security fixes + +## Examples + +### Patch Release (0.1.0 β†’ 0.1.1) + +```markdown +## [0.1.1] - 2025-11-15 + +### Fixed +- Fixed selector parsing for date fields +- Corrected error handling in retry logic + +### Documentation +- Updated API documentation +``` + +### Minor Release (0.1.0 β†’ 0.2.0) + +```markdown +## [0.2.0] - 2025-11-20 + +### Added +- New `findByIds()` method for batch retrieval +- Support for custom request headers +- Connection pooling support + +### Changed +- Improved error messages with more context + +### Deprecated +- `oldMethod()` will be removed in v1.0.0 +``` + +### Major Release (0.2.0 β†’ 1.0.0) + +```markdown +## [1.0.0] - 2025-12-01 + +### Added +- Stable API release +- Full TypeScript type coverage + +### Changed +- **BREAKING**: Client methods now return typed responses +- **BREAKING**: Renamed `findAll()` to `find()` with options + +### Removed +- **BREAKING**: Removed deprecated `oldMethod()` + +### Migration Guide + +#### Client Method Changes + +Before (0.x.x): +```typescript +const rows = await client.findAll('Users'); +``` + +After (1.0.0): +```typescript +const result = await client.find({ tableName: 'Users' }); +const rows = result.rows; +``` +``` + +[Unreleased]: https://github.com/techdivision/appsheet/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/techdivision/appsheet/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index cea728d..146e6ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,44 @@ This is a generic TypeScript library for AppSheet CRUD operations, designed for 1. **Direct client usage** - Simple AppSheetClient for basic operations 2. **Schema-based usage** - Runtime schema loading from YAML/JSON with type-safe table clients and validation +## Git-Flow Branch Strategy ⚠️ WICHTIG + +**KRITISCH:** Dieses Projekt folgt einer klassischen Git-Flow Strategie: + +``` +develop β†’ CI only (keine Deployments) +staging β†’ Staging Deployment (Pre-Production) +main β†’ Production Deployment +``` + +### Branch Rules + +| Branch | CI | Deploy | Purpose | +|-----------|-----|--------|---------| +| `develop` | βœ… | ❌ | Feature integration (CI only) | +| `staging` | βœ… | βœ… | Pre-production testing | +| `main` | βœ… | βœ… | Production releases | + +### Feature Development Flow + +```bash +# 1. Create feature from develop +git checkout develop +git checkout -b feature/new-feature + +# 2. Create PR: feature/new-feature β†’ develop (CI runs) +# 3. Merge: develop β†’ staging (auto-deploys to staging) +# 4. Merge: staging β†’ main (auto-deploys to production) +``` + +### Branch Protection + +- **develop**: 1 reviewer, CI required, no deploy +- **staging**: 1 reviewer, CI required, auto-deploy +- **main**: 2 reviewers, CI required, auto-deploy + +**Siehe:** [.github/GIT-FLOW.md](.github/GIT-FLOW.md) und [CONTRIBUTING.md](CONTRIBUTING.md) fΓΌr Details. + ## Development Commands ```bash @@ -17,8 +55,10 @@ npm run build:watch # Watch mode compilation npm run clean # Remove dist/ directory # Testing -npm test # Run Jest tests +npm test # Run all Jest tests npm test:watch # Watch mode testing +npm test -- # Run tests matching pattern (e.g., npm test -- AppSheetClient) +npx jest # Run specific test file (e.g., npx jest tests/client/AppSheetClient.test.ts) # Code Quality npm run lint # Check for linting errors @@ -31,6 +71,7 @@ npm run docs:serve # Generate and serve docs locally # CLI Testing (after build) node dist/cli/index.js inspect --help +npx appsheet inspect --help # After npm install (uses bin entry) ``` ## Architecture @@ -50,6 +91,11 @@ node dist/cli/index.js inspect --help - Standard format: `{ Rows: [...], Warnings?: [...] }` - Direct array format: `[...]` (automatically converted to standard format) +**AppSheetClientInterface** (`src/types/client.ts`) +- Interface defining the contract for all client implementations +- Implemented by both AppSheetClient and MockAppSheetClient +- Ensures type safety and allows swapping implementations in tests + **DynamicTable** (`src/client/DynamicTable.ts`) - Schema-aware table client with runtime validation - Validates field types, required fields, and enum values based on TableDefinition @@ -83,10 +129,14 @@ node dist/cli/index.js inspect --help **CLI Commands** (`src/cli/commands.ts`) - `init` - Create empty schema file -- `inspect` - Generate schema from AppSheet app (with optional auto-discovery) +- `inspect` - Generate schema from AppSheet app + - With `--tables` flag: Generate schema for specific tables + - Without `--tables` flag: Auto-discovery mode (prompts user to select tables interactively) - `add-table` - Add single table to existing schema - `validate` - Validate schema file structure +CLI binary name: `appsheet` (defined in package.json bin field) + ## Key Design Patterns ### Schema Structure @@ -182,22 +232,77 @@ Categories: Client, Schema Management, Connection Management, Types, Errors ``` src/ β”œβ”€β”€ client/ # AppSheetClient, DynamicTable +β”‚ └── __mocks__/ # Mock implementations for testing β”œβ”€β”€ types/ # All TypeScript interfaces and types β”œβ”€β”€ utils/ # ConnectionManager, SchemaLoader, SchemaManager β”œβ”€β”€ cli/ # CLI commands and SchemaInspector └── index.ts # Main export file +tests/ # Test files (separate from source) +β”œβ”€β”€ client/ # Client tests +β”‚ └── AppSheetClient.test.ts +└── ... # Tests mirror src/ structure + examples/ # Usage examples docs/ β”œβ”€β”€ TECHNICAL_CONCEPTION.md # Complete design document (German) └── api/ # Generated TypeDoc HTML (gitignored) ``` +## Testing + +### MockAppSheetClient +For testing purposes, use `MockAppSheetClient` (`src/client/MockAppSheetClient.ts`): +- In-memory mock implementation of `AppSheetClientInterface` +- Implements the same interface as `AppSheetClient` for easy swapping in tests +- Stores data in memory without making API calls +- Useful for unit tests and local development +- Fully tested with comprehensive test suite + +```typescript +import { MockAppSheetClient, AppSheetClientInterface } from '@techdivision/appsheet'; + +// Direct usage +const mockClient = new MockAppSheetClient({ + appId: 'mock-app', + applicationAccessKey: 'mock-key' +}); +await mockClient.addOne('Users', { id: '1', name: 'Test' }); +const users = await mockClient.findAll('Users'); // Returns mock data + +// Using interface for polymorphism +function processUsers(client: AppSheetClientInterface) { + return client.findAll('Users'); +} + +// Works with both real and mock clients +const realClient = new AppSheetClient({ appId, applicationAccessKey }); +const mockClient = new MockAppSheetClient({ appId, applicationAccessKey }); + +await processUsers(realClient); // Uses real API +await processUsers(mockClient); // Uses in-memory data +``` + +### Test Configuration +- Tests use Jest with ts-jest preset +- Test files located in `tests/` directory (separate from `src/`) +- Test structure mirrors `src/` directory structure +- Test files: `**/*.test.ts` or `**/*.spec.ts` +- Coverage configured to exclude type definitions and mock files +- Mock data available in `src/client/__mocks__/` directory +- Import paths from tests: `import { X } from '../../src/module/X'` + +### Test Files +- `tests/client/AppSheetClient.test.ts` - Tests for real AppSheet client +- `tests/client/MockAppSheetClient.test.ts` - Tests for mock client implementation + ## Important Notes - The library uses AppSheet API v2 -- CLI binary entry point: `dist/cli/index.js` (needs `chmod +x` after build) -- Schema files support environment variable substitution +- CLI binary entry point: `dist/cli/index.js` (automatically made executable by npm) +- CLI binary command: `appsheet` (can be run via `npx appsheet` after installation) +- Schema files support environment variable substitution with `${VAR_NAME}` syntax - SchemaInspector's `toSchemaName()` method removes "extract_" prefix and adds "s" suffix - Multi-instance support allows one MCP server to access multiple AppSheet apps - Runtime validation in DynamicTable checks types but doesn't prevent API calls for performance +- The library is designed to be installed from GitHub via npm (not published to npm registry yet) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e883664 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,486 @@ +# Contributing to AppSheet TypeScript Library + +Thank you for contributing! This document provides guidelines for contributing to this project. + +## Table of Contents + +- [Git-Flow Branch Strategy](#git-flow-branch-strategy) +- [Semantic Versioning](#semantic-versioning) +- [Development Workflow](#development-workflow) +- [Commit Message Guidelines](#commit-message-guidelines) +- [Pull Request Process](#pull-request-process) +- [Release Process](#release-process) + +## Git-Flow Branch Strategy + +**KRITISCH:** Dieses Projekt folgt einer klassischen Git-Flow Strategie mit drei Branches: + +``` +develop β†’ CI only (keine Deployments) +staging β†’ Staging Deployment (Pre-Production) +main β†’ Production Deployment +``` + +### Branch β†’ Environment Mapping + +| Branch | Environment | Deployment | CI | Purpose | +|-----------|----------------|------------|-----|---------| +| `develop` | None (CI only) | ❌ Nein | βœ… Ja | Development branch fΓΌr Feature-Integration | +| `staging` | Staging | βœ… Auto | βœ… Ja | Pre-Production Testing & Validation | +| `main` | Production | βœ… Auto | βœ… Ja | Production-Ready Releases | + +### Workflow + +``` +feature/xxx β†’ develop β†’ staging β†’ main + ↑ ↑ ↑ ↑ + β”‚ β”‚ β”‚ β”‚ +Feature Integration Testing Production +Branch (CI) (Deploy) (Deploy) +``` + +### Branch Purposes + +#### `develop` - Development Branch +- **Purpose**: Integration branch for all features +- **CI**: βœ… Runs tests, linting, and build +- **Deployment**: ❌ No automatic deployment +- **Protected**: Yes (requires PR and CI to pass) +- **Merges from**: `feature/*`, `bugfix/*`, `hotfix/*` branches +- **Merges to**: `staging` + +**Usage:** +```bash +# Create feature branch from develop +git checkout develop +git pull origin develop +git checkout -b feature/new-feature + +# Work on feature, commit, push +git push origin feature/new-feature + +# Create PR to develop +# After PR merge, feature is in develop +``` + +#### `staging` - Pre-Production Branch +- **Purpose**: Pre-production testing and validation +- **CI**: βœ… Runs tests, linting, and build +- **Deployment**: βœ… Auto-deploys to Staging environment +- **Protected**: Yes (requires PR from develop and CI to pass) +- **Merges from**: `develop` only +- **Merges to**: `main` + +**Usage:** +```bash +# Promote develop to staging +git checkout staging +git pull origin staging +git merge develop +git push origin staging + +# Or create PR: develop β†’ staging +``` + +#### `main` - Production Branch +- **Purpose**: Production-ready releases +- **CI**: βœ… Runs tests, linting, and build +- **Deployment**: βœ… Auto-deploys to Production environment +- **Protected**: Yes (requires PR from staging and CI to pass) +- **Merges from**: `staging` only (or `hotfix/*` in emergencies) +- **Merges to**: None (or back to `develop` via hotfix) + +**Usage:** +```bash +# Promote staging to production +git checkout main +git pull origin main +git merge staging +git push origin main + +# Or create PR: staging β†’ main +``` + +### Feature Development Workflow + +```bash +# 1. Create feature branch from develop +git checkout develop +git pull origin develop +git checkout -b feature/add-new-api + +# 2. Develop feature +git add . +git commit -m "feat: add new API endpoint" + +# 3. Push and create PR to develop +git push origin feature/add-new-api +# Create PR: feature/add-new-api β†’ develop + +# 4. After PR merge β†’ develop +# Feature is now in develop (CI runs) + +# 5. Promote to staging +git checkout staging +git merge develop +git push origin staging +# Or create PR: develop β†’ staging +# Staging deployment runs automatically + +# 6. Test in staging environment +# Validate features work correctly + +# 7. Promote to production +git checkout main +git merge staging +git push origin main +# Or create PR: staging β†’ main +# Production deployment runs automatically +``` + +### Hotfix Workflow + +For critical production bugs: + +```bash +# 1. Create hotfix branch from main +git checkout main +git pull origin main +git checkout -b hotfix/critical-bug + +# 2. Fix bug +git add . +git commit -m "fix: resolve critical production bug" + +# 3. Push and create PR to main +git push origin hotfix/critical-bug +# Create PR: hotfix/critical-bug β†’ main + +# 4. After merge β†’ main +# Production deployment runs + +# 5. Merge back to develop +git checkout develop +git merge main +git push origin develop +``` + +### Branch Protection Rules + +Configure these in GitHub repository settings: + +#### `develop` Branch Protection +- βœ… Require pull request reviews (1 approver) +- βœ… Require status checks to pass (CI) +- βœ… Require branches to be up to date +- βœ… Include administrators +- ❌ No deployment (CI only) + +#### `staging` Branch Protection +- βœ… Require pull request reviews (1 approver) +- βœ… Require status checks to pass (CI) +- βœ… Require branches to be up to date +- βœ… Only allow merges from `develop` +- βœ… Include administrators +- βœ… Auto-deploy to staging + +#### `main` Branch Protection +- βœ… Require pull request reviews (2 approvers) +- βœ… Require status checks to pass (CI) +- βœ… Require branches to be up to date +- βœ… Only allow merges from `staging` or `hotfix/*` +- βœ… Include administrators +- βœ… Auto-deploy to production + +### Common Scenarios + +#### Scenario 1: New Feature Development +```bash +feature/new-feature β†’ develop β†’ staging β†’ main +``` + +#### Scenario 2: Bug Fix in Development +```bash +bugfix/fix-issue β†’ develop β†’ staging β†’ main +``` + +#### Scenario 3: Hotfix in Production +```bash +hotfix/critical-fix β†’ main β†’ develop +``` + +#### Scenario 4: Testing in Staging +```bash +# Multiple features in develop +feature/a β†’ develop ─┐ +feature/b β†’ develop ─┼→ staging (test all together) +feature/c β†’ develop β”€β”˜ +``` + +## Semantic Versioning + +This project follows [Semantic Versioning 2.0.0](https://semver.org/). + +Given a version number `MAJOR.MINOR.PATCH`, increment the: + +- **MAJOR** version when you make incompatible API changes +- **MINOR** version when you add functionality in a backward compatible manner +- **PATCH** version when you make backward compatible bug fixes + +### Version Format + +``` +MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD] +``` + +**Examples:** +- `1.0.0` - First stable release +- `1.1.0` - Added new features (backward compatible) +- `1.1.1` - Bug fix release +- `2.0.0` - Breaking changes +- `1.2.0-beta.1` - Pre-release version +- `1.2.0+20130313144700` - Build metadata + +### Breaking Changes (MAJOR) + +Changes that require users to modify their code: + +- Removing or renaming public APIs +- Changing function signatures +- Changing return types +- Removing configuration options +- Changing required dependencies + +**Example:** +```typescript +// Before (v1.x.x) +client.findAll('Users') + +// After (v2.0.0) - Breaking change +client.find({ tableName: 'Users', selector: 'ALL' }) +``` + +### New Features (MINOR) + +Backward compatible additions: + +- Adding new public methods +- Adding new optional parameters +- Adding new configuration options +- Adding new exports + +**Example:** +```typescript +// v1.1.0 - Added new method (backward compatible) +client.findOne('Users', '[Email] = "john@example.com"') +``` + +### Bug Fixes (PATCH) + +Backward compatible bug fixes: + +- Fixing incorrect behavior +- Performance improvements +- Documentation updates +- Internal refactoring + +**Example:** +```typescript +// v1.1.1 - Fixed selector parsing bug +``` + +## Development Workflow + +### 1. Clone and Setup + +```bash +git clone git@github.com:techdivision/appsheet.git +cd appsheet +npm install +``` + +### 2. Create a Feature Branch + +```bash +git checkout -b feature/your-feature-name +# or +git checkout -b fix/bug-description +``` + +### 3. Make Changes + +- Write code +- Add tests +- Update documentation +- Run linter: `npm run lint:fix` +- Run tests: `npm test` +- Build: `npm run build` + +### 4. Commit Changes + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +```bash +git commit -m "feat: add new feature" +git commit -m "fix: resolve bug" +git commit -m "docs: update README" +git commit -m "chore: update dependencies" +``` + +## Commit Message Guidelines + +We use [Conventional Commits](https://www.conventionalcommits.org/) to automatically generate changelogs and determine version bumps. + +### Format + +``` +[(optional scope)]: + +[optional body] + +[optional footer(s)] +``` + +### Types + +- **feat**: A new feature (MINOR version bump) +- **fix**: A bug fix (PATCH version bump) +- **docs**: Documentation only changes +- **style**: Code style changes (formatting, missing semicolons, etc.) +- **refactor**: Code refactoring without changing functionality +- **perf**: Performance improvements +- **test**: Adding or updating tests +- **chore**: Build process or auxiliary tool changes +- **ci**: CI/CD configuration changes + +### Breaking Changes + +Add `BREAKING CHANGE:` in the footer or `!` after the type: + +```bash +git commit -m "feat!: change API signature" +# or +git commit -m "feat: change API signature + +BREAKING CHANGE: The findAll method now requires options object" +``` + +### Examples + +```bash +# New feature (MINOR bump) +git commit -m "feat: add runAsUserEmail configuration" + +# Bug fix (PATCH bump) +git commit -m "fix: correct selector parsing for date fields" + +# Breaking change (MAJOR bump) +git commit -m "feat!: redesign client interface + +BREAKING CHANGE: Client methods now return promises with typed responses" + +# Documentation +git commit -m "docs: add JSDoc comments to AppSheetClient" + +# Chore +git commit -m "chore: update TypeScript to v5.3" +``` + +## Pull Request Process + +1. **Update documentation** if you changed APIs +2. **Add tests** for new features +3. **Update CHANGELOG.md** with your changes (if significant) +4. **Ensure CI passes** (lint, build, tests) +5. **Request review** from maintainers +6. **Squash commits** if requested + +### PR Title Format + +Use conventional commit format for PR titles: + +``` +feat: add new feature +fix: resolve bug +docs: update documentation +``` + +## Release Process + +### Manual Release + +For maintainers with publish rights: + +```bash +# 1. Ensure clean working directory +git status + +# 2. Run tests +npm test + +# 3. Update version (npm will run build automatically via prepare script) +npm version patch # For bug fixes (1.0.0 -> 1.0.1) +npm version minor # For new features (1.0.0 -> 1.1.0) +npm version major # For breaking changes (1.0.0 -> 2.0.0) + +# 4. Push changes and tags +git push --follow-tags + +# 5. Publish to npm (if configured) +npm publish --access public +``` + +### Pre-release Versions + +For beta/alpha releases: + +```bash +# Create pre-release +npm version prerelease --preid=beta # 1.0.0 -> 1.0.1-beta.0 +npm version prerelease --preid=alpha # 1.0.0 -> 1.0.1-alpha.0 + +# Publish pre-release to npm +npm publish --tag beta +``` + +### Automated Release (via GitHub Actions) + +When a tag is pushed, GitHub Actions automatically: +1. Runs tests +2. Builds the package +3. Creates a GitHub Release +4. Publishes to npm (if configured) + +```bash +# Create and push tag manually +git tag v1.0.0 +git push origin v1.0.0 + +# Or use npm version (automatically creates tag) +npm version minor +git push --follow-tags +``` + +## Version History + +### Pre-1.0.0 (Development Phase) + +During the `0.x.x` phase: +- Breaking changes are allowed in MINOR versions +- API is not considered stable +- Use `0.x.x` for initial development + +### 1.0.0 (Stable Release) + +Once the API is stable and production-ready: +- Release `1.0.0` +- Follow strict semantic versioning +- Breaking changes require MAJOR version bump + +## Questions? + +If you have questions about versioning or contributions, please: +- Open an issue on GitHub +- Contact the maintainers + +Thank you for contributing! πŸš€ diff --git a/README.md b/README.md index c118335..632d76e 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,73 @@ npm run lint npm run format ``` +## Versioning + +This project follows [Semantic Versioning 2.0.0](https://semver.org/): + +- **MAJOR** version (X.0.0): Breaking changes +- **MINOR** version (0.X.0): New features (backward compatible) +- **PATCH** version (0.0.X): Bug fixes (backward compatible) + +### Release Process + +```bash +# Bug fix release (1.0.0 -> 1.0.1) +npm run version:patch + +# New feature release (1.0.0 -> 1.1.0) +npm run version:minor + +# Breaking change release (1.0.0 -> 2.0.0) +npm run version:major + +# Pre-release (1.0.0 -> 1.0.1-beta.0) +npm run version:prerelease + +# Push release +npm run release +``` + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed versioning guidelines. + +## Git-Flow + +This project uses Git-Flow with three branches: + +``` +develop β†’ CI only (no deployments) +staging β†’ Staging deployment (pre-production) +main β†’ Production deployment +``` + +**Quick Reference:** [.github/GIT-FLOW.md](./.github/GIT-FLOW.md) + +### Branch Flow +``` +feature/xxx β†’ develop β†’ staging β†’ main +``` + +### Quick Start +```bash +# 1. Create feature from develop +git checkout -b feature/my-feature develop + +# 2. Create PR to develop (CI runs) + +# 3. Merge develop to staging (deploys to staging) + +# 4. Merge staging to main (deploys to production) +``` + +## Contributing + +We welcome contributions! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for: +- **Git-Flow branch strategy** (develop β†’ staging β†’ main) +- Semantic versioning guidelines +- Commit message conventions (Conventional Commits) +- Development workflow +- Pull request process + ## License MIT diff --git a/jest.config.js b/jest.config.js index 3c89ac2..8523ce3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,13 +1,13 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], + roots: ['/tests'], testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', - '!src/**/*.test.ts', - '!src/**/__tests__/**' + '!src/**/__tests__/**', + '!src/**/__mocks__/**' ], coverageDirectory: 'coverage', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] diff --git a/package-lock.json b/package-lock.json index 6e0895b..87a5065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "@yourorg/appsheet", + "name": "@techdivision/appsheet", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@yourorg/appsheet", + "name": "@techdivision/appsheet", "version": "0.1.0", "license": "MIT", "dependencies": { + "@types/uuid": "^10.0.0", "axios": "^1.6.0", "commander": "^11.0.0", + "uuid": "^13.0.0", "yaml": "^2.3.0" }, "bin": { @@ -1457,6 +1459,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", @@ -5905,6 +5913,19 @@ "license": "MIT", "optional": true }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index bb5ae0f..c119426 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,12 @@ "format": "prettier --write \"src/**/*.ts\"", "docs": "typedoc", "docs:serve": "typedoc && npx http-server docs/api -o", - "prepare": "npm run build" + "prepare": "npm run build", + "version:patch": "npm version patch", + "version:minor": "npm version minor", + "version:major": "npm version major", + "version:prerelease": "npm version prerelease --preid=beta", + "release": "npm test && npm run lint && npm run build && git push --follow-tags" }, "keywords": [ "appsheet", @@ -46,8 +51,10 @@ "typescript": "^5.0.0" }, "dependencies": { + "@types/uuid": "^10.0.0", "axios": "^1.6.0", "commander": "^11.0.0", + "uuid": "^13.0.0", "yaml": "^2.3.0" }, "optionalDependencies": { diff --git a/src/client/AppSheetClient.test.ts b/src/client/AppSheetClient.test.ts deleted file mode 100644 index 7e5fd43..0000000 --- a/src/client/AppSheetClient.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * Tests for AppSheetClient with runAsUserEmail functionality - */ - -import axios from 'axios'; -import { AppSheetClient } from './AppSheetClient'; - -// Mock axios -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -describe('AppSheetClient - runAsUserEmail', () => { - const mockConfig = { - appId: 'test-app-id', - applicationAccessKey: 'test-key', - }; - - const mockAxiosInstance = { - post: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockedAxios.create.mockReturnValue(mockAxiosInstance as any); - }); - - describe('Global runAsUserEmail configuration', () => { - it('should include RunAsUserEmail in Properties when globally configured', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'global@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [] }, - }); - - await client.findAll('TestTable'); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'global@example.com', - }), - }) - ); - }); - - it('should not include RunAsUserEmail when not configured', async () => { - const client = new AppSheetClient(mockConfig); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [] }, - }); - - await client.findAll('TestTable'); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: {}, - }) - ); - }); - - it('should apply global runAsUserEmail to add operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'admin@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '123' }] }, - }); - - await client.add({ - tableName: 'Users', - rows: [{ name: 'John' }], - }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Action: 'Add', - Properties: expect.objectContaining({ - RunAsUserEmail: 'admin@example.com', - }), - }) - ); - }); - - it('should apply global runAsUserEmail to update operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'editor@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '123', name: 'Updated' }] }, - }); - - await client.update({ - tableName: 'Users', - rows: [{ id: '123', name: 'Updated' }], - }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Action: 'Edit', - Properties: expect.objectContaining({ - RunAsUserEmail: 'editor@example.com', - }), - }) - ); - }); - - it('should apply global runAsUserEmail to delete operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'deleter@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [] }, - }); - - await client.delete({ - tableName: 'Users', - rows: [{ id: '123' }], - }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Action: 'Delete', - Properties: expect.objectContaining({ - RunAsUserEmail: 'deleter@example.com', - }), - }) - ); - }); - }); - - describe('Per-operation runAsUserEmail override', () => { - it('should allow per-operation override of global runAsUserEmail', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'global@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '123' }] }, - }); - - await client.add({ - tableName: 'Users', - rows: [{ name: 'John' }], - properties: { - RunAsUserEmail: 'override@example.com', - }, - }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'override@example.com', - }), - }) - ); - }); - - it('should merge per-operation properties with global runAsUserEmail', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'global@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [] }, - }); - - await client.find({ - tableName: 'Users', - properties: { - Locale: 'de-DE', - Timezone: 'Europe/Berlin', - }, - }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'global@example.com', - Locale: 'de-DE', - Timezone: 'Europe/Berlin', - }), - }) - ); - }); - - it('should handle selector and runAsUserEmail together in find operations', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'finder@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [] }, - }); - - await client.find({ - tableName: 'Users', - selector: '[Status] = "Active"', - }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'finder@example.com', - Selector: '[Status] = "Active"', - }), - }) - ); - }); - }); - - describe('Convenience methods', () => { - it('should apply runAsUserEmail to findAll convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'reader@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '1' }, { id: '2' }] }, - }); - - await client.findAll('Users'); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Action: 'Find', - Properties: expect.objectContaining({ - RunAsUserEmail: 'reader@example.com', - }), - }) - ); - }); - - it('should apply runAsUserEmail to findOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'reader@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '123', name: 'John' }] }, - }); - - await client.findOne('Users', '[Email] = "john@example.com"'); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'reader@example.com', - Selector: '[Email] = "john@example.com"', - }), - }) - ); - }); - - it('should apply runAsUserEmail to addOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'creator@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '123', name: 'Jane' }] }, - }); - - await client.addOne('Users', { name: 'Jane' }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'creator@example.com', - }), - }) - ); - }); - - it('should apply runAsUserEmail to updateOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'updater@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [{ id: '123', name: 'Updated' }] }, - }); - - await client.updateOne('Users', { id: '123', name: 'Updated' }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'updater@example.com', - }), - }) - ); - }); - - it('should apply runAsUserEmail to deleteOne convenience method', async () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'deleter@example.com', - }); - - mockAxiosInstance.post.mockResolvedValue({ - data: { Rows: [] }, - }); - - await client.deleteOne('Users', { id: '123' }); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - Properties: expect.objectContaining({ - RunAsUserEmail: 'deleter@example.com', - }), - }) - ); - }); - }); - - describe('Configuration retrieval', () => { - it('should include runAsUserEmail in getConfig() result', () => { - const client = new AppSheetClient({ - ...mockConfig, - runAsUserEmail: 'test@example.com', - }); - - const config = client.getConfig(); - - expect(config.runAsUserEmail).toBe('test@example.com'); - }); - - it('should have undefined runAsUserEmail in getConfig() when not set', () => { - const client = new AppSheetClient(mockConfig); - - const config = client.getConfig(); - - expect(config.runAsUserEmail).toBeUndefined(); - }); - }); -}); diff --git a/src/client/AppSheetClient.ts b/src/client/AppSheetClient.ts index c9d3e14..965fcdb 100644 --- a/src/client/AppSheetClient.ts +++ b/src/client/AppSheetClient.ts @@ -7,6 +7,7 @@ import axios, { AxiosInstance, AxiosError } from 'axios'; import { AppSheetConfig, + AppSheetClientInterface, RequestProperties, AddOptions, FindOptions, @@ -55,7 +56,7 @@ import { * }); * ``` */ -export class AppSheetClient { +export class AppSheetClient implements AppSheetClientInterface { private readonly axios: AxiosInstance; private readonly config: Required> & { runAsUserEmail?: string }; diff --git a/src/client/MockAppSheetClient.ts b/src/client/MockAppSheetClient.ts new file mode 100644 index 0000000..d25245e --- /dev/null +++ b/src/client/MockAppSheetClient.ts @@ -0,0 +1,402 @@ +/** + * MockAppSheetClient - Mock Implementation for Testing + * + * Provides a mock implementation of AppSheetClient for testing purposes. + * Uses in-memory storage instead of making real API requests. + * + * @module client + * @category Client + */ + +import { v4 as uuidv4 } from 'uuid'; +import { + AppSheetConfig, + AppSheetClientInterface, + AddOptions, + FindOptions, + UpdateOptions, + DeleteOptions, + AddResponse, + FindResponse, + UpdateResponse, + DeleteResponse, + ValidationError, + NotFoundError, + MockDataProvider, +} from '../types'; +import { MockDatabase } from './__mocks__/MockDatabase'; +import { createDefaultMockData } from './__mocks__/mockData'; + +/** + * Mock AppSheet API client for testing. + * + * Implements the same interface as AppSheetClient but uses an in-memory database. + * Useful for unit and integration tests without hitting the real AppSheet API. + * + * @category Client + * + * @example + * ```typescript + * // Option 1: Use default mock data (example data for testing) + * const client = new MockAppSheetClient({ + * appId: 'mock-app', + * applicationAccessKey: 'mock-key' + * }); + * client.seedDatabase(); // Load default example data + * + * // Option 2: Use project-specific mock data (recommended) + * class MyProjectMockData implements MockDataProvider { + * getTables(): Map { + * const tables = new Map(); + * tables.set('users', { + * rows: [{ id: '1', name: 'John' }], + * keyField: 'id' + * }); + * return tables; + * } + * } + * + * const mockData = new MyProjectMockData(); + * const client = new MockAppSheetClient({ + * appId: 'mock-app', + * applicationAccessKey: 'mock-key' + * }, mockData); // Tables are automatically seeded + * + * // Use like real client + * const users = await client.findAll('users'); + * const user = await client.addOne('users', { id: '2', name: 'Jane' }); + * ``` + */ +export class MockAppSheetClient implements AppSheetClientInterface { + private readonly config: Required> & { + runAsUserEmail?: string; + }; + private readonly database: MockDatabase; + + /** + * Creates a new Mock AppSheet client instance. + * + * @param config - Configuration for the mock client (only appId and applicationAccessKey are used) + * @param dataProvider - Optional project-specific mock data provider. If provided, tables are automatically seeded. + * + * @example + * ```typescript + * // Without data provider (manual seeding) + * const client = new MockAppSheetClient({ + * appId: 'mock-app', + * applicationAccessKey: 'mock-key', + * runAsUserEmail: 'test@example.com' + * }); + * client.seedDatabase(); // Load default example data + * + * // With data provider (automatic seeding) + * const mockData = new MyProjectMockData(); + * const client = new MockAppSheetClient({ + * appId: 'mock-app', + * applicationAccessKey: 'mock-key' + * }, mockData); // Tables automatically seeded + * ``` + */ + constructor(config: AppSheetConfig, dataProvider?: MockDataProvider) { + this.config = { + baseUrl: 'https://api.appsheet.com/api/v2', + timeout: 30000, + retryAttempts: 3, + ...config, + }; + + this.database = new MockDatabase(); + + // Auto-seed from data provider if provided + if (dataProvider) { + this.loadFromProvider(dataProvider); + } + } + + /** + * Seed the database with default example mock data. + * + * **Note:** This method loads generic example data for quick testing. + * For production tests, use a project-specific MockDataProvider instead. + * + * Creates: + * - 3 areas (generic examples) + * - 4 categories (generic examples) + * - 50 services (generic examples with UUIDs) + * + * @deprecated Consider using MockDataProvider for project-specific test data + * + * @example + * ```typescript + * // Quick testing with example data + * client.seedDatabase(); + * const services = await client.findAll('service_portfolio'); + * // Returns 50 generic example services + * ``` + */ + seedDatabase(): void { + const { areas, categories, services } = createDefaultMockData(); + + this.database.initializeTable('area', areas, 'area_id'); + this.database.initializeTable('category', categories, 'category_id'); + this.database.initializeTable('service_portfolio', services, 'service_portfolio_id'); + } + + /** + * Clear all data from the mock database. + * + * Useful for cleaning up between tests. + */ + clearDatabase(): void { + this.database.clearAll(); + } + + /** + * Clear a specific table. + * + * @param tableName - Name of the table to clear + */ + clearTable(tableName: string): void { + this.database.clearTable(tableName); + } + + /** + * Add (Create) one or more rows to a table. + */ + async add = Record>(options: AddOptions): Promise> { + const createdRows: T[] = []; + + for (const row of options.rows) { + // Generate ID if not provided + const keyField = this.getKeyField(options.tableName); + const rowWithId = { + ...row, + [keyField]: (row as any)[keyField] || uuidv4(), + created_at: new Date().toISOString(), + created_by: + options.properties?.RunAsUserEmail || this.config.runAsUserEmail || 'mock@example.com', + } as T; + + const created = this.database.insert(options.tableName, rowWithId, keyField); + createdRows.push(created); + } + + return { + rows: createdRows, + warnings: [], + }; + } + + /** + * Find (Read) rows from a table with optional filtering. + */ + async find = Record>(options: FindOptions): Promise> { + let rows = this.database.findAll(options.tableName); + + // Apply selector filter if provided + if (options.selector) { + rows = this.applySelector(rows, options.selector); + } + + return { + rows, + warnings: [], + }; + } + + /** + * Update (Edit) one or more rows in a table. + */ + async update = Record>(options: UpdateOptions): Promise> { + const updatedRows: T[] = []; + const keyField = this.getKeyField(options.tableName); + + for (const row of options.rows) { + const keyValue = (row as any)[keyField]; + if (!keyValue) { + throw new ValidationError( + `Row is missing key field "${keyField}"`, + { field: keyField, tableName: options.tableName } + ); + } + + const updated = this.database.update(options.tableName, keyValue, { + ...row, + modified_at: new Date().toISOString(), + modified_by: + options.properties?.RunAsUserEmail || this.config.runAsUserEmail || 'mock@example.com', + } as Partial); + + if (!updated) { + throw new NotFoundError( + `Row with key "${keyValue}" not found in table "${options.tableName}"`, + { key: keyValue, tableName: options.tableName } + ); + } + + updatedRows.push(updated); + } + + return { + rows: updatedRows, + warnings: [], + }; + } + + /** + * Delete one or more rows from a table. + */ + async delete = Record>(options: DeleteOptions): Promise { + const keyField = this.getKeyField(options.tableName); + let deletedCount = 0; + + for (const row of options.rows) { + const keyValue = (row as any)[keyField]; + if (!keyValue) { + throw new ValidationError( + `Row is missing key field "${keyField}"`, + { field: keyField, tableName: options.tableName } + ); + } + + const deleted = this.database.delete(options.tableName, keyValue); + if (deleted) { + deletedCount++; + } + } + + return { + success: true, + deletedCount, + warnings: [], + }; + } + + /** + * Convenience method to find all rows in a table. + */ + async findAll = Record>(tableName: string): Promise { + const response = await this.find({ tableName }); + return response.rows; + } + + /** + * Convenience method to find a single row by selector. + */ + async findOne = Record>( + tableName: string, + selector: string + ): Promise { + const response = await this.find({ tableName, selector }); + return response.rows[0] || null; + } + + /** + * Convenience method to add a single row to a table. + */ + async addOne = Record>(tableName: string, row: T): Promise { + const response = await this.add({ tableName, rows: [row] }); + return response.rows[0]; + } + + /** + * Convenience method to update a single row in a table. + */ + async updateOne = Record>(tableName: string, row: T): Promise { + const response = await this.update({ tableName, rows: [row] }); + return response.rows[0]; + } + + /** + * Convenience method to delete a single row from a table. + */ + async deleteOne = Record>(tableName: string, row: T): Promise { + await this.delete({ tableName, rows: [row] }); + return true; + } + + /** + * Get the current client configuration. + */ + getConfig(): Readonly< + Required> & { runAsUserEmail?: string } + > { + return { ...this.config }; + } + + /** + * Load mock data from a MockDataProvider. + * + * Initializes all tables provided by the data provider. + * Useful for seeding the database with project-specific test data. + * + * @param provider - MockDataProvider implementation with getTables() + * + * @example + * ```typescript + * const mockData = new MyProjectMockData(); + * client.loadFromProvider(mockData); + * ``` + */ + private loadFromProvider(provider: MockDataProvider): void { + const tables = provider.getTables(); + + tables.forEach((tableData, tableName) => { + this.database.initializeTable(tableName, tableData.rows, tableData.keyField); + }); + } + + /** + * Get the key field for a table. + * + * Maps table names to their primary key fields. + */ + private getKeyField(tableName: string): string { + const keyFieldMap: Record = { + service_portfolio: 'service_portfolio_id', + area: 'area_id', + category: 'category_id', + }; + + return keyFieldMap[tableName] || 'id'; + } + + /** + * Apply AppSheet selector filter to rows. + * + * Supports simple selectors like: + * - `[field] = "value"` - Exact match + * - `[field] = 'value'` - Exact match (single quotes) + * - `[field] IN ("value1", "value2")` - Array match + * + * @param rows - Rows to filter + * @param selector - AppSheet selector expression + * @returns Filtered rows + */ + private applySelector(rows: T[], selector: string): T[] { + // Parse simple selector: [field] = "value" + const exactMatchRegex = /\[(\w+)\]\s*=\s*["']([^"']+)["']/; + const exactMatch = selector.match(exactMatchRegex); + + if (exactMatch) { + const [, field, value] = exactMatch; + return rows.filter((row) => (row as any)[field] === value); + } + + // Parse IN selector: [field] IN ("value1", "value2") + const inMatchRegex = /\[(\w+)\]\s+IN\s+\(([^)]+)\)/i; + const inMatch = selector.match(inMatchRegex); + + if (inMatch) { + const [, field, valuesStr] = inMatch; + const values = valuesStr + .split(',') + .map((v) => v.trim().replace(/["']/g, '')); + + return rows.filter((row) => values.includes((row as any)[field])); + } + + // If selector is not recognized, return all rows (no filter) + return rows; + } +} diff --git a/src/client/__mocks__/MockDatabase.ts b/src/client/__mocks__/MockDatabase.ts new file mode 100644 index 0000000..e53e215 --- /dev/null +++ b/src/client/__mocks__/MockDatabase.ts @@ -0,0 +1,239 @@ +/** + * MockDatabase - In-Memory Database for MockAppSheetClient + * + * Provides thread-safe in-memory storage for mock AppSheet data. + * Used by MockAppSheetClient to simulate AppSheet API behavior. + * + * @module client + * @category Client + */ + +/** + * In-memory database for mock AppSheet data. + * + * Stores data by table name with Map for O(1) lookups. + * Thread-safe for concurrent operations. + * + * @category Client + * + * @example + * ```typescript + * const db = new MockDatabase(); + * db.insert('Users', { id: '1', name: 'John' }); + * const user = db.findOne('Users', { id: '1' }); + * ``` + */ +export class MockDatabase { + private readonly tables: Map>; + + constructor() { + this.tables = new Map(); + } + + /** + * Initialize a table with seed data. + * + * @param tableName - Name of the table + * @param rows - Array of rows to seed + * @param keyField - Field to use as primary key (default: first UUID field or 'id') + */ + initializeTable>( + tableName: string, + rows: T[], + keyField?: string + ): void { + const table = new Map(); + + // Auto-detect key field if not provided + const key = keyField || this.detectKeyField(rows[0]); + + rows.forEach((row) => { + const keyValue = row[key]; + if (!keyValue) { + throw new Error(`Row is missing key field "${key}"`); + } + table.set(keyValue, { ...row }); + }); + + this.tables.set(tableName, table); + } + + /** + * Insert a row into a table. + * + * @param tableName - Name of the table + * @param row - Row to insert + * @param keyField - Field to use as primary key + * @returns The inserted row + */ + insert>(tableName: string, row: T, keyField: string): T { + const table = this.getOrCreateTable(tableName); + const keyValue = row[keyField]; + + if (!keyValue) { + throw new Error(`Row is missing key field "${keyField}"`); + } + + if (table.has(keyValue)) { + throw new Error(`Row with key "${keyValue}" already exists in table "${tableName}"`); + } + + const newRow = { ...row }; + table.set(keyValue, newRow); + return newRow; + } + + /** + * Find all rows in a table. + * + * @param tableName - Name of the table + * @returns Array of all rows + */ + findAll>(tableName: string): T[] { + const table = this.tables.get(tableName); + if (!table) { + return []; + } + // Return deep copies to prevent external mutation + return Array.from(table.values()).map((row) => ({ ...row })); + } + + /** + * Find a single row by key. + * + * @param tableName - Name of the table + * @param key - Key value to search for + * @returns The row or null if not found + */ + findOne>(tableName: string, key: string): T | null { + const table = this.tables.get(tableName); + if (!table) { + return null; + } + const row = table.get(key); + return row ? { ...row } : null; + } + + /** + * Find rows matching a filter predicate. + * + * @param tableName - Name of the table + * @param predicate - Filter function + * @returns Array of matching rows + */ + findWhere>( + tableName: string, + predicate: (row: T) => boolean + ): T[] { + const all = this.findAll(tableName); + return all.filter(predicate); + } + + /** + * Update a row in a table. + * + * @param tableName - Name of the table + * @param key - Key value to update + * @param updates - Partial row with fields to update + * @returns The updated row or null if not found + */ + update>( + tableName: string, + key: string, + updates: Partial + ): T | null { + const table = this.tables.get(tableName); + if (!table) { + return null; + } + + const existing = table.get(key); + if (!existing) { + return null; + } + + const updated = { ...existing, ...updates }; + table.set(key, updated); + return { ...updated }; + } + + /** + * Delete a row from a table. + * + * @param tableName - Name of the table + * @param key - Key value to delete + * @returns True if deleted, false if not found + */ + delete(tableName: string, key: string): boolean { + const table = this.tables.get(tableName); + if (!table) { + return false; + } + return table.delete(key); + } + + /** + * Clear all data from a table. + * + * @param tableName - Name of the table + */ + clearTable(tableName: string): void { + this.tables.delete(tableName); + } + + /** + * Clear all tables and data. + */ + clearAll(): void { + this.tables.clear(); + } + + /** + * Get or create a table. + */ + private getOrCreateTable(tableName: string): Map { + if (!this.tables.has(tableName)) { + this.tables.set(tableName, new Map()); + } + return this.tables.get(tableName)!; + } + + /** + * Auto-detect key field from row. + * Looks for common patterns: + * - Fields ending with '_id' (e.g., 'service_portfolio_id') + * - Field named 'id' + * - First UUID-formatted field + */ + private detectKeyField(row: Record): string { + if (!row) { + return 'id'; + } + + const keys = Object.keys(row); + + // Look for fields ending with '_id' + const idField = keys.find((key) => key.endsWith('_id')); + if (idField) { + return idField; + } + + // Look for 'id' field + if (keys.includes('id')) { + return 'id'; + } + + // Look for first UUID field + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidField = keys.find((key) => { + const value = row[key]; + return typeof value === 'string' && uuidRegex.test(value); + }); + if (uuidField) { + return uuidField; + } + + // Default to 'id' + return 'id'; + } +} diff --git a/src/client/__mocks__/mockData.ts b/src/client/__mocks__/mockData.ts new file mode 100644 index 0000000..786221b --- /dev/null +++ b/src/client/__mocks__/mockData.ts @@ -0,0 +1,251 @@ +/** + * Example Mock Data for MockAppSheetClient + * + * **IMPORTANT:** These are generic example data for quick testing purposes. + * For production tests in your project, create a project-specific MockDataProvider + * implementation instead of using these example data. + * + * Provides example test data for service portfolios, areas, and categories. + * Uses UUIDs for IDs and generic English service names. + * + * @see MockDataProvider for implementing project-specific mock data + * @module client + * @category Client + * @example + * ```typescript + * // DON'T: Use example data in production tests + * const { services } = createDefaultMockData(); + * + * // DO: Create project-specific mock data provider + * class MyProjectMockData implements MockDataProvider { + * getTables(): Map { + * const tables = new Map(); + * tables.set('service_portfolio', { + * rows: [ + * { service_portfolio_id: 'service-001', service: 'My Service' } + * ], + * keyField: 'service_portfolio_id' + * }); + * return tables; + * } + * } + * ``` + */ + +import { v4 as uuidv4 } from 'uuid'; + +/** + * Service Portfolio Mock Data + * + * Represents services in the TechDivision service portfolio. + */ +export interface ServicePortfolio { + service_portfolio_id: string; + service: string; + area_id_fk: string; + category_id_fk: string; + flight_level?: string; + method_toolkit?: string; + clarifying_question?: string; + result_deliverable?: string; + activity_field?: string; + teams?: string; + type?: string; + solution?: string; + status: 'Akzeptiert' | 'Vorgeschlagen' | 'Deprecated'; + created_at: string; + created_by: string; + modified_at?: string; + modified_by?: string; +} + +/** + * Area Mock Data + */ +export interface Area { + area_id: string; + name: string; + description?: string; +} + +/** + * Category Mock Data + */ +export interface Category { + category_id: string; + name: string; + description?: string; +} + +/** + * Generate mock areas (3 total) + */ +export function generateMockAreas(): Area[] { + return [ + { + area_id: uuidv4(), + name: 'Consulting', + description: 'Beratungsleistungen und Strategie', + }, + { + area_id: uuidv4(), + name: 'Solutions', + description: 'Technische LΓΆsungsentwicklung', + }, + { + area_id: uuidv4(), + name: 'Operations', + description: 'Betrieb und Support', + }, + ]; +} + +/** + * Generate mock categories (4 total) + */ +export function generateMockCategories(): Category[] { + return [ + { + category_id: uuidv4(), + name: 'Development', + description: 'Softwareentwicklung', + }, + { + category_id: uuidv4(), + name: 'Architecture', + description: 'Systemarchitektur', + }, + { + category_id: uuidv4(), + name: 'DevOps', + description: 'CI/CD und Infrastruktur', + }, + { + category_id: uuidv4(), + name: 'Quality Assurance', + description: 'Testing und QualitΓ€tssicherung', + }, + ]; +} + +/** + * Generate mock services (50 total) + * + * @param areas - Array of areas to link services to + * @param categories - Array of categories to link services to + */ +export function generateMockServices(areas: Area[], categories: Category[]): ServicePortfolio[] { + const services: ServicePortfolio[] = []; + const statuses: Array<'Akzeptiert' | 'Vorgeschlagen' | 'Deprecated'> = [ + 'Akzeptiert', + 'Vorgeschlagen', + 'Deprecated', + ]; + const flightLevels = ['1', '2', '3']; + + // Service name templates + const serviceTemplates = [ + 'API Development', + 'Microservices Architecture', + 'Cloud Migration', + 'Performance Optimization', + 'Security Audit', + 'Code Review', + 'CI/CD Pipeline', + 'Database Design', + 'Frontend Development', + 'Backend Development', + 'Mobile App Development', + 'System Integration', + 'Data Migration', + 'Infrastructure Setup', + 'Monitoring Setup', + 'Logging Implementation', + 'Authentication Service', + 'Authorization Framework', + 'Payment Gateway Integration', + 'Email Service', + 'Notification System', + 'Search Implementation', + 'Caching Strategy', + 'Load Balancing', + 'Container Orchestration', + 'Serverless Functions', + 'Message Queue Setup', + 'Event Streaming', + 'GraphQL API', + 'REST API', + 'WebSocket Implementation', + 'gRPC Service', + 'Service Mesh', + 'API Gateway', + 'Rate Limiting', + 'Data Analytics', + 'Machine Learning Pipeline', + 'Report Generation', + 'Dashboard Development', + 'User Management', + 'Role-Based Access Control', + 'OAuth2 Implementation', + 'SSO Integration', + 'MFA Setup', + 'Backup Strategy', + 'Disaster Recovery', + 'High Availability Setup', + 'Multi-Region Deployment', + 'Content Delivery Network', + 'WAF Configuration', + ]; + + // Generate 50 services + for (let i = 0; i < 50; i++) { + const area = areas[i % areas.length]; + const category = categories[i % categories.length]; + const status = statuses[i % statuses.length]; + const flightLevel = flightLevels[i % flightLevels.length]; + + services.push({ + service_portfolio_id: uuidv4(), + service: serviceTemplates[i], + area_id_fk: area.area_id, + category_id_fk: category.category_id, + flight_level: flightLevel, + method_toolkit: `Toolkit ${i + 1}`, + clarifying_question: `Wie implementieren wir ${serviceTemplates[i]}?`, + result_deliverable: `Deliverable fΓΌr ${serviceTemplates[i]}`, + activity_field: `Activity ${i + 1}`, + teams: i % 2 === 0 ? 'Team Alpha' : 'Team Beta', + type: i % 3 === 0 ? 'Platform' : 'Application', + solution: `Solution fΓΌr ${serviceTemplates[i]}`, + status, + created_at: new Date(Date.now() - i * 86400000).toISOString(), + created_by: 'mock@example.com', + modified_at: i % 5 === 0 ? new Date(Date.now() - i * 43200000).toISOString() : undefined, + modified_by: i % 5 === 0 ? 'modifier@example.com' : undefined, + }); + } + + return services; +} + +/** + * Create default mock dataset. + * + * Generates consistent test data with: + * - 3 areas + * - 4 categories + * - 50 services + * + * @returns Object with areas, categories, and services + */ +export function createDefaultMockData(): { + areas: Area[]; + categories: Category[]; + services: ServicePortfolio[]; +} { + const areas = generateMockAreas(); + const categories = generateMockCategories(); + const services = generateMockServices(areas, categories); + + return { areas, categories, services }; +} diff --git a/src/client/index.ts b/src/client/index.ts index ab9a8e1..11d9521 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,4 +3,5 @@ */ export * from './AppSheetClient'; +export * from './MockAppSheetClient'; export * from './DynamicTable'; diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 0000000..a298426 --- /dev/null +++ b/src/types/client.ts @@ -0,0 +1,139 @@ +/** + * Client interface types + * @module types + * @category Types + */ + +import { AppSheetConfig } from './config'; +import { + AddOptions, + FindOptions, + UpdateOptions, + DeleteOptions, +} from './operations'; +import { + AddResponse, + FindResponse, + UpdateResponse, + DeleteResponse, +} from './responses'; + +/** + * Interface for AppSheet client implementations. + * + * This interface defines the contract that all AppSheet client implementations + * (real and mock) must follow. This ensures type safety and allows for easy + * swapping between real and mock implementations in tests. + * + * @category Types + * + * @example + * ```typescript + * // Function that works with any client implementation + * async function getUserCount(client: AppSheetClientInterface): Promise { + * const users = await client.findAll('Users'); + * return users.length; + * } + * + * // Can use with real client + * const realClient = new AppSheetClient({ appId, applicationAccessKey }); + * const count1 = await getUserCount(realClient); + * + * // Or with mock client + * const mockClient = new MockAppSheetClient({ appId, applicationAccessKey }); + * const count2 = await getUserCount(mockClient); + * ``` + */ +export interface AppSheetClientInterface { + /** + * Add (Create) one or more rows to a table. + * + * @template T - The type of the rows being added + * @param options - Options for the add operation + * @returns Promise resolving to the created rows with server-generated fields + */ + add = Record>(options: AddOptions): Promise>; + + /** + * Find (Read) rows from a table with optional filtering. + * + * @template T - The type of the rows being retrieved + * @param options - Options for the find operation + * @returns Promise resolving to the found rows + */ + find = Record>(options: FindOptions): Promise>; + + /** + * Update (Edit) one or more rows in a table. + * + * @template T - The type of the rows being updated + * @param options - Options for the update operation + * @returns Promise resolving to the updated rows + */ + update = Record>(options: UpdateOptions): Promise>; + + /** + * Delete one or more rows from a table. + * + * @template T - The type of the rows being deleted + * @param options - Options for the delete operation + * @returns Promise resolving to deletion result + */ + delete = Record>(options: DeleteOptions): Promise; + + /** + * Convenience method to find all rows in a table. + * + * @template T - The type of the rows being retrieved + * @param tableName - The name of the table + * @returns Promise resolving to all rows in the table + */ + findAll = Record>(tableName: string): Promise; + + /** + * Convenience method to find a single row by selector. + * + * @template T - The type of the row being retrieved + * @param tableName - The name of the table + * @param selector - AppSheet selector expression to filter rows + * @returns Promise resolving to the first matching row or null + */ + findOne = Record>(tableName: string, selector: string): Promise; + + /** + * Convenience method to add a single row to a table. + * + * @template T - The type of the row being added + * @param tableName - The name of the table + * @param row - The row to add + * @returns Promise resolving to the created row + */ + addOne = Record>(tableName: string, row: T): Promise; + + /** + * Convenience method to update a single row in a table. + * + * @template T - The type of the row being updated + * @param tableName - The name of the table + * @param row - The row with updated fields (must include key field) + * @returns Promise resolving to the updated row + */ + updateOne = Record>(tableName: string, row: T): Promise; + + /** + * Convenience method to delete a single row from a table. + * + * @template T - The type of the row being deleted + * @param tableName - The name of the table + * @param row - The row to delete (must include key field) + * @returns Promise resolving to true if deleted successfully + */ + deleteOne = Record>(tableName: string, row: T): Promise; + + /** + * Get the current client configuration. + * + * @returns Readonly copy of the client configuration + */ + getConfig(): Readonly> & { runAsUserEmail?: string }>; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0d7d451..3c7bbf9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,9 @@ // Config types export * from './config'; +// Client interface +export * from './client'; + // Operation types export * from './operations'; @@ -16,3 +19,6 @@ export * from './schema'; // Error types export * from './errors'; + +// Mock types +export * from './mock'; diff --git a/src/types/mock.ts b/src/types/mock.ts new file mode 100644 index 0000000..4f8797d --- /dev/null +++ b/src/types/mock.ts @@ -0,0 +1,142 @@ +/** + * Type definitions for Mock implementations + * + * Provides interfaces for project-specific mock data providers. + * Mock data should be implemented per project, not in the library. + * + * @module types + * @category Types + */ + +/** + * Table data structure for mock database. + * + * Contains rows and optional key field specification. + * + * @typeParam T - Type of rows in the table + * + * @example + * ```typescript + * const tableData: TableData = { + * rows: [ + * { id: '1', name: 'John' }, + * { id: '2', name: 'Jane' } + * ], + * keyField: 'id' + * }; + * ``` + */ +export interface TableData { + /** + * Array of rows to be inserted into the table. + * Each row should contain the key field specified in keyField. + */ + rows: T[]; + + /** + * Name of the field to use as primary key. + * + * If not provided, the MockDatabase will auto-detect the key field + * by looking for fields ending with '_id' (e.g., 'service_portfolio_id'), + * or falling back to 'id'. + * + * @example 'service_portfolio_id' + * @example 'user_id' + * @example 'id' + */ + keyField?: string; +} + +/** + * Interface for project-specific mock data providers. + * + * Implement this interface to provide custom mock data for your project. + * The interface is intentionally generic - it does not prescribe specific + * methods like getUsers() or getServices() since these are project-specific. + * + * @category Mock + * + * @example + * ```typescript + * // Example: Service Portfolio Mock Data + * class ServicePortfolioMockData implements MockDataProvider { + * getTables(): Map { + * const tables = new Map(); + * + * tables.set('area', { + * rows: [ + * { area_id: 'area-001', name: 'Consulting' }, + * { area_id: 'area-002', name: 'Solutions' }, + * ], + * keyField: 'area_id' + * }); + * + * tables.set('service_portfolio', { + * rows: [ + * { + * service_portfolio_id: 'service-001', + * service: 'Cloud Migration', + * area_id_fk: 'area-001', + * status: 'Akzeptiert' + * }, + * // ... more services + * ], + * keyField: 'service_portfolio_id' + * }); + * + * return tables; + * } + * } + * + * // Usage with MockAppSheetClient + * const mockData = new ServicePortfolioMockData(); + * const client = new MockAppSheetClient({ + * appId: 'mock-app', + * applicationAccessKey: 'mock-key' + * }, mockData); + * + * // Tables are automatically seeded + * const services = await client.findAll('service_portfolio'); + * ``` + * + * @example + * ```typescript + * // Example: E-Commerce Mock Data + * class ECommerceMockData implements MockDataProvider { + * getTables(): Map { + * const tables = new Map(); + * + * tables.set('products', { + * rows: [ + * { id: '1', name: 'Laptop', price: 999 }, + * { id: '2', name: 'Mouse', price: 29 }, + * ], + * keyField: 'id' + * }); + * + * tables.set('orders', { + * rows: [ + * { id: '1', product_id: '1', quantity: 1 }, + * ], + * keyField: 'id' + * }); + * + * return tables; + * } + * } + * ``` + */ +export interface MockDataProvider { + /** + * Get all tables with their mock data. + * + * Returns a Map where: + * - Key: Table name (e.g., 'service_portfolio', 'users', 'products') + * - Value: TableData with rows and optional keyField + * + * This method is called by MockAppSheetClient to seed the mock database. + * + * @returns Map of table name to table data + */ + getTables(): Map; +} diff --git a/tests/__mocks__/uuid.ts b/tests/__mocks__/uuid.ts new file mode 100644 index 0000000..d464abe --- /dev/null +++ b/tests/__mocks__/uuid.ts @@ -0,0 +1,14 @@ +/** + * Mock for uuid library + */ + +let counter = 0; + +export function v4(): string { + counter++; + return `mock-uuid-${counter.toString().padStart(10, '0')}`; +} + +export function resetCounter(): void { + counter = 0; +} diff --git a/tests/client/AppSheetClient.test.ts b/tests/client/AppSheetClient.test.ts new file mode 100644 index 0000000..4000bf3 --- /dev/null +++ b/tests/client/AppSheetClient.test.ts @@ -0,0 +1,794 @@ +/** + * Test Suite: AppSheetClient - runAsUserEmail Functionality + * + * Integration test suite for the AppSheetClient class that verifies the + * runAsUserEmail feature works correctly with the real HTTP client (mocked axios). + * + * Unlike MockAppSheetClient tests which use in-memory storage, these tests verify + * that the client correctly formats HTTP requests to the AppSheet API, specifically + * testing how the runAsUserEmail configuration is propagated to API requests. + * + * Test areas: + * - Global runAsUserEmail configuration (applied to all operations) + * - Per-operation runAsUserEmail override (operation-specific user context) + * - Property merging (combining runAsUserEmail with other request properties) + * - Convenience methods (simplified API with runAsUserEmail support) + * - Configuration retrieval (getConfig() method) + * + * @module tests/client + */ + +import axios from 'axios'; +import { AppSheetClient } from '../../src/client/AppSheetClient'; + +/** + * Mock axios module to intercept HTTP requests without hitting real API. + * This allows us to verify request structure and runAsUserEmail inclusion. + */ +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +/** + * Test Suite: AppSheetClient - runAsUserEmail Feature + * + * Tests the real AppSheetClient implementation (not the mock) by verifying + * HTTP request payloads contain correct runAsUserEmail values in the + * Properties field of AppSheet API requests. + */ +describe('AppSheetClient - runAsUserEmail', () => { + /** + * Base configuration for creating AppSheetClient instances in tests. + * Minimal config with only required fields. + */ + const mockConfig = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + }; + + /** + * Mocked axios instance that captures HTTP POST requests. + * Allows verification of request structure without network calls. + */ + const mockAxiosInstance = { + post: jest.fn(), + }; + + /** + * Setup: Reset all mocks and configure axios before each test. + * Ensures test isolation and clean state for HTTP request verification. + */ + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.create.mockReturnValue(mockAxiosInstance as any); + }); + + /** + * Test Suite: Global runAsUserEmail Configuration + * + * Verifies that when runAsUserEmail is configured globally on the client, + * it is automatically included in all HTTP requests to the AppSheet API + * in the Properties.RunAsUserEmail field. + * + * Tests cover all CRUD operations (Find, Add, Edit/Update, Delete) to ensure + * consistent behavior across different API action types. + */ + describe('Global runAsUserEmail configuration', () => { + /** + * Test: Global runAsUserEmail Included in Find Request + * + * Verifies that when a client is initialized with global runAsUserEmail, + * the HTTP request payload includes it in the Properties field. + * + * Test approach: + * 1. Create client with global runAsUserEmail + * 2. Execute a find operation + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present with correct value + * + * Expected behavior: + * - HTTP request contains Properties object + * - Properties.RunAsUserEmail equals configured value + * - All subsequent requests include this value automatically + * + * Use case: Setting up audit trail / user context for all operations + */ + it('should include RunAsUserEmail in Properties when globally configured', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'global@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.findAll('TestTable'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'global@example.com', + }), + }) + ); + }); + + /** + * Test: No runAsUserEmail When Not Configured + * + * Verifies that when runAsUserEmail is not configured, the HTTP request + * Properties field remains empty, avoiding unnecessary API parameters. + * + * Expected behavior: + * - Properties object exists but is empty + * - No RunAsUserEmail field is included + * - API request is minimal and clean + * + * Use case: Operations not requiring user context + */ + it('should not include RunAsUserEmail when not configured', async () => { + const client = new AppSheetClient(mockConfig); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.findAll('TestTable'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: {}, + }) + ); + }); + + /** + * Test: Global runAsUserEmail Included in Add Request + * + * Verifies that Add (Create) operations include the global runAsUserEmail + * in the HTTP request payload's Properties field. + * + * Test approach: + * 1. Create client with global runAsUserEmail='admin@example.com' + * 2. Execute an add operation to create new rows + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present with correct value + * 5. Verify Action field is set to 'Add' + * + * Expected behavior: + * - HTTP request contains Action: 'Add' + * - Properties.RunAsUserEmail equals 'admin@example.com' + * - Global runAsUserEmail applies to create operations automatically + * + * Use case: Audit trail showing which admin user created new records + */ + it('should apply global runAsUserEmail to add operations', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'admin@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '123' }] }, + }); + + await client.add({ + tableName: 'Users', + rows: [{ name: 'John' }], + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Action: 'Add', + Properties: expect.objectContaining({ + RunAsUserEmail: 'admin@example.com', + }), + }) + ); + }); + + /** + * Test: Global runAsUserEmail Included in Update Request + * + * Verifies that Update (Edit) operations include the global runAsUserEmail + * in the HTTP request payload's Properties field. + * + * Test approach: + * 1. Create client with global runAsUserEmail='editor@example.com' + * 2. Execute an update operation to modify existing rows + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present with correct value + * 5. Verify Action field is set to 'Edit' (AppSheet uses 'Edit', not 'Update') + * + * Expected behavior: + * - HTTP request contains Action: 'Edit' + * - Properties.RunAsUserEmail equals 'editor@example.com' + * - Global runAsUserEmail applies to update operations automatically + * + * Use case: Audit trail showing which user modified records + * Note: AppSheet API uses 'Edit' action name for update operations + */ + it('should apply global runAsUserEmail to update operations', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'editor@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '123', name: 'Updated' }] }, + }); + + await client.update({ + tableName: 'Users', + rows: [{ id: '123', name: 'Updated' }], + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Action: 'Edit', + Properties: expect.objectContaining({ + RunAsUserEmail: 'editor@example.com', + }), + }) + ); + }); + + /** + * Test: Global runAsUserEmail Included in Delete Request + * + * Verifies that Delete operations include the global runAsUserEmail + * in the HTTP request payload's Properties field. + * + * Test approach: + * 1. Create client with global runAsUserEmail='deleter@example.com' + * 2. Execute a delete operation to remove rows + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present with correct value + * 5. Verify Action field is set to 'Delete' + * + * Expected behavior: + * - HTTP request contains Action: 'Delete' + * - Properties.RunAsUserEmail equals 'deleter@example.com' + * - Global runAsUserEmail applies to delete operations automatically + * - Response Rows array is empty (rows are deleted, not returned) + * + * Use case: Audit trail showing which user deleted records + * Important: Delete operations typically return empty Rows array + */ + it('should apply global runAsUserEmail to delete operations', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'deleter@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.delete({ + tableName: 'Users', + rows: [{ id: '123' }], + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Action: 'Delete', + Properties: expect.objectContaining({ + RunAsUserEmail: 'deleter@example.com', + }), + }) + ); + }); + }); + + /** + * Test Suite: Per-operation runAsUserEmail Override + * + * Verifies that runAsUserEmail can be overridden on a per-operation basis, + * allowing different user contexts for individual operations even when a + * global runAsUserEmail is configured. + * + * This functionality is critical for scenarios where: + * - A service account is configured globally + * - Individual operations need to run as specific end users + * - Different permissions are required for different operations + * + * Tests also verify that per-operation properties can be merged with + * global runAsUserEmail configuration without conflict. + */ + describe('Per-operation runAsUserEmail override', () => { + /** + * Test: Per-operation Override of Global runAsUserEmail + * + * Verifies that a per-operation runAsUserEmail specified in the properties + * parameter takes precedence over the global configuration. + * + * Test approach: + * 1. Create client with global runAsUserEmail='global@example.com' + * 2. Execute operation with properties.RunAsUserEmail='override@example.com' + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail equals override value, not global value + * + * Expected behavior: + * - Per-operation RunAsUserEmail overrides global configuration + * - Properties.RunAsUserEmail equals 'override@example.com' (not 'global@example.com') + * - Global setting remains unchanged for subsequent operations + * + * Use case: Service configured with service account email globally, but + * specific operations need to run as the actual end user for permissions + * or audit trail purposes. + */ + it('should allow per-operation override of global runAsUserEmail', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'global@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '123' }] }, + }); + + await client.add({ + tableName: 'Users', + rows: [{ name: 'John' }], + properties: { + RunAsUserEmail: 'override@example.com', + }, + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'override@example.com', + }), + }) + ); + }); + + /** + * Test: Merging Per-operation Properties with Global runAsUserEmail + * + * Verifies that when additional properties are specified per-operation, + * they are correctly merged with the global runAsUserEmail configuration + * without conflict or data loss. + * + * Test approach: + * 1. Create client with global runAsUserEmail='global@example.com' + * 2. Execute find operation with additional properties (Locale, Timezone) + * 3. Inspect the HTTP POST request payload + * 4. Verify all properties are present: RunAsUserEmail, Locale, Timezone + * + * Expected behavior: + * - Global RunAsUserEmail is automatically included + * - Per-operation properties (Locale, Timezone) are added + * - All properties coexist in the Properties object + * - No property overwrites or conflicts occur + * + * Use case: Operations requiring user context plus additional configuration + * like localization settings, timezone, or custom AppSheet properties. + */ + it('should merge per-operation properties with global runAsUserEmail', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'global@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.find({ + tableName: 'Users', + properties: { + Locale: 'de-DE', + Timezone: 'Europe/Berlin', + }, + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'global@example.com', + Locale: 'de-DE', + Timezone: 'Europe/Berlin', + }), + }) + ); + }); + + /** + * Test: Selector and runAsUserEmail Combined in Find Operations + * + * Verifies that AppSheet Selector expressions (filtering criteria) can be + * used together with runAsUserEmail in find operations. Both are passed + * in the Properties object. + * + * Test approach: + * 1. Create client with global runAsUserEmail='finder@example.com' + * 2. Execute find operation with selector='[Status] = "Active"' + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties contains both RunAsUserEmail and Selector + * + * Expected behavior: + * - Properties.RunAsUserEmail equals 'finder@example.com' + * - Properties.Selector equals '[Status] = "Active"' + * - Both properties coexist without conflict + * - Selector uses AppSheet's bracket notation syntax + * + * Use case: Querying filtered data with user context, common in + * permission-based data access where users can only see their own + * records or records matching certain criteria. + * + * Note: Selector is a special property in AppSheet API used for filtering + * rows in Find operations using AppSheet's expression syntax. + */ + it('should handle selector and runAsUserEmail together in find operations', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'finder@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.find({ + tableName: 'Users', + selector: '[Status] = "Active"', + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'finder@example.com', + Selector: '[Status] = "Active"', + }), + }) + ); + }); + }); + + /** + * Test Suite: Convenience Methods with runAsUserEmail + * + * Verifies that all convenience methods (simplified APIs for common operations) + * correctly apply the global runAsUserEmail configuration to their HTTP requests. + * + * Convenience methods tested: + * - findAll(tableName): Find all rows in a table + * - findOne(tableName, selector): Find a single row matching a selector + * - addOne(tableName, row): Add a single row + * - updateOne(tableName, row): Update a single row + * - deleteOne(tableName, row): Delete a single row + * + * These methods provide simpler APIs compared to the full add/find/update/delete + * methods, automatically wrapping single rows in arrays and handling common + * use cases with less boilerplate code. + */ + describe('Convenience methods', () => { + /** + * Test: findAll() Convenience Method with runAsUserEmail + * + * Verifies that the findAll() convenience method correctly applies the + * global runAsUserEmail configuration to its HTTP requests. + * + * Test approach: + * 1. Create client with global runAsUserEmail='reader@example.com' + * 2. Call findAll('Users') convenience method + * 3. Inspect the HTTP POST request payload + * 4. Verify Action='Find' and Properties.RunAsUserEmail is present + * + * Expected behavior: + * - HTTP request contains Action: 'Find' + * - Properties.RunAsUserEmail equals 'reader@example.com' + * - No Selector is included (findAll retrieves all rows) + * - Method provides simpler API than full find() method + * + * Use case: Retrieving all records in a table with user context, + * useful for permission-based filtering or audit trails. + * + * API comparison: + * - Convenience: client.findAll('Users') + * - Full API: client.find({ tableName: 'Users' }) + */ + it('should apply runAsUserEmail to findAll convenience method', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'reader@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '1' }, { id: '2' }] }, + }); + + await client.findAll('Users'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Action: 'Find', + Properties: expect.objectContaining({ + RunAsUserEmail: 'reader@example.com', + }), + }) + ); + }); + + /** + * Test: findOne() Convenience Method with runAsUserEmail + * + * Verifies that the findOne() convenience method correctly applies both + * the global runAsUserEmail and the provided selector to HTTP requests. + * + * Test approach: + * 1. Create client with global runAsUserEmail='reader@example.com' + * 2. Call findOne('Users', '[Email] = "john@example.com"') + * 3. Inspect the HTTP POST request payload + * 4. Verify both RunAsUserEmail and Selector are in Properties + * + * Expected behavior: + * - Properties.RunAsUserEmail equals 'reader@example.com' + * - Properties.Selector equals '[Email] = "john@example.com"' + * - Method returns first matching row or null + * - Simpler API than find() for single-row queries + * + * Use case: Looking up a specific record by email, ID, or other unique + * identifier with user context for permissions or audit trails. + * + * API comparison: + * - Convenience: client.findOne('Users', '[Email] = "john@example.com"') + * - Full API: client.find({ tableName: 'Users', selector: '[Email] = "john@example.com"' }).then(r => r[0] || null) + */ + it('should apply runAsUserEmail to findOne convenience method', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'reader@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '123', name: 'John' }] }, + }); + + await client.findOne('Users', '[Email] = "john@example.com"'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'reader@example.com', + Selector: '[Email] = "john@example.com"', + }), + }) + ); + }); + + /** + * Test: addOne() Convenience Method with runAsUserEmail + * + * Verifies that the addOne() convenience method correctly applies the + * global runAsUserEmail configuration when creating a single row. + * + * Test approach: + * 1. Create client with global runAsUserEmail='creator@example.com' + * 2. Call addOne('Users', { name: 'Jane' }) + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present + * + * Expected behavior: + * - HTTP request contains Action: 'Add' + * - Properties.RunAsUserEmail equals 'creator@example.com' + * - Single row is wrapped in array automatically + * - Returns the created row (not array) + * + * Use case: Creating a single record with user context for audit trail + * tracking who created the record. + * + * API comparison: + * - Convenience: client.addOne('Users', { name: 'Jane' }) + * - Full API: client.add({ tableName: 'Users', rows: [{ name: 'Jane' }] }).then(r => r[0]) + */ + it('should apply runAsUserEmail to addOne convenience method', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'creator@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '123', name: 'Jane' }] }, + }); + + await client.addOne('Users', { name: 'Jane' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'creator@example.com', + }), + }) + ); + }); + + /** + * Test: updateOne() Convenience Method with runAsUserEmail + * + * Verifies that the updateOne() convenience method correctly applies the + * global runAsUserEmail configuration when updating a single row. + * + * Test approach: + * 1. Create client with global runAsUserEmail='updater@example.com' + * 2. Call updateOne('Users', { id: '123', name: 'Updated' }) + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present + * + * Expected behavior: + * - HTTP request contains Action: 'Edit' + * - Properties.RunAsUserEmail equals 'updater@example.com' + * - Single row is wrapped in array automatically + * - Returns the updated row (not array) + * + * Use case: Updating a single record with user context for audit trail + * tracking who modified the record. + * + * API comparison: + * - Convenience: client.updateOne('Users', { id: '123', name: 'Updated' }) + * - Full API: client.update({ tableName: 'Users', rows: [{ id: '123', name: 'Updated' }] }).then(r => r[0]) + */ + it('should apply runAsUserEmail to updateOne convenience method', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'updater@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [{ id: '123', name: 'Updated' }] }, + }); + + await client.updateOne('Users', { id: '123', name: 'Updated' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'updater@example.com', + }), + }) + ); + }); + + /** + * Test: deleteOne() Convenience Method with runAsUserEmail + * + * Verifies that the deleteOne() convenience method correctly applies the + * global runAsUserEmail configuration when deleting a single row. + * + * Test approach: + * 1. Create client with global runAsUserEmail='deleter@example.com' + * 2. Call deleteOne('Users', { id: '123' }) + * 3. Inspect the HTTP POST request payload + * 4. Verify Properties.RunAsUserEmail is present + * + * Expected behavior: + * - HTTP request contains Action: 'Delete' + * - Properties.RunAsUserEmail equals 'deleter@example.com' + * - Single row is wrapped in array automatically + * - Returns boolean indicating success (true if deleted) + * + * Use case: Deleting a single record with user context for audit trail + * tracking who deleted the record. + * + * API comparison: + * - Convenience: client.deleteOne('Users', { id: '123' }) + * - Full API: client.delete({ tableName: 'Users', rows: [{ id: '123' }] }).then(r => r.success) + */ + it('should apply runAsUserEmail to deleteOne convenience method', async () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'deleter@example.com', + }); + + mockAxiosInstance.post.mockResolvedValue({ + data: { Rows: [] }, + }); + + await client.deleteOne('Users', { id: '123' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + Properties: expect.objectContaining({ + RunAsUserEmail: 'deleter@example.com', + }), + }) + ); + }); + }); + + /** + * Test Suite: Configuration Retrieval + * + * Verifies that the getConfig() method correctly returns the client's + * configuration including the runAsUserEmail setting if configured. + * + * This allows users to: + * - Inspect the current configuration at runtime + * - Verify runAsUserEmail is set correctly + * - Debug configuration issues + * - Clone or modify configuration for new client instances + */ + describe('Configuration retrieval', () => { + /** + * Test: getConfig() Includes runAsUserEmail When Configured + * + * Verifies that the getConfig() method returns the client's configuration + * including the runAsUserEmail setting when it is configured. + * + * Test approach: + * 1. Create client with runAsUserEmail='test@example.com' + * 2. Call getConfig() method + * 3. Verify returned config object includes runAsUserEmail property + * 4. Verify runAsUserEmail value matches configured value + * + * Expected behavior: + * - getConfig() returns an object with all client configuration + * - config.runAsUserEmail equals 'test@example.com' + * - Configuration is readable at runtime + * - Other config fields (appId, applicationAccessKey) are also present + * + * Use case: Inspecting configuration at runtime for debugging, logging, + * or creating new client instances with modified configuration. + * + * Note: The returned config is readonly to prevent accidental modification. + */ + it('should include runAsUserEmail in getConfig() result', () => { + const client = new AppSheetClient({ + ...mockConfig, + runAsUserEmail: 'test@example.com', + }); + + const config = client.getConfig(); + + expect(config.runAsUserEmail).toBe('test@example.com'); + }); + + /** + * Test: getConfig() Returns Undefined runAsUserEmail When Not Set + * + * Verifies that when runAsUserEmail is not configured during client + * initialization, the getConfig() method returns undefined for this + * property rather than a default value or empty string. + * + * Test approach: + * 1. Create client without runAsUserEmail configuration + * 2. Call getConfig() method + * 3. Verify config.runAsUserEmail is undefined + * + * Expected behavior: + * - getConfig() returns an object with all required configuration + * - config.runAsUserEmail is undefined (not null, not empty string) + * - Other config fields (appId, applicationAccessKey) are present + * - Client functions normally without runAsUserEmail + * + * Use case: Clients that don't require user context can omit + * runAsUserEmail, and this is reflected in getConfig() output. + * + * Important: Undefined means the feature is not configured, which is + * different from an empty string or null value. + */ + it('should have undefined runAsUserEmail in getConfig() when not set', () => { + const client = new AppSheetClient(mockConfig); + + const config = client.getConfig(); + + expect(config.runAsUserEmail).toBeUndefined(); + }); + }); +}); diff --git a/tests/client/MockAppSheetClient.test.ts b/tests/client/MockAppSheetClient.test.ts new file mode 100644 index 0000000..8263283 --- /dev/null +++ b/tests/client/MockAppSheetClient.test.ts @@ -0,0 +1,960 @@ +/** + * Test Suite: MockAppSheetClient + * + * Comprehensive test suite for the MockAppSheetClient class, which provides an in-memory + * mock implementation of the AppSheetClientInterface for testing purposes. + * + * The tests verify: + * - Database management (initialization, seeding, clearing) + * - CRUD operations (Create, Read, Update, Delete) + * - Convenience methods (simplified API wrappers) + * - Configuration handling (including runAsUserEmail) + * - Interface compliance with AppSheetClientInterface + * + * @module tests/client + */ + +// Mock uuid before importing MockAppSheetClient to avoid ES module issues in Jest +jest.mock('uuid'); + +import { MockAppSheetClient } from '../../src/client/MockAppSheetClient'; +import { ValidationError, NotFoundError } from '../../src/types'; + +/** + * Test data interface representing a User entity. + * Includes optional audit fields that are auto-populated by the mock client. + */ +interface User { + id: string; + name: string; + email?: string; + status?: string; + created_at?: string; + created_by?: string; + modified_at?: string; + modified_by?: string; +} + +/** + * Test data interface representing a Product entity. + */ +interface Product { + id: string; + name: string; +} + +/** + * Test Suite: MockAppSheetClient Core Functionality + * + * Tests the complete lifecycle of the MockAppSheetClient including initialization, + * CRUD operations, database management, and interface compliance. + */ +describe('MockAppSheetClient', () => { + let client: MockAppSheetClient; + + /** + * Setup: Initialize a fresh MockAppSheetClient instance before each test. + * Ensures test isolation by providing a clean database state. + */ + beforeEach(() => { + client = new MockAppSheetClient({ + appId: 'mock-app', + applicationAccessKey: 'mock-key', + }); + }); + + /** + * Test Suite: Database Management + * + * Verifies the mock database lifecycle management including initialization, + * seeding with test data, and cleanup operations. + */ + describe('Database Management', () => { + /** + * Test: Initial State - Empty Database + * + * Verifies that a newly instantiated MockAppSheetClient starts with an empty + * in-memory database. This ensures test isolation and predictable initial state. + * + * Expected behavior: + * - findAll() on any table returns an empty array + * - No pre-existing data interferes with tests + */ + it('should start with empty database', async () => { + const users = await client.findAll('users'); + expect(users).toEqual([]); + }); + + /** + * Test: Database Seeding with Default Data + * + * Verifies the seedDatabase() method populates the mock database with + * predefined example data for quick testing scenarios. + * + * Expected behavior: + * - service_portfolio table: 50 example records + * - area table: 3 example records + * - category table: 4 example records + * + * Note: This uses the deprecated default seeding mechanism. For production + * tests, prefer using MockDataProvider for project-specific test data. + */ + it('should seed database with default data', () => { + client.seedDatabase(); + // Default data includes service_portfolio, area, category tables + expect(client.findAll('service_portfolio')).resolves.toHaveLength(50); + expect(client.findAll('area')).resolves.toHaveLength(3); + expect(client.findAll('category')).resolves.toHaveLength(4); + }); + + /** + * Test: Clear Entire Database + * + * Verifies that clearDatabase() removes all data from all tables, + * resetting the mock database to its initial empty state. + * + * Test steps: + * 1. Add a user to populate the database + * 2. Verify the user exists + * 3. Clear the entire database + * 4. Verify all tables are empty + * + * Use case: Cleanup between test scenarios without reinitializing the client + */ + it('should clear database', async () => { + await client.addOne('users', { id: '1', name: 'John' }); + expect(await client.findAll('users')).toHaveLength(1); + + client.clearDatabase(); + expect(await client.findAll('users')).toHaveLength(0); + }); + + /** + * Test: Clear Specific Table + * + * Verifies that clearTable() removes data only from the specified table + * while leaving other tables untouched. + * + * Test steps: + * 1. Add data to multiple tables (users, products) + * 2. Clear only the users table + * 3. Verify users table is empty + * 4. Verify products table still contains data + * + * Use case: Selective cleanup for tests that need to reset only certain data + */ + it('should clear specific table', async () => { + await client.addOne('users', { id: '1', name: 'John' }); + await client.addOne('products', { id: '1', name: 'Product' }); + + client.clearTable('users'); + expect(await client.findAll('users')).toHaveLength(0); + expect(await client.findAll('products')).toHaveLength(1); + }); + }); + + /** + * Test Suite: CRUD Operations - Add (Create) + * + * Verifies the add() operation which creates new rows in the mock database. + * Tests cover single/multiple row insertion, auto-ID generation, and + * audit field population (created_at, created_by). + */ + describe('CRUD Operations - Add', () => { + /** + * Test: Add Single Row + * + * Verifies that add() successfully inserts a single row into the database + * with all provided fields, and automatically adds audit fields. + * + * Expected behavior: + * - Row is stored with all provided fields (id, name, email) + * - created_at timestamp is automatically added + * - created_by is set to default 'mock@example.com' + * - Response contains exactly one row + * + * This is the most common use case for creating data in tests. + */ + it('should add single row', async () => { + const result = await client.add({ + tableName: 'users', + rows: [{ id: '1', name: 'John', email: 'john@example.com' }], + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ + id: '1', + name: 'John', + email: 'john@example.com', + }); + expect(result.rows[0]).toHaveProperty('created_at'); + expect(result.rows[0]).toHaveProperty('created_by', 'mock@example.com'); + }); + + /** + * Test: Add Multiple Rows in Single Operation + * + * Verifies that add() can insert multiple rows in a single batch operation, + * mimicking the AppSheet API's bulk insert capability. + * + * Expected behavior: + * - Both rows are inserted successfully + * - Each row maintains its distinct data + * - Response contains all inserted rows in order + * + * Use case: Efficient batch data setup for tests requiring multiple records + */ + it('should add multiple rows', async () => { + const result = await client.add({ + tableName: 'users', + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + }); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toBe('John'); + expect(result.rows[1].name).toBe('Jane'); + }); + + /** + * Test: Auto-Generate ID When Not Provided + * + * Verifies that the mock client automatically generates a unique ID + * when the provided ID is empty or missing, mimicking AppSheet's + * auto-key generation behavior. + * + * Expected behavior: + * - Empty ID ('') triggers auto-generation + * - Generated ID is a non-empty string + * - ID is unique (uses mocked UUID) + * + * Use case: Tests where specific IDs are not important, allowing + * the mock to generate them automatically + */ + it('should auto-generate ID if not provided', async () => { + const result = await client.add({ + tableName: 'users', + rows: [{ id: '', name: 'John' }], // Provide empty id, will be auto-generated + }); + + expect(result.rows[0]).toHaveProperty('id'); + expect(typeof result.rows[0].id).toBe('string'); + expect(result.rows[0].id.length).toBeGreaterThan(0); + }); + + /** + * Test: Use Global runAsUserEmail from Configuration + * + * Verifies that when runAsUserEmail is configured globally on the client, + * it is automatically applied to the created_by field for all add operations. + * + * Expected behavior: + * - created_by field is set to the configured runAsUserEmail + * - Global setting applies to all operations unless overridden + * + * Use case: Testing audit trails and permission contexts where all + * operations should be attributed to a specific user + */ + it('should use runAsUserEmail from config', async () => { + const clientWithUser = new MockAppSheetClient({ + appId: 'mock-app', + applicationAccessKey: 'mock-key', + runAsUserEmail: 'admin@example.com', + }); + + const result = await clientWithUser.add({ + tableName: 'users', + rows: [{ id: '1', name: 'John' }], + }); + + expect(result.rows[0].created_by).toBe('admin@example.com'); + }); + + /** + * Test: Override Global runAsUserEmail Per Operation + * + * Verifies that the per-operation RunAsUserEmail property overrides + * the global configuration, allowing specific operations to run as + * different users. + * + * Expected behavior: + * - Operation-level RunAsUserEmail takes precedence over global config + * - created_by reflects the operation-level user + * - Global config is not permanently changed + * + * Use case: Testing scenarios where certain operations need elevated + * privileges or must be attributed to system/service accounts + */ + it('should override runAsUserEmail per operation', async () => { + const result = await client.add({ + tableName: 'users', + rows: [{ id: '1', name: 'John' }], + properties: { RunAsUserEmail: 'system@example.com' }, + }); + + expect(result.rows[0].created_by).toBe('system@example.com'); + }); + }); + + /** + * Test Suite: CRUD Operations - Find (Read) + * + * Verifies the find() operation which retrieves rows from the database + * with optional filtering using AppSheet selector syntax. + * Tests cover various selector patterns and edge cases. + */ + describe('CRUD Operations - Find', () => { + /** + * Setup: Populate database with test users for querying. + * Creates 3 users with different status values for selector testing. + */ + beforeEach(async () => { + await client.add({ + tableName: 'users', + rows: [ + { id: '1', name: 'John', status: 'active' }, + { id: '2', name: 'Jane', status: 'active' }, + { id: '3', name: 'Bob', status: 'inactive' }, + ], + }); + }); + + /** + * Test: Find All Rows Without Filter + * + * Verifies that find() without a selector returns all rows in the table. + * + * Expected behavior: + * - All 3 rows are returned + * - No filtering is applied + * - Results are returned in insertion order + * + * Use case: Retrieving complete table contents for verification + */ + it('should find all rows', async () => { + const result = await client.find({ tableName: 'users' }); + expect(result.rows).toHaveLength(3); + }); + + /** + * Test: Find with Exact Match Selector (Double Quotes) + * + * Verifies that AppSheet selector syntax `[field] = "value"` correctly + * filters rows based on exact string matching. + * + * Expected behavior: + * - Only rows matching the exact field value are returned + * - Double quotes are properly parsed + * - Case-sensitive matching + * + * Use case: Precise field-based queries mimicking AppSheet filter behavior + */ + it('should find rows with selector (exact match)', async () => { + const result = await client.find({ + tableName: 'users', + selector: '[name] = "John"', + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toBe('John'); + }); + + /** + * Test: Find with Exact Match Selector (Single Quotes) + * + * Verifies that the selector parser handles both single and double quotes + * for string values, ensuring compatibility with different AppSheet syntax styles. + * + * Expected behavior: + * - Single quotes work identically to double quotes + * - Multiple rows matching the condition are returned + * + * Use case: Testing selector syntax flexibility + */ + it('should find rows with selector (single quotes)', async () => { + const result = await client.find({ + tableName: 'users', + selector: "[status] = 'active'", + }); + + expect(result.rows).toHaveLength(2); + }); + + /** + * Test: Find with IN Selector for Multiple Values + * + * Verifies that the IN selector syntax allows matching against multiple + * values, mimicking SQL-like IN clause behavior. + * + * Selector format: `[field] IN ("value1", "value2")` + * + * Expected behavior: + * - Rows matching any of the values are returned + * - Case-sensitive matching + * - Multiple values are properly parsed from comma-separated list + * + * Use case: Filtering by multiple possible values (e.g., status IN ("pending", "active")) + */ + it('should find rows with IN selector', async () => { + const result = await client.find({ + tableName: 'users', + selector: '[name] IN ("John", "Jane")', + }); + + expect(result.rows).toHaveLength(2); + }); + + /** + * Test: Empty Result for Non-Matching Selector + * + * Verifies that find() returns an empty array when no rows match the + * selector criteria, rather than throwing an error. + * + * Expected behavior: + * - Empty array is returned + * - No error is thrown + * - Response structure remains valid + * + * Use case: Graceful handling of queries with no results + */ + it('should return empty array for non-matching selector', async () => { + const result = await client.find({ + tableName: 'users', + selector: '[name] = "NonExistent"', + }); + + expect(result.rows).toHaveLength(0); + }); + }); + + /** + * Test Suite: CRUD Operations - Update (Edit) + * + * Verifies the update() operation which modifies existing rows in the database. + * Tests cover single/multiple row updates, field merging, validation, error handling, + * and audit field population (modified_at, modified_by). + */ + describe('CRUD Operations - Update', () => { + /** + * Setup: Populate database with test users for updating. + * Creates 2 users with known data for update testing. + */ + beforeEach(async () => { + await client.add({ + tableName: 'users', + rows: [ + { id: '1', name: 'John', email: 'john@example.com' }, + { id: '2', name: 'Jane', email: 'jane@example.com' }, + ], + }); + }); + + /** + * Test: Update Single Row + * + * Verifies that update() successfully modifies an existing row's fields + * and automatically adds modification audit fields. + * + * Expected behavior: + * - Specified fields are updated (name) + * - modified_at timestamp is automatically added + * - modified_by is set to default 'mock@example.com' + * - Only the specified row is modified + * + * Use case: Standard data modification in tests + */ + it('should update single row', async () => { + const result = await client.update({ + tableName: 'users', + rows: [{ id: '1', name: 'John Updated' }], + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toBe('John Updated'); + expect(result.rows[0]).toHaveProperty('modified_at'); + expect(result.rows[0]).toHaveProperty('modified_by', 'mock@example.com'); + }); + + /** + * Test: Update Multiple Rows in Single Operation + * + * Verifies that update() can modify multiple rows in a single batch operation. + * + * Expected behavior: + * - Both rows are updated successfully + * - Each row receives its specified updates + * - Response contains all updated rows + * + * Use case: Efficient batch updates for test data + */ + it('should update multiple rows', async () => { + const result = await client.update({ + tableName: 'users', + rows: [ + { id: '1', name: 'John Updated' }, + { id: '2', name: 'Jane Updated' }, + ], + }); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toBe('John Updated'); + expect(result.rows[1].name).toBe('Jane Updated'); + }); + + /** + * Test: Preserve Unchanged Fields During Update + * + * Verifies that update() performs a partial update (merge), preserving + * fields that are not explicitly provided in the update data. + * + * Test steps: + * 1. Update only the name field + * 2. Verify name is updated + * 3. Verify email field remains unchanged + * + * Expected behavior: + * - Only specified fields are modified + * - Unspecified fields retain their original values + * - No data loss occurs on partial updates + * + * Use case: Selective field updates without needing complete row data + */ + it('should preserve unchanged fields', async () => { + const result = await client.update({ + tableName: 'users', + rows: [{ id: '1', name: 'John Updated' }], + }); + + expect(result.rows[0].email).toBe('john@example.com'); + }); + + /** + * Test: Validation Error for Missing Key Field + * + * Verifies that update() throws ValidationError when the key field (id) + * is missing or empty, preventing ambiguous updates. + * + * Expected behavior: + * - ValidationError is thrown + * - No data is modified + * - Error message indicates missing key field + * + * Use case: Ensuring data integrity by preventing invalid updates + */ + it('should throw ValidationError if key field is missing', async () => { + await expect( + client.update({ + tableName: 'users', + rows: [{ id: '', name: 'John Updated' }], // Missing id + }) + ).rejects.toThrow(ValidationError); + }); + + /** + * Test: NotFoundError for Non-Existent Row + * + * Verifies that update() throws NotFoundError when attempting to update + * a row that doesn't exist in the database. + * + * Expected behavior: + * - NotFoundError is thrown + * - No data is modified + * - Error indicates which row was not found + * + * Use case: Proper error handling for update operations on missing data + */ + it('should throw NotFoundError if row does not exist', async () => { + await expect( + client.update({ + tableName: 'users', + rows: [{ id: '999', name: 'NonExistent' }], + }) + ).rejects.toThrow(NotFoundError); + }); + + /** + * Test: Override runAsUserEmail for Update Operation + * + * Verifies that the per-operation RunAsUserEmail property is applied + * to the modified_by audit field during updates. + * + * Expected behavior: + * - modified_by reflects the operation-level user + * - Audit trail accurately tracks who made the modification + * + * Use case: Testing audit trails with specific user attributions + */ + it('should use runAsUserEmail from properties', async () => { + const result = await client.update({ + tableName: 'users', + rows: [{ id: '1', name: 'John Updated' }], + properties: { RunAsUserEmail: 'admin@example.com' }, + }); + + expect(result.rows[0].modified_by).toBe('admin@example.com'); + }); + }); + + /** + * Test Suite: CRUD Operations - Delete + * + * Verifies the delete() operation which removes rows from the database. + * Tests cover single/multiple row deletion, validation, and graceful + * handling of non-existent rows. + */ + describe('CRUD Operations - Delete', () => { + /** + * Setup: Populate database with test users for deletion. + * Creates 3 users to test various deletion scenarios. + */ + beforeEach(async () => { + await client.add({ + tableName: 'users', + rows: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + { id: '3', name: 'Bob' }, + ], + }); + }); + + /** + * Test: Delete Single Row + * + * Verifies that delete() successfully removes a single row from the database. + * + * Test steps: + * 1. Delete user with id='1' + * 2. Verify operation reports success and deletedCount=1 + * 3. Verify remaining rows count is correct + * + * Expected behavior: + * - Row is permanently removed from database + * - Delete response indicates success + * - Only specified row is deleted + * + * Use case: Standard data cleanup in tests + */ + it('should delete single row', async () => { + const result = await client.delete({ + tableName: 'users', + rows: [{ id: '1', name: '' }], + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + + const remaining = await client.findAll('users'); + expect(remaining).toHaveLength(2); + }); + + /** + * Test: Delete Multiple Rows in Single Operation + * + * Verifies that delete() can remove multiple rows in a single batch operation. + * + * Expected behavior: + * - Both rows are deleted + * - deletedCount accurately reflects number of rows removed + * - Remaining rows are unaffected + * + * Use case: Efficient batch cleanup for test data + */ + it('should delete multiple rows', async () => { + const result = await client.delete({ + tableName: 'users', + rows: [{ id: '1', name: '' }, { id: '2', name: '' }], + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(2); + + const remaining = await client.findAll('users'); + expect(remaining).toHaveLength(1); + }); + + /** + * Test: Validation Error for Missing Key Field + * + * Verifies that delete() throws ValidationError when the key field (id) + * is missing or empty, preventing ambiguous deletions. + * + * Expected behavior: + * - ValidationError is thrown + * - No data is deleted + * - Error message indicates missing key field + * + * Use case: Preventing accidental mass deletions due to missing identifiers + */ + it('should throw ValidationError if key field is missing', async () => { + await expect( + client.delete({ + tableName: 'users', + rows: [{ id: '', name: 'John' }], + }) + ).rejects.toThrow(ValidationError); + }); + + /** + * Test: Graceful Handling of Non-Existent Row Deletion + * + * Verifies that delete() handles attempts to delete non-existent rows gracefully + * without throwing an error, mimicking AppSheet API behavior. + * + * Expected behavior: + * - Operation reports success=true + * - deletedCount is 0 + * - No error is thrown + * - Database state is unchanged + * + * Use case: Idempotent delete operations in tests + */ + it('should not throw error if row does not exist', async () => { + const result = await client.delete({ + tableName: 'users', + rows: [{ id: '999', name: '' }], + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(0); + }); + }); + + /** + * Test Suite: Convenience Methods + * + * Verifies the simplified convenience wrapper methods that provide + * a more ergonomic API for common single-row operations. + * These methods wrap the core CRUD operations with simpler signatures. + */ + describe('Convenience Methods', () => { + /** + * Setup: Populate database with test users for convenience method testing. + */ + beforeEach(async () => { + await client.add({ + tableName: 'users', + rows: [ + { id: '1', name: 'John', email: 'john@example.com' }, + { id: '2', name: 'Jane', email: 'jane@example.com' }, + ], + }); + }); + + /** + * Test: findAll() Convenience Method + * + * Verifies that findAll() returns all rows from a table without + * requiring the full options object structure. + * + * Expected behavior: + * - Returns array of rows directly (not wrapped in response object) + * - All rows are returned + * - Simplified API reduces boilerplate + * + * Use case: Quick data retrieval in tests + */ + it('findAll should return all rows', async () => { + const users = await client.findAll('users'); + expect(users).toHaveLength(2); + }); + + /** + * Test: findOne() Returns First Match + * + * Verifies that findOne() returns the first row matching the selector + * as a direct object (not array). + * + * Expected behavior: + * - Returns single matching row + * - Returns null if no match (not empty array) + * - Simplified API for single-row queries + * + * Use case: Fetching specific records by criteria + */ + it('findOne should return first matching row', async () => { + const user = await client.findOne('users', '[name] = "John"'); + expect(user).not.toBeNull(); + expect(user?.name).toBe('John'); + }); + + /** + * Test: findOne() Returns Null for No Match + * + * Verifies that findOne() returns null when no rows match the selector. + * + * Expected behavior: + * - Returns null (not undefined or empty array) + * - Allows safe optional chaining (user?.field) + * - Consistent with single-row semantics + * + * Use case: Conditional logic based on record existence + */ + it('findOne should return null if no match', async () => { + const user = await client.findOne('users', '[name] = "NonExistent"'); + expect(user).toBeNull(); + }); + + /** + * Test: addOne() Convenience Method + * + * Verifies that addOne() adds a single row and returns it directly + * without requiring array wrapping. + * + * Expected behavior: + * - Returns created row directly (not in array) + * - Simplified API for single-row inserts + * - Common use case in tests + * + * Use case: Quick test data creation + */ + it('addOne should add single row', async () => { + const user = await client.addOne('users', { + id: '3', + name: 'Bob', + }); + + expect(user.id).toBe('3'); + expect(user.name).toBe('Bob'); + }); + + /** + * Test: updateOne() Convenience Method + * + * Verifies that updateOne() updates a single row and returns it directly. + * + * Expected behavior: + * - Returns updated row directly (not in array) + * - Simplified API for single-row updates + * + * Use case: Quick data modifications in tests + */ + it('updateOne should update single row', async () => { + const user = await client.updateOne('users', { + id: '1', + name: 'John Updated', + }); + + expect(user.name).toBe('John Updated'); + }); + + /** + * Test: deleteOne() Convenience Method + * + * Verifies that deleteOne() deletes a single row and returns a simple boolean. + * + * Expected behavior: + * - Returns true for successful deletion + * - Simplified API for single-row deletions + * - No need to check response object structure + * + * Use case: Quick data cleanup in tests + */ + it('deleteOne should delete single row', async () => { + const result = await client.deleteOne('users', { id: '1', name: '' }); + expect(result).toBe(true); + + const remaining = await client.findAll('users'); + expect(remaining).toHaveLength(1); + }); + }); + + /** + * Test Suite: Configuration Management + * + * Verifies that the client properly stores and retrieves configuration + * values including default values and optional parameters. + */ + describe('Configuration', () => { + /** + * Test: Retrieve Default Configuration + * + * Verifies that getConfig() returns all configuration values including + * defaults that were applied during client initialization. + * + * Expected behavior: + * - Provided values are returned (appId, applicationAccessKey) + * - Default values are returned (baseUrl, timeout, retryAttempts) + * - Configuration is read-only (via Readonly type) + * + * Use case: Debugging client configuration in tests + */ + it('should return configuration', () => { + const config = client.getConfig(); + expect(config.appId).toBe('mock-app'); + expect(config.applicationAccessKey).toBe('mock-key'); + expect(config.baseUrl).toBe('https://api.appsheet.com/api/v2'); + expect(config.timeout).toBe(30000); + expect(config.retryAttempts).toBe(3); + }); + + /** + * Test: Configuration Includes Optional runAsUserEmail + * + * Verifies that when runAsUserEmail is provided during client initialization, + * it is included in the returned configuration. + * + * Expected behavior: + * - runAsUserEmail is present in config when provided + * - Value matches what was provided during initialization + * + * Use case: Verifying user context configuration + */ + it('should include runAsUserEmail in config if provided', () => { + const clientWithUser = new MockAppSheetClient({ + appId: 'mock-app', + applicationAccessKey: 'mock-key', + runAsUserEmail: 'admin@example.com', + }); + + const config = clientWithUser.getConfig(); + expect(config.runAsUserEmail).toBe('admin@example.com'); + }); + }); + + /** + * Test Suite: Interface Compliance + * + * Verifies that MockAppSheetClient correctly implements the + * AppSheetClientInterface, ensuring it can be used interchangeably + * with the real AppSheetClient in code that depends on the interface. + */ + describe('Interface Compliance', () => { + /** + * Test: Implements AppSheetClientInterface + * + * Verifies that MockAppSheetClient implements all required methods + * from AppSheetClientInterface, enabling polymorphic usage. + * + * Test approach: + * 1. Type check: Assign client to interface type (compile-time check) + * 2. Runtime check: Verify all interface methods exist and are functions + * + * Expected behavior: + * - All 10 interface methods are present + * - All methods are of type 'function' + * - Type system allows treating mock as interface + * + * Use case: Ensuring MockAppSheetClient can substitute AppSheetClient + * in test scenarios, enabling dependency injection and mocking patterns + */ + it('should implement AppSheetClientInterface', () => { + // Type check: This ensures MockAppSheetClient implements AppSheetClientInterface + const clientInterface: import('../../src/types').AppSheetClientInterface = client; + + // Check all required methods exist + expect(typeof clientInterface.add).toBe('function'); + expect(typeof clientInterface.find).toBe('function'); + expect(typeof clientInterface.update).toBe('function'); + expect(typeof clientInterface.delete).toBe('function'); + expect(typeof clientInterface.findAll).toBe('function'); + expect(typeof clientInterface.findOne).toBe('function'); + expect(typeof clientInterface.addOne).toBe('function'); + expect(typeof clientInterface.updateOne).toBe('function'); + expect(typeof clientInterface.deleteOne).toBe('function'); + expect(typeof clientInterface.getConfig).toBe('function'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3d1b0f3..28bd680 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "moduleResolution": "node" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "tests"] } From 3e8b546bf07ba43f5b145d7eafdaf13e36123662 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 14 Nov 2025 17:17:43 +0100 Subject: [PATCH 2/2] fix: replace require() with ES6 import in SchemaInspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert require('readline') to ES6 import statement - Fixes ESLint error: @typescript-eslint/no-var-requires - Ensures CI pipeline passes πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli/SchemaInspector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/SchemaInspector.ts b/src/cli/SchemaInspector.ts index 1665577..3e3ef8d 100644 --- a/src/cli/SchemaInspector.ts +++ b/src/cli/SchemaInspector.ts @@ -4,6 +4,7 @@ * @category CLI */ +import * as readline from 'readline'; import { AppSheetClient } from '../client'; import { TableInspectionResult, ConnectionDefinition, TableDefinition } from '../types'; @@ -227,7 +228,6 @@ export class SchemaInspector { console.log('\nAutomatic table discovery is not available.'); console.log('Please enter table names manually.\n'); - const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout,