Skip to Content
🚀 APSO is now in public beta. Get started →
GuidesToolsEnd-to-End Testing

End-to-End Testing

Apso exists to make backend development disappear into the workflow a builder is already in. If they are a developer, the CLI scaffolds a production-ready API in fewer steps than any BaaS on the market. If they are a founder or designer working through AI, the same CLI integrates with Claude Code, Cursor, and Codex so they never leave the conversation.

These three test journeys validate that promise end to end. Each one represents a real user with a real product to build, and each one maps to friction points that competing platforms leave unsolved: multi-entity schema design, non-destructive migrations, auth without a third-party dependency, and AI-assisted iteration that produces architecturally coherent code rather than per-prompt fragments.

Every journey works two ways: CLI commands in a terminal, or natural language in an AI coding assistant. Both paths hit the same pipeline.

Prerequisites

# Node.js 18+ node --version # v18.x.x or higher # Apso CLI npm install -g @apso/cli # Authenticate (required for deployment, optional for local dev) apso login apso whoami

For AI-assisted paths, install Claude Code:

npm install -g @anthropic-ai/claude-code

No Docker required. The default setup uses PGlite (embedded Postgres).


Journey 1: New Developer Building a Backend from Scratch

Use case

A developer is building a SaaS product and needs a backend. They have been evaluating Supabase, Firebase, and PocketBase, and they want to know how fast they can go from zero to a working REST API with proper relationships, validation, and enum fields. They do not want vendor lock-in, and they want to own the generated code.

The developer is building a property management system: buildings, units within buildings, tenants, and lease agreements. This is a realistic multi-entity schema with one-to-many relationships (building to units, unit to leases) and foreign key constraints. It is the kind of data model that exposes whether a code generator handles relationships correctly or produces broken join logic.

What this tests

  • Project creation speed (compared to 3-step Supabase/PocketBase quickstarts)
  • Schema definition with multiple entity types, enums, nullable fields, and relationships
  • Code generation producing correct TypeORM entities with proper decorators
  • Full CRUD lifecycle: create with foreign keys, read with relationships, update, delete
  • PGlite database working out of the box with no configuration

Steps

Create the project

CLI path:

apso init --name property-api --skip-platform cd property-api

apso init clones the template, initializes git, and runs npm install for TypeScript projects. One command, no manual setup.

AI-assisted path: Open Claude Code in an empty directory:

“I need a backend for a property management app. Buildings, units, tenants, leases. Use Apso.”

The agent should run apso init, edit .apsorc with the schema, and run apso generate.

Verify:

ls .apsorc package.json src/ # All three should exist

Define the schema

Edit .apsorc with the data model:

.apsorc
{ "version": 2, "rootFolder": "src", "entities": [ { "name": "Building", "created_at": true, "updated_at": true, "fields": [ { "name": "name", "type": "text" }, { "name": "address", "type": "text" }, { "name": "city", "type": "text" }, { "name": "state", "type": "varchar", "length": 2 }, { "name": "zip", "type": "varchar", "length": 10 }, { "name": "yearBuilt", "type": "integer", "nullable": true } ] }, { "name": "Unit", "created_at": true, "updated_at": true, "fields": [ { "name": "number", "type": "text" }, { "name": "floor", "type": "integer" }, { "name": "bedrooms", "type": "integer" }, { "name": "bathrooms", "type": "float" }, { "name": "sqft", "type": "integer" }, { "name": "rentAmount", "type": "float" }, { "name": "status", "type": "enum", "values": ["Vacant", "Occupied", "Maintenance"], "default": "Vacant" } ] }, { "name": "Tenant", "created_at": true, "updated_at": true, "fields": [ { "name": "firstName", "type": "text" }, { "name": "lastName", "type": "text" }, { "name": "email", "type": "text" }, { "name": "phone", "type": "varchar", "length": 20, "nullable": true } ] }, { "name": "Lease", "created_at": true, "updated_at": true, "fields": [ { "name": "startDate", "type": "date" }, { "name": "endDate", "type": "date" }, { "name": "monthlyRent", "type": "float" }, { "name": "securityDeposit", "type": "float", "nullable": true }, { "name": "status", "type": "enum", "values": ["Active", "Expired", "Terminated"], "default": "Active" } ] } ], "relationships": [ { "from": "Building", "to": "Unit", "type": "OneToMany" }, { "from": "Unit", "to": "Lease", "type": "OneToMany" }, { "from": "Tenant", "to": "Lease", "type": "OneToMany" } ] }

Generate code and start the server

apso generate npm run dev

Verify code generation:

