★ E-Commerce REST API — Full Showcase

A production-grade online store REST API demonstrating every major SpaceNode feature: auto-discovery modules, authentication, role-based access, DTO validation, DI services, event bus, and full integration testing with 46 tests.

What this example covers
  • Registration & authentication (Bearer tokens, bcrypt)
  • Role-based access control (user / admin)
  • Products CRUD (admin) + listing with search & pagination (public)
  • Categories CRUD (admin) + listing (public)
  • Shopping cart with stock validation (authenticated users)
  • DTO request validation on every mutation
  • Event bus for action logging
  • 46 integration tests — zero failures

Project Structure

ecommerce/
├── index.js              // Entry point — MongoDB, auth, guards, createApp
├── package.json
├── .env                  // MONGO_URI
├── test.js               // 46 integration tests
├── models/
│   ├── cart.model.js     // Cart schema (items + productId ref)
│   ├── category.model.js // Category schema
│   ├── product.model.js  // Product schema (name, price, stock, active)
│   ├── token.model.js    // Session token schema
│   └── user.model.js     // User schema (bcrypt, roles, toSafe)
└── modules/
    ├── auth/
    │   ├── module.js
    │   ├── auth.controller.js
    │   ├── auth.service.js
    │   └── auth.dto.js
    ├── cart/
    │   ├── module.js
    │   ├── cart.controller.js
    │   ├── cart.service.js
    │   └── cart.dto.js
    ├── category/
    │   ├── module.js
    │   ├── category.controller.js
    │   ├── category.service.js
    │   └── category.dto.js
    └── product/
        ├── module.js
        ├── product.controller.js
        ├── product.service.js
        └── product.dto.js

API Endpoints

The API exposes 17 RESTful endpoints organized into 4 resource groups. Each endpoint has a clear access level — public routes for browsing, authenticated routes for cart operations, and admin-only routes for catalog management.

Auth — /auth

Handles user registration, login, and profile retrieval. Registration creates a bcrypt-hashed user and returns a session token. Login validates credentials and issues a new token. The /me endpoint requires a valid Bearer token.

MethodPathDescriptionAccess
POST/auth/registerRegister new userPublic
POST/auth/loginLoginPublic
GET/auth/meCurrent user profileAuth

Products — /products

Product listing and search are public — anyone can browse the catalog. Creating, updating, and deleting products requires both authentication and the admin role. The listing supports query parameters for search, category filtering, and pagination.

MethodPathDescriptionAccess
GET/productsList (search, pagination)Public
GET/products/:idSingle productPublic
POST/productsCreate productAdmin
PUT/products/:idUpdate productAdmin
DELETE/products/:idDelete productAdmin

Categories — /categories

Categories organize products into logical groups (e.g., Electronics, Clothing). Public users can browse categories, while only admins can create, edit, or delete them.

MethodPathDescriptionAccess
GET/categoriesList all categoriesPublic
GET/categories/:idSingle categoryPublic
POST/categoriesCreate categoryAdmin
PUT/categories/:idUpdate categoryAdmin
DELETE/categories/:idDelete categoryAdmin

Cart — /cart

All cart operations require authentication. Users can add products, update quantities, remove individual items, or clear the entire cart. The controller validates stock availability before adding items.

MethodPathDescriptionAccess
GET/cartCart contentsAuth
POST/cart/itemsAdd to cartAuth
PUT/cart/items/:idUpdate quantityAuth
DELETE/cart/items/:idRemove from cartAuth
DELETE/cartClear cartAuth

Entry Point — index.js

The entry point connects to MongoDB, sets up Bearer token authentication via defineAuth(), registers a custom 'admin' guard via defineGuard(), and bootstraps the application with createApp(). Auto-discovery finds all 4 modules automatically — no manual route registration needed.

// SpaceNode — E-Commerce REST API Example

import 'dotenv/config'
import mongoose from 'mongoose'
import { createApp, defineAuth, defineGuard } from 'spacenode'
import { Token } from './models/token.model.js'
import { User } from './models/user.model.js'

// ── Connect to MongoDB ──

await mongoose.connect(process.env.MONGO_URI)
console.log('✅ MongoDB connected')

// ── Auth setup ──

defineAuth(async (token) => {
  const session = await Token.findOne({ token })
  if (!session) return null
  const user = await User.findById(session.userId)
  if (!user) return null
  return user.toSafe()
})

// ── Custom guard: admin only ──

