executing-plans
executing-plans
Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available
appfolio-ci-integrationConfigure CI/CD pipeline for AppFolio property management integrations. Trigger: "appfolio CI".
Independent GitHub snapshot · Automated classification · Original documentation governs
name: appfolio-ci-integration description: 'Configure CI/CD pipeline for AppFolio property management integrations.
Trigger: "appfolio CI".
' allowed-tools: Read, Write, Edit, Bash(npm:), Bash(curl:), Grep version: 1.5.0 license: MIT author: Jeremy Longshore jeremy@intentsolutions.io tags:
Configure CI pipelines that validate AppFolio property management API integrations using a two-tier strategy. Unit tests mock the AppFolio REST client to verify tenant lookup, work order creation, and property listing logic without consuming API quota. Integration tests run against the AppFolio sandbox environment on main-branch merges only, using Basic Auth credentials stored as GitHub secrets. This keeps PR feedback fast and free while catching real API contract drift before production deploys.
main pushes after lint, type, and
unit checks pass, and inject credentials only through repository secrets.# .github/workflows/appfolio-tests.yml
name: AppFolio API Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run lint && npm run typecheck
- run: npm test -- --testPathPattern=unit # No API credentials needed
integration-tests:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm test -- --testPathPattern=integration
env:
APPFOLIO_CLIENT_ID: ${{ secrets.APPFOLIO_CLIENT_ID }}
APPFOLIO_CLIENT_SECRET: ${{ secrets.APPFOLIO_CLIENT_SECRET }}
APPFOLIO_BASE_URL: ${{ secrets.APPFOLIO_SANDBOX_URL }}
// tests/unit/work-order-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createWorkOrder } from '../../src/services/work-order-service';
import * as appfolioClient from '../../src/lib/appfolio-client';
vi.mock('../../src/lib/appfolio-client');
describe('WorkOrderService', () => {
it('creates a maintenance work order for a property', async () => {
vi.mocked(appfolioClient.post).mockResolvedValue({
id: 'wo-4821',
property_id: 'prop-100',
category: 'Plumbing',
status: 'Open',
});
const result = await createWorkOrder('prop-100', 'Plumbing', 'Leaking faucet unit 3B');
expect(result.status).toBe('Open');
expect(appfolioClient.post).toHaveBeenCalledWith('/work_orders', {
property_id: 'prop-100',
category: 'Plumbing',
description: 'Leaking faucet unit 3B',
});
});
});
// tests/integration/tenant-lookup.test.ts
import { describe, it, expect } from 'vitest';
import { AppFolioClient } from '../../src/lib/appfolio-client';
const canRun = process.env.APPFOLIO_CLIENT_ID && process.env.APPFOLIO_CLIENT_SECRET;
describe.skipIf(!canRun)('AppFolio Tenant Lookup (live sandbox)', () => {
const client = new AppFolioClient({
clientId: process.env.APPFOLIO_CLIENT_ID!,
clientSecret: process.env.APPFOLIO_CLIENT_SECRET!,
baseUrl: process.env.APPFOLIO_BASE_URL!,
});
it('lists tenants for a known property', async () => {
const tenants = await client.get('/tenants', { property_id: 'prop-100' });
expect(Array.isArray(tenants)).toBe(true);
expect(tenants[0]).toHaveProperty('lease_status');
});
});
// tests/helpers/api-budget.ts
let callCount = 0;
const MAX_CALLS_PER_RUN = 25; // AppFolio sandbox has 100 req/min rate limit
export function trackApiCall(): void {
callCount++;
if (callCount > MAX_CALLS_PER_RUN) {
throw new Error(
`CI API budget exceeded: ${callCount}/${MAX_CALLS_PER_RUN} calls. ` +
'Reduce integration test scope or split across jobs.'
);
}
}
export function getCallCount(): number { return callCount; }
| CI Issue | Cause | Fix |
|---|---|---|
| 401 Unauthorized in integration job | Expired or rotated sandbox credentials | Regenerate APPFOLIO_CLIENT_ID and APPFOLIO_CLIENT_SECRET in GitHub Secrets |
| 429 Too Many Requests | Sandbox rate limit (100 req/min) hit by parallel tests | Run integration tests with --maxWorkers=1 |
| Tenant list empty | Sandbox data periodically reset by AppFolio | Stop the job and restore the pre-provisioned fixture outside CI |
| Typecheck fails on API response | AppFolio schema updated without notice | Regenerate types from OpenAPI spec, update interfaces |
| Integration job skipped | Branch protection rule not matching refs/heads/main | Verify workflow if condition matches your default branch name |
For a work-order mapping change, add a synthetic mock response and verify the new field in the PR suite. After merge, the protected main workflow reads one pre-provisioned sandbox work order and confirms the normalized shape without creating tenants, properties, or work orders. If the fixture is unavailable, the credential fails, or the request budget is exceeded, mark the release blocked and have the sandbox owner restore the fixture or credential before rerunning the live lane.
See appfolio-deploy-integration.