# Entity files exist for all four entities ls src/autogen/Building/Building.entity.ts ls src/autogen/Unit/Unit.entity.ts ls src/autogen/Tenant/Tenant.entity.ts ls src/autogen/Lease/Lease.entity.ts # Each entity has controller, service, and module ls src/autogen/Building/Building.controller.ts ls src/autogen/Building/Building.service.ts ls src/autogen/Building/Building.module.ts

The server should start on port 3000 with no errors. No database setup, no Docker, no environment variable configuration.

Test the full CRUD lifecycle

In a separate terminal, exercise every operation:

Create records with relationships:

# Create a building curl -s -X POST http://localhost:3000/building \ -H "Content-Type: application/json" \ -d '{ "name": "Riverside Towers", "address": "450 River Road", "city": "Portland", "state": "OR", "zip": "97201", "yearBuilt": 2018 }' | jq '.id' # Expected: returns an id (e.g., 1) # Create a unit linked to that building curl -s -X POST http://localhost:3000/unit \ -H "Content-Type: application/json" \ -d '{ "number": "4B", "floor": 4, "bedrooms": 2, "bathrooms": 1.5, "sqft": 950, "rentAmount": 2200, "status": "Vacant", "buildingId": 1 }' | jq '.id' # Create a tenant curl -s -X POST http://localhost:3000/tenant \ -H "Content-Type: application/json" \ -d '{ "firstName": "Sarah", "lastName": "Chen", "email": "sarah.chen@example.com", "phone": "503-555-0142" }' | jq '.id' # Create a lease linking tenant to unit curl -s -X POST http://localhost:3000/lease \ -H "Content-Type: application/json" \ -d '{ "startDate": "2025-03-01", "endDate": "2026-02-28", "monthlyRent": 2200, "securityDeposit": 4400, "status": "Active", "unitId": 1, "tenantId": 1 }' | jq '.id'

Verify reads and relationships:

curl -s http://localhost:3000/lease | jq '.length' # Expected: 1 curl -s http://localhost:3000/building/1 | jq '.name' # Expected: "Riverside Towers"

Update a record:

curl -s -X PATCH http://localhost:3000/unit/1 \ -H "Content-Type: application/json" \ -d '{"status": "Occupied"}' | jq '.status' # Expected: "Occupied"

Delete a record:

curl -s -X DELETE http://localhost:3000/lease/1 -o /dev/null -w "%{http_code}" # Expected: 200 curl -s http://localhost:3000/lease | jq '.length' # Expected: 0

Pass criteria: Four entities with working CRUD. Foreign keys (buildingId, unitId, tenantId) link records correctly. Enum fields accept only valid values. Server starts with zero configuration on PGlite.


Journey 2: Product Builder Using AI to Create a Backend

Use case

A founder is building a SaaS MVP. They have a product idea, they know their domain, but they are not writing backend code. They are working through Claude Code (or Cursor, or Codex), describing their product in plain language, and the AI is handling schema design, code generation, and server setup.

This is the workflow that full-stack AI builders like Bolt and Lovable attempt, but those tools produce fragile backends tightly coupled to specific providers. Apso’s AI integration generates architecturally coherent NestJS code the founder can hand off to a developer later, with no vendor lock-in. The schema lives in a single .apsorc file, not scattered across a platform UI.

The founder is building a freelancer booking platform: clients post projects, freelancers apply, and the platform tracks contracts and payments. This is the kind of multi-entity, relationship-heavy product that exposes whether AI-assisted code generation produces a coherent system or per-prompt fragments that fall apart when connected.

What this tests

  • AI’s ability to translate product requirements into a correct .apsorc schema
  • Multi-entity relationship coherence (not just individual CRUD endpoints)
  • Iterative schema evolution through conversation (add entity, modify entity, add relationships)
  • That the AI uses Apso CLI commands, not custom code or workarounds
  • That a non-developer can validate the result without reading TypeScript

Steps

Describe the product

Open Claude Code in an empty directory. Describe the product as a founder would:

“I’m building a freelancer marketplace. Clients can post projects with a title, description, budget, and deadline. Freelancers have a name, email, bio, hourly rate, and a list of skills. Freelancers apply to projects with a cover letter and proposed rate. When a client accepts an application, it becomes a contract with a start date, end date, and agreed rate. Set this up with Apso.”

What the AI should do:

  1. Run apso init --name freelancer-api --skip-platform
  2. Create .apsorc with entities: Client, Freelancer, Project, Application, Contract
  3. Define relationships: Project belongs to Client, Application belongs to both Project and Freelancer, Contract links to Application
  4. Run apso generate
  5. Start the server with npm run dev

Verify the AI got it right:

# Check entities cat .apsorc | jq '.entities[].name' # Expected: Client, Freelancer, Project, Application, Contract (or similar) # Check relationships connect the entities cat .apsorc | jq '.relationships | length' # Expected: at least 4 relationships # Generated files exist ls src/autogen/Project/Project.entity.ts ls src/autogen/Application/Application.entity.ts ls src/autogen/Contract/Contract.entity.ts

