Skip to content

Testing with the Memory Adapter

Chapter 6 of the recipes storyline: the same contract as the search endpoint and its predecessors, executed without a database. Next: the cross-cutting Authorization & Scoping.

Everything the previous chapters promised (allow-lists, defaults, the pagination envelope) lives in the schema and the parse pipeline, not in the database. @rapiq/adapter-memory compiles a parsed Query into plain functions and applies it to arrays, with the same operator semantics the SQL backends render. That makes the whole contract testable in a unit test: decode real wire input through the real codec and registry, run it against fixtures, assert on the result. No engine, no Docker, no mocks.

Fixtures

Plain objects standing in for the rows the endpoint would load; embedded realm objects stand in for the join:

typescript
// test/fixtures.ts
import type { User } from '../src/entities';

export const users: User[] = [
    { id: 1, name: 'Ada', email: 'ada@example.com', age: 34, realm: { id: 'a', name: 'Alpha' } },
    { id: 2, name: 'Ben', email: 'ben@example.com', age: 17, realm: { id: 'a', name: 'Alpha' } },
    { id: 3, name: 'Cleo', email: 'cleo@example.com', age: 28, realm: { id: 'b', name: 'Beta' } },
];

Contract tests

applyQuery(query, data) filters, sorts, paginates and projects in one call and returns { data, total, pagination }; total counts the matches before pagination, exactly what chapter 3's meta block reports. The decode step is the endpoint's own, so these tests pin the contract, not the implementation:

typescript
// test/users.spec.ts
import { describe, expect, it } from 'vitest';
import { FiltersParseError } from '@rapiq/core';
import { applyQuery } from '@rapiq/adapter-memory';
import { codec } from '../src/codec';
import { users } from './fixtures';

const decode = (input: string) => {
    const query = codec.decode(input, { schema: 'user' });
    if (!query) {
        throw new Error('invalid query input');
    }

    return query;
};

describe('GET /users contract', () => {
    it('applies the schema defaults', () => {
        const { data, total, pagination } = applyQuery(decode(''), users);

        expect(total).toBe(3);
        expect(pagination).toEqual({ limit: 50, offset: 0 });    // maxLimit doubles as the default
        expect(data[0]).toEqual({ id: 3, name: 'Cleo' });        // default fields, sorted -id
    });

    it('filters, sorts and pages', () => {
        const { data, total, pagination } = applyQuery(
            decode("filter=gte(age,'18')&sort=-age&page[limit]=1&page[offset]=1"),
            users,
        );

        expect(total).toBe(2);                                   // matches before pagination
        expect(pagination).toEqual({ limit: 1, offset: 1 });
        expect(data).toEqual([{ id: 3, name: 'Cleo' }]);
    });

    it('narrows an included relation to its fieldset', () => {
        const { data } = applyQuery(decode('include=realm&fields[realm]=name'), users);

        expect(data[0]).toEqual({ id: 3, name: 'Cleo', realm: { name: 'Beta' } });
    });

    it('rejects keys outside the contract', () => {
        // the endpoint's 400 branch: throwOnFailure turns this into a typed throw
        expect(() => decode('filter[secret]=x')).toThrow(FiltersParseError);
    });
});

Chapter 5's search endpoint tests the same way; only the parse step differs:

typescript
import { MongoParser } from '@rapiq/parser-mongo';
import { registry } from '../src/schema';

const parser = new MongoParser(registry);

it('parses a search document against the same contract', () => {
    const query = parser.parse({
        filters: { $or: [{ name: { $contains: 'ad' } }, { age: { $gte: 30 } }] },
    }, { schema: 'user' });

    expect(applyQuery(query, users).data).toEqual([{ id: 1, name: 'Ada' }]);
});

Guarding single records

Filters compile to standalone predicates, so a condition can be asserted against one object; the authorization chapter builds its ability guards on exactly this:

typescript
import { and, eq, gte } from '@rapiq/core';
import { compileFilters, compileQuery } from '@rapiq/adapter-memory';

// an ability, as data: "may edit adult users of realm a"
const canEditAdults = and(eq('realm.id', 'a'), gte('age', 18));

const guard = compileFilters(canEditAdults);
guard(users[0]);    // true
guard(users[1]);    // false: age 17

// whole-query form: would this record be selected by the client's query?
const compiled = compileQuery<User>(decode("filter=gte(age,'18')"));
compiled.matches(users[0]);    // true
compiled.matches(users[1]);    // false

compileFilters accepts built-in Filter / Filters semantics (a leaf, a compound tree, or a schema's filters.default), and compiling once amortizes the cost across many records. Its TypeScript boundary is the open ICondition contract, but an unknown custom condition is rejected with CONDITION_DETACHED; see the adapter boundary.

Why this is a faithful double

The memory adapter aims for SQL parity: null and absent values behave like the single SQL NULL (positive operators never match them, negated operators are exact complements), string matching is case-insensitive by default, and dotted paths over arrays use the same-element join-row binding a SQL join produces. A test that passes here asserts the semantics the database query will have, not a mock's opinion of them.

Two caveats keep it honest:

  • It is a semantics double, not a database. relations neither loads nor prunes anything; fixtures must already carry what the endpoint would join (the embedded realm above). Per-backend edge cases are listed in the divergences table.
  • Memory evaluates the full operator set, while the Prisma and Drizzle serializers refuse regex, mod and size with a typed error. A test suite for those backends should not rely on operators the production adapter rejects.

Next steps

Released under the MIT License.