defineGuard('admin', () => (request) => {
  if (!request.user) {
    request.error(401, 'Authorization required')
  }
  if (request.user.role !== 'admin') {
    request.error(403, 'Admin access only')
  }
})

// ── Create application ──

const app = await createApp({
  openapi: true,
  watch: true,
  debug: true,
})

// ── Root route ──

app.setRoute('GET', '/', ({ send }) => {
  send({
    name: 'SpaceNode E-Commerce API',
    version: '1.0.0',
    endpoints: { /* ... */ },
  })
})

// ── Start ──

app.listen(3000)
Note — That's the entire bootstrap. createApp() auto-discovers all modules in modules/, registers services, wires routes, and enables OpenAPI.

Mongoose Models

The data layer uses 5 Mongoose schemas. Each model defines its own validation constraints, relationships (via ObjectId refs), and helper methods. Passwords are automatically hashed on save, and sensitive fields are stripped via toSafe().

models/user.model.js

Stores user accounts with name, email, hashed password, and role (user or admin). The pre('save') hook auto-hashes passwords with bcrypt. The verifyPassword() method compares plain text to the hash, and toSafe() returns a sanitized object without the password field.

import mongoose from 'mongoose'
import bcrypt from 'bcrypt'

const userSchema = new mongoose.Schema({
  name:  { type: String, required: true, minlength: 2, maxlength: 50 },
  email: { type: String, required: true, unique: true, lowercase: true, trim: true },
  password: { type: String, required: true },
  role:  { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true })

userSchema.pre('save', async function () {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 10)
  }
})

userSchema.methods.verifyPassword = function (plain) {
  return bcrypt.compare(plain, this.password)
}

userSchema.methods.toSafe = function () {
  const obj = this.toObject()
  delete obj.password
  obj.id = obj._id
  return obj
}

export const User = mongoose.model('User', userSchema)

models/token.model.js

Maps session tokens to user IDs. Each login or registration generates a random 64-character hex token stored here. The defineAuth() verifier looks up this collection to authenticate requests.

import mongoose from 'mongoose'

const tokenSchema = new mongoose.Schema({
  token:  { type: String, required: true, unique: true, index: true },
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true })

export const Token = mongoose.model('Token', tokenSchema)

models/product.model.js

Represents a product in the catalog — name, price, stock count, category reference, description, image URL, and an active flag for soft-disabling products without deleting them.

import mongoose from 'mongoose'

const productSchema = new mongoose.Schema({
  name:        { type: String, required: true, minlength: 2, maxlength: 200 },
  price:       { type: Number, required: true, min: 0 },
  categoryId:  { type: mongoose.Schema.Types.ObjectId, ref: 'Category', required: true },
  description: { type: String, default: '', maxlength: 2000 },
  stock:       { type: Number, default: 0, min: 0 },
  image:       { type: String, default: '' },
  active:      { type: Boolean, default: true },
}, { timestamps: true })

export const Product = mongoose.model('Product', productSchema)

models/category.model.js

A simple schema for product categories — name, description, and an optional icon field. Products reference categories via categoryId.

import mongoose from 'mongoose'

const categorySchema = new mongoose.Schema({
  name:        { type: String, required: true, minlength: 2 },
  description: { type: String, default: '' },
  icon:        { type: String, default: '' },
}, { timestamps: true })

export const Category = mongoose.model('Category', categorySchema)

models/cart.model.js

Each user has a single cart document containing an array of items. Each item holds a productId reference and a quantity. The unique: true constraint on userId ensures one cart per user.

import mongoose from 'mongoose'

const cartItemSchema = new mongoose.Schema({
  productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true },
  quantity:  { type: Number, required: true, min: 1, default: 1 },
  addedAt:   { type: Date, default: Date.now },
}, { _id: false })

const cartSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true },
  items:  [cartItemSchema],
})

export const Cart = mongoose.model('Cart', cartSchema)

Auth Module

Handles user registration, login, and session management. The module uses 'cors' and 'logger' pipes at the module level, and DTO validation on mutation endpoints. The 'auth' pipe protects the /me route.

modules/auth/module.js

The module definition file — SpaceNode auto-discovers this. It declares the URL prefix, shared pipes, and routes. Each route maps an HTTP method + path to a controller function, with optional per-route pipes like 'dto:registerDto'.

