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 whoamiFor AI-assisted paths, install Claude Code:
npm install -g @anthropic-ai/claude-codeNo 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-apiapso 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 existDefine the schema
Edit .apsorc with the data model:
{
"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 devVerify 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.tsThe 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: 0Pass 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
.apsorcschema - 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:
- Run
apso init --name freelancer-api --skip-platform - Create
.apsorcwith entities: Client, Freelancer, Project, Application, Contract - Define relationships: Project belongs to Client, Application belongs to both Project and Freelancer, Contract links to Application
- Run
apso generate - 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.tsAsk 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 2Ask 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:
- Add a Payment entity to
.apsorcwith the described fields - Add a ManyToOne relationship from Payment to Contract
- Run
apso generate - 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 idAsk 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:
- Edit the Project entity in
.apsorc - Run
apso generate - 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:
- Add BetterAuth configuration and entities (User, Session, Account) to
.apsorc - Run
apso generate - 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 projectsPass 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, notDROP 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 devVerify:
# 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: 1Modify 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 devVerify:
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 migrateThis runs a migration diff inside a PGlite sandbox. No external database, no credentials.
Inspect the SQL output. Check for:
ALTER TABLE ... ADD COLUMNforleaseTypeandnotes(notDROP TABLEfollowed byCREATE TABLE)CREATE TABLEfor the Amenity table and its join tableCREATE TYPEfor new enum types- No
DROPstatements on existing tables or columns - No
NOT NULLon new columns without aDEFAULT(that would break existing rows)
# Get raw SQL for closer inspection
apso migrate --sqlFailure indicators:
DROP TABLEon any pre-existing table- Missing columns or wrong types
NOT NULLon a new column without aDEFAULT- 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 migrateThe 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.sqlThe output should be identical or semantically equivalent to the first run.
Deploy the changes
apso deployWhen apso deploy runs, the build engine:
- Clones the repo
- Runs existing TypeORM migrations to establish the baseline
- Diffs TypeORM entities against the live database schema
- Generates and runs the new migration
- 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 amenityPass 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
| Command | What it does |
|---|---|
apso init --name <name> | Create a new project (clones template, installs deps) |
apso init --name <name> --skip-platform | Create without linking to Apso Cloud |
apso generate | Generate code from .apsorc schema |
apso migrate | Test migration in PGlite sandbox, show SQL |
apso migrate --sql | Output raw migration SQL only |
apso migrate --apply | Save current schema as baseline for next diff |
apso migrate --reset | Clear sandbox state |
apso schema validate | Validate .apsorc syntax and relationships |
apso deploy | Deploy to Apso Cloud |
apso status | Show deployment status and live URL |