Ask AI to create test data

“Create some sample data: two clients, three freelancers, two projects, and a few applications.”

What the AI should do: POST records to each endpoint, linking them with the correct foreign keys. The AI should create clients first, then projects (which need a clientId), then freelancers, then applications (which need a projectId and freelancerId).

Verify:

curl -s http://localhost:3000/project | jq '.length' # Expected: 2 curl -s http://localhost:3000/application | jq '.length' # Expected: at least 2

Ask AI to add a feature

“I need to track payments. When a contract is completed, the client pays the freelancer. Track the amount, payment date, and status (pending, completed, failed). Link each payment to a contract.”

What the AI should do:

  1. Add a Payment entity to .apsorc with the described fields
  2. Add a ManyToOne relationship from Payment to Contract
  3. Run apso generate
  4. Restart the server

Verify:

ls src/autogen/Payment/Payment.entity.ts curl -s -X POST http://localhost:3000/payment \ -H "Content-Type: application/json" \ -d '{ "amount": 2500, "paymentDate": "2025-07-15", "status": "completed", "contractId": 1 }' | jq '.id' # Expected: returns an id

Ask AI to modify an existing entity

“Add a ‘category’ enum to Projects with values Design, Development, Writing, Marketing. Default to Development. Also add a nullable ‘attachmentUrl’ text field.”

What the AI should do:

  1. Edit the Project entity in .apsorc
  2. Run apso generate
  3. Restart the server

Verify:

grep "category" src/autogen/Project/Project.entity.ts grep "attachmentUrl" src/autogen/Project/Project.entity.ts # Both should match curl -s -X POST http://localhost:3000/project \ -H "Content-Type: application/json" \ -d '{ "title": "Logo Redesign", "description": "Need a new logo", "budget": 500, "deadline": "2025-09-01", "category": "Design", "attachmentUrl": "https://example.com/brief.pdf", "clientId": 1 }' | jq '{category, attachmentUrl}' # Expected: {"category": "Design", "attachmentUrl": "https://example.com/brief.pdf"}

Add authentication

“Add authentication. Users should sign up with email and password. Protect all the project and contract endpoints so only authenticated users can access them.”

What the AI should do:

  1. Add BetterAuth configuration and entities (User, Session, Account) to .apsorc
  2. Run apso generate
  3. Restart the server

Verify:

# Auth endpoints exist curl -s -X POST http://localhost:3000/api/auth/sign-up \ -H "Content-Type: application/json" \ -d '{"email": "founder@example.com", "password": "Test1234!"}' | jq # Protected routes return 401 without a token curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/project # Expected: 401 # Sign in and access protected route TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/sign-in \ -H "Content-Type: application/json" \ -d '{"email": "founder@example.com", "password": "Test1234!"}' | jq -r '.token') curl -s http://localhost:3000/project \ -H "Authorization: Bearer $TOKEN" | jq # Expected: returns projects

Pass criteria: The AI translates product requirements into a correct, multi-entity schema with proper relationships. Every entity, field, and relationship the founder described exists in .apsorc and works through the API. The founder validates results through curl without reading or editing code. Authentication protects endpoints as expected. The entire flow happens through conversation.


Journey 3: Developer Evolving an Existing Application

Use case

A developer has a running Apso project in production. Requirements changed. They need to add entities, modify fields, and introduce new relationship types. The changes must not destroy existing data, and the migration system must produce correct, non-destructive SQL.

This is the workflow developers repeat daily, and it is where most backend tools break down. AI-generated migrations often produce plausible-looking SQL that fails under real traffic or destroys data. Firebase schema changes require manual data reshaping. Supabase migrations work but require writing raw SQL. Apso generates TypeORM migrations from the schema diff and lets you inspect the SQL before applying it.

This journey starts from the property management API built in Journey 1.

What this tests

  • Adding a new entity with a ManyToMany relationship (join table generation)
  • Modifying existing entities (additive field changes)
  • Migration SQL correctness: ALTER TABLE ADD COLUMN, not DROP TABLE + CREATE TABLE
  • Migration idempotency: applying the same migration twice produces no changes
  • Migration reset and replay: clearing state and regenerating produces identical SQL
  • Deployment pipeline: migrations apply cleanly to a live database

Steps

Add a new entity with a ManyToMany relationship

The property manager wants to track amenities (Pool, Gym, Parking) and tag them to buildings. A building can have many amenities, and the same amenity can belong to many buildings.

Add to .apsorc:

// Add to the entities array: { "name": "Amenity", "created_at": true, "fields": [ { "name": "name", "type": "text" }, { "name": "description", "type": "text", "nullable": true }, { "name": "category", "type": "enum", "values": ["Fitness", "Convenience", "Outdoor", "Security"], "default": "Convenience" } ] } // Add to the relationships array: { "from": "Building", "to": "Amenity", "type": "ManyToMany", "to_name": "amenities" }
apso generate npm run dev

Verify:

# New entity files exist ls src/autogen/Amenity/Amenity.entity.ts ls src/autogen/Amenity/Amenity.controller.ts # Building entity now has a @ManyToMany decorator grep "ManyToMany" src/autogen/Building/Building.entity.ts # Expected: match found # CRUD works curl -s -X POST http://localhost:3000/amenity \ -H "Content-Type: application/json" \ -d '{"name": "Rooftop Pool", "category": "Outdoor"}' | jq '.id' curl -s http://localhost:3000/amenity | jq '.length' # Expected: 1

Modify an existing entity

Add a leaseType enum and a nullable notes field to the Lease entity. This is an additive change that must not affect existing lease records.

Add to the Lease fields array in .apsorc:

{ "name": "leaseType", "type": "enum", "values": ["Standard", "MonthToMonth", "Sublease"], "default": "Standard" }, { "name": "notes", "type": "text", "nullable": true }
apso generate npm run dev

Verify:

grep "leaseType" src/autogen/Lease/Lease.entity.ts grep "notes" src/autogen/Lease/Lease.entity.ts # Both should match curl -s -X POST http://localhost:3000/lease \ -H "Content-Type: application/json" \ -d '{ "startDate": "2025-06-01", "endDate": "2025-08-31", "monthlyRent": 1800, "leaseType": "Sublease", "notes": "Summer sublease while primary tenant is abroad", "unitId": 1, "tenantId": 1 }' | jq '{leaseType, notes}' # Expected: {"leaseType": "Sublease", "notes": "Summer sublease..."}

Validate migration SQL

This is the most critical step. It separates Apso from tools that generate plausible-looking SQL and from platforms that require you to write raw migration DDL.

apso migrate

This runs a migration diff inside a PGlite sandbox. No external database, no credentials.

Inspect the SQL output. Check for:

  • ALTER TABLE ... ADD COLUMN for leaseType and notes (not DROP TABLE followed by CREATE TABLE)
  • CREATE TABLE for the Amenity table and its join table
  • CREATE TYPE for new enum types
  • No DROP statements on existing tables or columns
  • No NOT NULL on new columns without a DEFAULT (that would break existing rows)
# Get raw SQL for closer inspection apso migrate --sql

Failure indicators:

  • DROP TABLE on any pre-existing table
  • Missing columns or wrong types
  • NOT NULL on a new column without a DEFAULT
  • References to tables or columns you did not change

Verify migration idempotency

# Apply the snapshot (marks the current schema as the baseline) apso migrate --apply # Run migrate again apso migrate

The second run should report no changes. If it generates SQL again, the snapshot did not capture the state correctly.

Verify migration reset and replay

# Reset the sandbox to before the migration apso migrate --reset # Re-run the migration apso migrate --sql > /tmp/migration-rerun.sql

The output should be identical or semantically equivalent to the first run.

Deploy the changes

apso deploy

When apso deploy runs, the build engine:

  1. Clones the repo
  2. Runs existing TypeORM migrations to establish the baseline
  3. Diffs TypeORM entities against the live database schema
  4. Generates and runs the new migration
  5. Deploys the updated service

The apso migrate command you ran locally simulates step 3 in a PGlite sandbox. If it produced correct SQL locally, it will produce correct SQL against the live database.

Verify:

apso status # Expected: a live URL with status "Running" # The Amenity entity and leaseType field should be present curl -s -X POST https://<live-url>/amenity \ -H "Content-Type: application/json" \ -d '{"name": "Gym", "category": "Fitness"}' | jq # Expected: 201 with the created amenity

Pass criteria: Schema changes produce additive SQL with no destructive operations. Applying the snapshot makes the next migration run a no-op. Reset and replay produce the same output. The deployed API includes all schema changes, and existing data is preserved.


CLI Commands Reference

CommandWhat it does
apso init --name <name>Create a new project (clones template, installs deps)
apso init --name <name> --skip-platformCreate without linking to Apso Cloud
apso generateGenerate code from .apsorc schema
apso migrateTest migration in PGlite sandbox, show SQL
apso migrate --sqlOutput raw migration SQL only
apso migrate --applySave current schema as baseline for next diff
apso migrate --resetClear sandbox state
apso schema validateValidate .apsorc syntax and relationships
apso deployDeploy to Apso Cloud
apso statusShow deployment status and live URL
Last updated on