export default {
  prefix: '/auth',
  pipe: ['cors', 'logger'],

  routes: [
    ['POST', '/register', 'register', ['dto:registerDto']],
    ['POST', '/login',    'login',    ['dto:loginDto']],
    ['GET',  '/me',       'me',       ['auth']],
  ],
}

modules/auth/auth.dto.js

Defines validation schemas for registration and login. SpaceNode’s built-in DTO validator uses array syntax — ['string', 'required', 'email'] validates that the field is a non-empty string matching an email pattern. No external library needed.

export const registerDto = {
  name:     ['string', 'required', 'min:2', 'max:50'],
  email:    ['string', 'required', 'email'],
  password: ['string', 'required', 'min:6', 'max:100'],
}

export const loginDto = {
  email:    ['string', 'required', 'email'],
  password: ['string', 'required', 'min:1'],
}

modules/auth/auth.service.js

Two services exported here — userStore for user CRUD operations and tokenStore for session token management. SpaceNode auto-registers both into the DI container and injects them into any controller that destructures them.

import { randomBytes } from 'node:crypto'
import { User } from '../../models/user.model.js'
import { Token } from '../../models/token.model.js'

// ── User Service ──

export const userStore = {
  async create({ name, email, password, role = 'user' }) {
    const existing = await User.findOne({ email })
    if (existing) return null
    return User.create({ name, email, password, role })
  },

  async getById(id)    { return User.findById(id) },
  async getByEmail(email) { return User.findOne({ email }) },
  async verifyPassword(user, password) { return user.verifyPassword(password) },
}

// ── Token Service ──

export const tokenStore = {
  async create(userId) {
    const token = randomBytes(32).toString('hex')
    await Token.create({ token, userId })
    return token
  },

  async get(token)   { return Token.findOne({ token }) },
  async revoke(token) { await Token.deleteOne({ token }) },
}

modules/auth/auth.controller.js

Three handler functions: register, login, and me. Each receives the request object and DI services. Notice how guard() blocks if a condition is truthy (e.g., duplicate email), while check() blocks if falsy (e.g., user not found). The emit() fires events for action logging.

/**
 * POST /auth/register
 * Body: { name, email, password }  →  { user, token }
 */
export async function register(request, { userStore, tokenStore }) {
  const { body, guard, check, emit, send } = request
  const { name, email, password } = body

  const existing = await userStore.getByEmail(email)
  guard(existing, 409, 'User with this email already exists')

  const user = await userStore.create({ name, email, password })
  check(user, 500, 'Failed to create user')

  const token = await tokenStore.create(user._id)
  await emit('auth:register', { userId: user._id, email })

  const safe = user.toSafe()
  send(201, {
    user: { id: safe.id, name: safe.name, email: safe.email, role: safe.role },
    token,
  })
}

/**
 * POST /auth/login
 * Body: { email, password }  →  { user, token }
 */
export async function login(request, { userStore, tokenStore }) {
  const { body, check, emit, send } = request
  const { email, password } = body

  const user = await userStore.getByEmail(email)
  check(user, 401, 'Invalid email or password')

  const valid = await userStore.verifyPassword(user, password)
  check(valid, 401, 'Invalid email or password')

  const token = await tokenStore.create(user._id)
  await emit('auth:login', { userId: user._id, email })

  const safe = user.toSafe()
  send({
    user: { id: safe.id, name: safe.name, email: safe.email, role: safe.role },
    token,
  })
}

/**
 * GET /auth/me  →  { user }
 */
export function me({ user, send }) {
  send({ user })
}

Product Module

Full CRUD for products with role-based access. Public routes (list, getById) have no pipes. Admin routes (create, update, delete) chain 'auth''admin''dto:...' pipes for authentication, role check, and input validation.

modules/product/module.js

Declares 5 routes under /products. The empty-string path ('') matches the prefix itself. Notice how admin routes stack three pipes — SpaceNode runs them in order, short-circuiting on failure.

export default {
  prefix: '/products',
  pipe: ['cors', 'logger'],

  routes: [
    ['GET', '',     'list'],
    ['GET', '/:id', 'getById'],

    ['POST',   '',     'create',  ['auth', 'admin', 'dto:createProductDto']],
    ['PUT',    '/:id', 'update',  ['auth', 'admin', 'dto:updateProductDto']],
    ['DELETE', '/:id', 'remove',  ['auth', 'admin']],
  ],
}

modules/product/product.dto.js

Two DTOs: createProductDto (requires name, price, categoryId) and updateProductDto (all fields optional — partial updates). SpaceNode only validates fields present in the request body against the DTO rules.

export const createProductDto = {
  name:        ['string', 'required', 'min:2', 'max:200'],
  price:       ['number', 'required', 'min:0'],
  categoryId:  ['string', 'required'],
  description: ['string', 'max:2000'],
  stock:       ['number', 'min:0'],
  image:       ['string', 'max:500'],
}

export const updateProductDto = {
  name:        ['string', 'min:2', 'max:200'],
  price:       ['number', 'min:0'],
  categoryId:  ['string'],
  description: ['string', 'max:2000'],
  stock:       ['number', 'min:0'],
  image:       ['string', 'max:500'],
  active:      ['boolean'],
}

modules/product/product.service.js

The productStore service wraps all MongoDB operations — create, getById, list (with search/pagination/filtering), update (whitelisted fields only), and delete. The list() method builds a dynamic filter from query parameters and returns paginated results with total count.

import { Product } from '../../models/product.model.js'

export const productStore = {
  async create({ name, price, categoryId, description = '', stock = 0, image = '' }) {
    return Product.create({ name, price, categoryId, description, stock, image })
  },

  async getById(id) {
    return Product.findById(id)
  },

  async list({ categoryId, search, page = 1, limit = 20, onlyActive = true } = {}) {
    const filter = {}

    if (onlyActive) filter.active = true
    if (categoryId) filter.categoryId = categoryId
    if (search) {
      const regex = new RegExp(search, 'i')
      filter.$or = [{ name: regex }, { description: regex }]
    }

    const total = await Product.countDocuments(filter)
    const offset = (Number(page) - 1) * Number(limit)
    const items = await Product.find(filter).skip(offset).limit(Number(limit))

    return { items, total, page: Number(page), pages: Math.ceil(total / Number(limit)) }
  },

  async update(id, data) {
    const allowed = ['name', 'price', 'categoryId', 'description', 'stock', 'image', 'active']
    const update = {}
    for (const key of allowed) {
      if (data[key] !== undefined) update[key] = data[key]
    }
    return Product.findByIdAndUpdate(id, update, { new: true })
  },

  async delete(id) {
    const result = await Product.findByIdAndDelete(id)
    return !!result
  },
}

modules/product/product.controller.js

Each handler delegates to the productStore service. Cross-module DI is demonstrated in create — it injects categoryStore (from the category module) to verify the category exists before creating a product. All mutations emit events for audit logging.

/**
 * GET /products?categoryId=...&search=...&page=1&limit=20
 */
export async function list({ query, send }, { productStore }) {
  const { categoryId, search, page, limit } = query
  const result = await productStore.list({ categoryId, search, page, limit })
  send(result)
}

/** GET /products/:id */
export async function getById({ params, check, send }, { productStore }) {
  const product = await productStore.getById(params.id)
  check(product, 404, 'Product not found')
  send(product)
}

/** POST /products — Admin only */
export async function create(request, { categoryStore, productStore }) {
  const { body, user, check, emit, send } = request
  const category = await categoryStore.getById(body.categoryId)
  check(category, 400, 'Category not found')
  const product = await productStore.create(body)
  await emit('product:created', { productId: product._id, adminId: user.id })
  send(201, product)
}

/** PUT /products/:id — Admin only */
export async function update(request, { productStore }) {
  const { params, body, user, check, emit, send } = request
  const product = await productStore.update(params.id, body)
  check(product, 404, 'Product not found')
  await emit('product:updated', { productId: product._id, adminId: user.id })
  send(product)
}

/** DELETE /products/:id — Admin only */
export async function remove(request, { productStore }) {
  const { params, user, check, emit, send } = request
  const product = await productStore.getById(params.id)
  check(product, 404, 'Product not found')
  await productStore.delete(params.id)
  await emit('product:deleted', { productId: params.id, adminId: user.id })
  send(204)
}

Category Module

Mirrors the product module pattern — public listing, admin-only mutations. Demonstrates SpaceNode’s consistent module structure: every module has the same 4 files (module.js, dto, service, controller) following the same conventions.

modules/category/module.js

Same structure as other modules — prefix, shared pipes, and routes. Admin routes use the 'admin' guard and the corresponding DTO for input validation.

export default {
  prefix: '/categories',
  pipe: ['cors', 'logger'],

  routes: [
    ['GET', '',     'list'],
    ['GET', '/:id', 'getById'],

    ['POST',   '',     'create', ['auth', 'admin', 'dto:createCategoryDto']],
    ['PUT',    '/:id', 'update', ['auth', 'admin', 'dto:updateCategoryDto']],
    ['DELETE', '/:id', 'remove', ['auth', 'admin']],
  ],
}

modules/category/category.dto.js

Validation schemas for creating and updating categories. The updateCategoryDto omits 'required' — allowing partial updates where only the provided fields are validated.

export const createCategoryDto = {
  name:        ['string', 'required', 'min:2', 'max:100'],
  description: ['string', 'max:500'],
  icon:        ['string', 'max:200'],
}

export const updateCategoryDto = {
  name:        ['string', 'min:2', 'max:100'],
  description: ['string', 'max:500'],
  icon:        ['string', 'max:200'],
}

modules/category/category.service.js

CRUD operations for categories. The update() method whitelists allowed fields before applying changes — protecting against mass assignment attacks.

import { Category } from '../../models/category.model.js'

export const categoryStore = {
  async list()       { return Category.find() },
  async getById(id) { return Category.findById(id) },

  async create(data) {
    return Category.create({
      name: data.name,
      description: data.description || '',
      icon: data.icon || '',
    })
  },

  async update(id, data) {
    const allowed = ['name', 'description', 'icon']
    const update = {}
    for (const key of allowed) {
      if (data[key] !== undefined) update[key] = data[key]
    }
    return Category.findByIdAndUpdate(id, update, { new: true })
  },

  async delete(id) {
    const result = await Category.findByIdAndDelete(id)
    return !!result
  },
}

modules/category/category.controller.js

Standard CRUD handlers. Each admin action emits a domain event (e.g., 'category:created') with relevant metadata. These events can be consumed by other modules for analytics, caching, or notifications.

/** GET /categories — Public */
export async function list({ send }, { categoryStore }) {
  const categories = await categoryStore.list()
  send(categories)
}

/** GET /categories/:id — Public */
export async function getById({ params, check, send }, { categoryStore }) {
  const category = await categoryStore.getById(params.id)
  check(category, 404, 'Category not found')
  send(category)
}

/** POST /categories — Admin only */
export async function create(request, { categoryStore }) {
  const { body, user, emit, send } = request
  const category = await categoryStore.create(body)
  await emit('category:created', { categoryId: category._id, adminId: user.id })
  send(201, category)
}

/** PUT /categories/:id — Admin only */
export async function update(request, { categoryStore }) {
  const { params, body, user, check, emit, send } = request
  const category = await categoryStore.update(params.id, body)
  check(category, 404, 'Category not found')
  await emit('category:updated', { categoryId: category._id, adminId: user.id })
  send(category)
}

/** DELETE /categories/:id — Admin only */
export async function remove(request, { categoryStore }) {
  const { params, user, check, emit, send } = request
  const category = await categoryStore.getById(params.id)
  check(category, 404, 'Category not found')
  await categoryStore.delete(params.id)
  await emit('category:deleted', { categoryId: params.id, adminId: user.id })
  send(204)
}

Cart Module

The most complex module — demonstrates module-level authentication ('auth' pipe on all routes), stock validation, cross-module DI (injects productStore), and event-driven communication. All cart operations are scoped to the authenticated user.

modules/cart/module.js

Note the 'auth' pipe at the module level — every route in this module requires authentication. Individual routes add DTO validation where needed. No route has 'admin' — any authenticated user can manage their own cart.

export default {
  prefix: '/cart',
  pipe: ['cors', 'logger', 'auth'],

  routes: [
    ['GET',    '',           'list'],
    ['POST',   '/items',     'addItem',    ['dto:addItemDto']],
    ['PUT',    '/items/:id', 'updateItem', ['dto:updateItemDto']],
    ['DELETE', '/items/:id', 'removeItem'],
    ['DELETE', '',           'clear'],
  ],
}

modules/cart/cart.dto.js

Validates cart item operations. addItemDto requires a productId and limits quantity to 1–99. updateItemDto allows setting quantity to 0 (which triggers item removal in the service layer).

export const addItemDto = {
  productId: ['string', 'required'],
  quantity:  ['number', 'min:1', 'max:99'],
}

export const updateItemDto = {
  quantity: ['number', 'required', 'min:0', 'max:99'],
}

modules/cart/cart.service.js

Handles all cart persistence logic with MongoDB atomic operations. addItem() either increments an existing item’s quantity (via $inc) or pushes a new item (via $push with upsert). The list() method populates product references to return enriched cart data with names and prices.

import { Cart } from '../../models/cart.model.js'

export const cartStore = {
  async list(userId) {
    const cart = await Cart.findOne({ userId }).populate('items.productId', 'name price image')
    if (!cart || !cart.items.length) return { items: [], total: 0, count: 0 }
    const validItems = cart.items.filter(i => i.productId && typeof i.productId === 'object')
    const total = validItems.reduce((sum, i) => sum + i.productId.price * i.quantity, 0)
    return { items: validItems, total, count: validItems.length }
  },

  async getCart(userId)  { return Cart.findOne({ userId }) },

  async addItem(userId, productId, quantity = 1) {
    const has = await Cart.exists({ userId, 'items.productId': productId })
    if (has) {
      return Cart.findOneAndUpdate(
        { userId, 'items.productId': productId },
        { $inc: { 'items.$.quantity': quantity } },
        { new: true }
      )
    }
    return Cart.findOneAndUpdate(
      { userId },
      { $push: { items: { productId, quantity } } },
      { new: true, upsert: true }
    )
  },

  async updateItem(userId, productId, quantity) {
    if (quantity <= 0) return this.removeItem(userId, productId)
    return Cart.findOneAndUpdate(
      { userId, 'items.productId': productId },
      { $set: { 'items.$.quantity': quantity } },
      { new: true }
    )
  },

  async removeItem(userId, productId) {
    return Cart.findOneAndUpdate(
      { userId },
      { $pull: { items: { productId } } },
      { new: true }
    )
  },

  async clear(userId) {
    return Cart.findOneAndUpdate({ userId }, { $set: { items: [] } }, { new: true })
  },
}

modules/cart/cart.controller.js

The addItem handler is the most interesting — it validates that the product exists, is active, and has sufficient stock before adding to cart. It uses cross-module DI by injecting productStore from the product module. The removeItem handler checks cart contents before deletion to return a proper 404 if the item isn’t in the cart.

/** GET /cart — Cart contents */
export async function list({ user, send }, { cartStore }) {
  const cart = await cartStore.list(user.id)
  send(cart)
}

/** POST /cart/items — Add product to cart */
export async function addItem(request, { productStore, cartStore }) {
  const { body, user, check, emit, send } = request
  const { productId, quantity = 1 } = body

  const product = await productStore.getById(productId)
  check(product, 404, 'Product not found')
  check(product.active, 400, 'Product is not available for purchase')
  check(product.stock >= quantity, 400, `Not enough stock (available: ${product.stock})`)

  const item = await cartStore.addItem(user.id, productId, quantity)
  await emit('cart:item-added', { userId: user.id, productId, quantity })
  send(201, { message: `${product.name} added to cart`, item })
}

/** PUT /cart/items/:id — Update quantity */
export async function updateItem(request, { productStore, cartStore }) {
  const { params, body, user, check, send } = request
  const productId = params.id
  const { quantity } = body

  if (quantity > 0) {
    const product = await productStore.getById(productId)
    check(product, 404, 'Product not found')
    check(product.stock >= quantity, 400, `Not enough stock (available: ${product.stock})`)
  }

  const item = await cartStore.updateItem(user.id, productId, quantity)
  check(item, 404, 'Product not found in cart')
  send(item)
}

/** DELETE /cart/items/:id — Remove product from cart */
export async function removeItem(request, { cartStore }) {
  const { params, user, check, emit, send } = request
  const productId = params.id

  const cartBefore = await cartStore.getCart(user.id)
  const hadItem = cartBefore?.items.some(i => String(i.productId) === String(productId))
  check(hadItem, 404, 'Product not found in cart')

  await cartStore.removeItem(user.id, productId)
  await emit('cart:item-removed', { userId: user.id, productId })
  send({ message: 'Product removed from cart' })
}

/** DELETE /cart — Clear cart */
export async function clear({ user, send }, { cartStore }) {
  await cartStore.clear(user.id)
  send({ message: 'Cart cleared' })
}

Integration Tests — 46 tests, 0 failures

The test suite uses SpaceNode’s built-in app.inject() method to send requests directly to the application without starting an HTTP server. This makes tests fast, deterministic, and free of port conflicts. Tests cover all 17 endpoints including edge cases like duplicate registration, unauthorized access, out-of-stock validation, and cascading deletes.

Built-in testing — Uses SpaceNode's app.inject() to test routes without starting a server. No HTTP overhead, real MongoDB.
// Run tests:
// node --test test.js

import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createApp, defineAuth, defineGuard } from 'spacenode'

// ── Setup: connect, defineAuth, createApp ──

describe('Auth', () => {
  it('POST /auth/register — should register a new user', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/auth/register',
      body: { name: 'Test User', email: testEmail, password: 'secret123' },
    })
    assert.strictEqual(res.statusCode, 201)
    assert.ok(res.json.token)
    assert.strictEqual(res.json.user.role, 'user')
  })

  it('POST /auth/register — should reject duplicate email', async () => {
    const res = await app.inject({ method: 'POST', url: '/auth/register',
      body: { name: 'Dup', email: testEmail, password: 'secret123' } })
    assert.strictEqual(res.statusCode, 409)
  })

  it('GET /auth/me — should return current user', async () => {
    const res = await app.inject({
      method: 'GET', url: '/auth/me',
      headers: { authorization: `Bearer ${userToken}` },
    })
    assert.strictEqual(res.statusCode, 200)
    assert.strictEqual(res.json.user.email, testEmail)
  })
  // ... 10 auth tests total
})

describe('Products', () => {
  it('POST /products — should create product (admin)', async () => {
    const res = await app.inject({
      method: 'POST', url: '/products',
      body: { name: 'iPhone 16', price: 999, categoryId, stock: 10 },
      headers: { authorization: `Bearer ${adminToken}` },
    })
    assert.strictEqual(res.statusCode, 201)
    assert.strictEqual(res.json.price, 999)
  })

  it('GET /products?search= — should filter by search', async () => {
    const res = await app.inject({ method: 'GET', url: `/products?search=iPhone` })
    assert.ok(res.json.items[0].name.includes('iPhone'))
  })
  // ... 11 product tests total
})

describe('Cart', () => {
  it('POST /cart/items — should add product to cart', async () => {
    const res = await app.inject({
      method: 'POST', url: '/cart/items',
      body: { productId, quantity: 2 },
      headers: { authorization: `Bearer ${userToken}` },
    })
    assert.strictEqual(res.statusCode, 201)
    assert.ok(res.json.message.includes('added to cart'))
  })
  // ... 11 cart tests total
})
Expected test output:

▶ Auth (10 tests)
▶ Categories (8 tests)
▶ Products (11 tests)
▶ Cart (11 tests)
▶ Delete operations (5 tests)

ℹ tests 46
ℹ pass  46
ℹ fail  0

Quick Setup

Get the project running locally in under a minute. You need Node.js 18+ and a running MongoDB instance (local or Atlas cloud).

# Clone and install
git clone https://github.com/1RomanKulichenko/SpaceNode.git
cd SpaceNode/examples/ecommerce
npm install

# Configure MongoDB
echo "MONGO_URI=mongodb://localhost:27017/ecommerce" > .env

# Start the server
node index.js
# → E-Commerce API started at http://localhost:3000

# Run tests
node --test test.js
# → 46 tests passed, 0 failures

Dependencies

Only 4 production dependencies — SpaceNode itself, Mongoose for MongoDB integration, bcrypt for password hashing, and dotenv for environment variables. No Express, no Passport, no body-parser — SpaceNode has all of that built in.

{
  "dependencies": {
    "bcrypt":    "^6.0.0",     // Password hashing
    "dotenv":    "^17.3.1",    // Environment variables
    "mongoose":  "^9.2.4",     // MongoDB ODM
    "spacenode": "^1.0.2"      // The framework
  }
}

SpaceNode Features Used

This example demonstrates:
  • Auto-discovery — 4 modules discovered automatically from modules/
  • defineAuth() — Bearer token authentication with MongoDB sessions
  • defineGuard() — Custom 'admin' guard for role-based access
  • DTO validation — Array-based schemas: ['string', 'required', 'email']
  • Pipeline pipes'cors', 'logger', 'auth' at module level
  • DI servicesuserStore, tokenStore, productStore, categoryStore, cartStore injected into controllers
  • Event busemit('auth:register'), emit('product:created'), emit('cart:item-added')
  • Request helperscheck(), guard(), send(), error()
  • inject() testing — 46 tests without starting an HTTP server
  • OpenAPI — Auto-generated API docs at /openapi.json