test(server): add vitest harness and initial integration tests (#302)

This commit is contained in:
Ylber Gashi
2026-01-19 22:17:32 +01:00
committed by GitHub
parent cc72b3e2a1
commit 3e1070f3a6
26 changed files with 3555 additions and 233 deletions

66
apps/server/README.md Normal file
View File

@@ -0,0 +1,66 @@
# Colanode Server (apps/server)
Colanode server is the authoritative sync, auth, and realtime layer for the local-first collaboration stack. Clients keep a local SQLite cache and sync mutation batches to this server; the server validates, persists, and broadcasts changes over WebSocket.
## Architecture overview
- **Runtime**: Fastify + Zod validation, WebSocket support, Redis-backed event bus, BullMQ jobs, Kysely/Postgres persistence.
- **Data model**: Nodes, documents, collaborations, reactions, interactions, and tombstones are revisioned streams with merge-safe CRDT updates.
- **Sync**: Clients push mutation batches; server writes to Postgres and emits events. WebSocket synchronizers stream incremental changes per entity type using revision cursors.
- **Storage**: File data is stored via pluggable providers (file system, S3, GCS, Azure). TUS handles resumable uploads.
- **Security**: Token-per-device authentication, rate limiting (IP + device + email), workspace authorization gates.
## Requirements
- Node.js 20+ (tests require this due to Vitest v4).
- Postgres with the pgvector extension.
- Redis (or compatible).
- Docker is recommended for local dev and required for Testcontainers-based tests.
## Quick start (local dev)
From the repo root:
```bash
npm install
docker compose -f hosting/docker/docker-compose.yaml up -d
```
From `apps/server`:
```bash
cp .env.example .env
npm run dev
```
## Configuration
The server reads configuration from a JSON file, or falls back to schema defaults in `apps/server/src/lib/config/`.
- `CONFIG` points to the config JSON file.
- `apps/server/config.example.json` is the recommended template.
- Values can reference `env://VAR_NAME` or `file://path/to/secret` for secrets.
- `postgres.url` is required and defaults to `env://POSTGRES_URL`.
## Code map
- `apps/server/src/api`: HTTP + WebSocket routes and plugins.
- `apps/server/src/data`: database + redis clients and migrations.
- `apps/server/src/synchronizers`: WebSocket sync streams by entity type.
- `apps/server/src/jobs`: background jobs and handlers.
- `apps/server/src/services`: email, jobs, and other infrastructure services.
- `apps/server/src/lib`: shared server logic and helpers.
## Tests
From `apps/server`:
```bash
npm run test
```
Notes:
- Tests use Testcontainers for Postgres (pgvector) and Redis. Docker must be running.
- Fastify route tests use `fastify.inject()` (no network ports).
- Shared test helpers live in `apps/server/test/helpers`.

View File

@@ -15,15 +15,21 @@
"build": "npm run compile && tsup-node",
"clean": "del-cli dist isolate tsconfig.tsbuildinfo",
"lint": "eslint . --max-warnings 0",
"dev": "tsx watch --env-file .env src/index.ts"
"dev": "tsx watch --env-file .env src/index.ts",
"test": "vitest run --config vitest.config.ts"
},
"description": "",
"devDependencies": {
"@testcontainers/postgresql": "^11.11.0",
"@testcontainers/redis": "^11.11.0",
"@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",
"@types/ws": "^8.18.1",
"tsup": "^8.5.1"
"testcontainers": "^11.11.0",
"tsup": "^8.5.1",
"vite-tsconfig-paths": "^6.0.4",
"vitest": "^4.0.17"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.965.0",

View File

@@ -0,0 +1,105 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ApiHeader, UserStatus } from '@colanode/core';
import { database } from '@colanode/server/data/database';
import { buildTestApp } from '../helpers/app';
import {
buildAuthHeader,
createAccount,
createDevice,
createUser,
createWorkspace,
} from '../helpers/seed';
const CLIENT_PLATFORM = 'test-platform';
const CLIENT_VERSION = '1.2.3';
const CLIENT_TYPE = 'web';
const CLIENT_IP = '203.0.113.10';
const app = buildTestApp();
beforeAll(async () => {
await app.ready();
});
afterAll(async () => {
await app.close();
});
describe('POST /client/v1/accounts/sync', () => {
it('returns only active workspaces and updates device metadata', async () => {
const account = await createAccount({
email: 'sync@example.com',
password: 'Password123!',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const hiddenWorkspace = await createWorkspace({
createdBy: account.id,
name: 'Hidden Workspace',
});
await createUser({
workspaceId: hiddenWorkspace.id,
account,
role: 'none',
});
const removedWorkspace = await createWorkspace({
createdBy: account.id,
name: 'Removed Workspace',
});
await createUser({
workspaceId: removedWorkspace.id,
account,
role: 'collaborator',
status: UserStatus.Removed,
});
const { device, token } = await createDevice({ accountId: account.id });
const response = await app.inject({
method: 'POST',
url: '/client/v1/accounts/sync',
headers: {
...buildAuthHeader(token),
[ApiHeader.ClientPlatform]: CLIENT_PLATFORM,
[ApiHeader.ClientVersion]: CLIENT_VERSION,
[ApiHeader.ClientType]: CLIENT_TYPE,
'x-forwarded-for': CLIENT_IP,
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
account: { id: string };
workspaces: { id: string }[];
};
expect(body.account.id).toBe(account.id);
expect(body.workspaces).toHaveLength(1);
expect(body.workspaces[0]?.id).toBe(workspace.id);
const updatedDevice = await database
.selectFrom('devices')
.selectAll()
.where('id', '=', device.id)
.executeTakeFirst();
expect(updatedDevice?.synced_at).not.toBeNull();
expect(updatedDevice?.platform).toBe(CLIENT_PLATFORM);
expect(updatedDevice?.version).toBe(CLIENT_VERSION);
expect(updatedDevice?.ip).toBe(CLIENT_IP);
});
});

View File

@@ -0,0 +1,118 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ApiErrorCode } from '@colanode/core';
import { database } from '@colanode/server/data/database';
import { buildTestApp } from '../helpers/app';
import {
createAccount,
createDevice,
createUser,
createWorkspace,
} from '../helpers/seed';
const app = buildTestApp();
beforeAll(async () => {
await app.ready();
});
afterAll(async () => {
await app.close();
});
describe('POST /client/v1/auth/email/login', () => {
it('returns EmailOrPasswordIncorrect for unknown email', async () => {
const response = await app.inject({
method: 'POST',
url: '/client/v1/auth/email/login',
payload: {
email: 'missing@example.com',
password: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
code: ApiErrorCode.EmailOrPasswordIncorrect,
});
});
});
describe('token lifecycle', () => {
it('creates a device on login and deletes it on logout', async () => {
const account = await createAccount({
email: 'login-user@example.com',
password: 'Password123!',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const loginResponse = await app.inject({
method: 'POST',
url: '/client/v1/auth/email/login',
payload: {
email: account.email,
password: 'Password123!',
},
});
expect(loginResponse.statusCode).toBe(200);
const loginBody = loginResponse.json();
expect(loginBody).toMatchObject({
type: 'success',
account: { id: account.id },
});
const deviceId = loginBody.deviceId as string;
const token = loginBody.token as string;
const device = await database
.selectFrom('devices')
.selectAll()
.where('id', '=', deviceId)
.executeTakeFirst();
expect(device).not.toBeNull();
const logoutResponse = await app.inject({
method: 'DELETE',
url: '/client/v1/auth/logout',
headers: {
authorization: `Bearer ${token}`,
},
});
expect(logoutResponse.statusCode).toBe(200);
const deletedDevice = await database
.selectFrom('devices')
.selectAll()
.where('id', '=', deviceId)
.executeTakeFirst();
expect(deletedDevice).toBeUndefined();
const logoutBody = logoutResponse.json();
expect(logoutBody).toMatchObject({});
});
it('rejects logout without a token', async () => {
const logoutResponse = await app.inject({
method: 'DELETE',
url: '/client/v1/auth/logout',
});
expect(logoutResponse.statusCode).toBe(401);
expect(logoutResponse.json()).toMatchObject({
code: ApiErrorCode.TokenMissing,
});
});
});

View File

@@ -0,0 +1,192 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ApiErrorCode, FileStatus } from '@colanode/core';
import { database } from '@colanode/server/data/database';
import { updateNode } from '@colanode/server/lib/nodes';
import { buildTestApp } from '../helpers/app';
import {
buildAuthHeader,
createAccount,
createDevice,
createFileNode,
createSpaceNode,
createUser,
createWorkspace,
} from '../helpers/seed';
const app = buildTestApp();
beforeAll(async () => {
await app.ready();
});
afterAll(async () => {
await app.close();
});
const extractPath = (location?: string) => {
if (!location) {
throw new Error('Missing Location header');
}
try {
const url = new URL(location);
return url.pathname;
} catch {
return location;
}
};
describe('file uploads', () => {
it('marks file Ready and sets uploaded_at after TUS completion', async () => {
const account = await createAccount({
email: 'upload@example.com',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
const user = await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const { token } = await createDevice({ accountId: account.id });
const rootId = await createSpaceNode({
workspaceId: workspace.id,
userId: user.id,
});
const payload = Buffer.from('hello world');
const fileId = await createFileNode({
workspaceId: workspace.id,
userId: user.id,
parentId: rootId,
rootId,
size: payload.length,
name: 'upload.txt',
extension: '.txt',
});
const createResponse = await app.inject({
method: 'POST',
url: `/client/v1/workspaces/${workspace.id}/files/${fileId}/tus`,
headers: {
...buildAuthHeader(token),
'Tus-Resumable': '1.0.0',
'Upload-Length': payload.length.toString(),
},
});
expect([201, 204]).toContain(createResponse.statusCode);
const location = createResponse.headers.location as string | undefined;
const uploadPath = extractPath(location);
const patchResponse = await app.inject({
method: 'PATCH',
url: uploadPath,
headers: {
...buildAuthHeader(token),
'Tus-Resumable': '1.0.0',
'Upload-Offset': '0',
'Content-Type': 'application/offset+octet-stream',
'Content-Length': payload.length.toString(),
},
payload,
});
expect([200, 204]).toContain(patchResponse.statusCode);
const upload = await database
.selectFrom('uploads')
.selectAll()
.where('file_id', '=', fileId)
.executeTakeFirst();
expect(upload?.uploaded_at).not.toBeNull();
const node = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', fileId)
.executeTakeFirst();
expect(node).not.toBeNull();
const attributes = node?.attributes as { status?: number } | null;
expect(attributes?.status).toBe(FileStatus.Ready);
});
});
describe('file download guards', () => {
it('rejects download when file is not ready or upload missing', async () => {
const account = await createAccount({
email: 'download@example.com',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
const user = await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const { token } = await createDevice({ accountId: account.id });
const rootId = await createSpaceNode({
workspaceId: workspace.id,
userId: user.id,
});
const fileId = await createFileNode({
workspaceId: workspace.id,
userId: user.id,
parentId: rootId,
rootId,
size: 10,
});
const notReadyResponse = await app.inject({
method: 'GET',
url: `/client/v1/workspaces/${workspace.id}/files/${fileId}`,
headers: buildAuthHeader(token),
});
expect(notReadyResponse.statusCode).toBe(400);
expect(notReadyResponse.json()).toMatchObject({
code: ApiErrorCode.FileNotReady,
});
const updated = await updateNode({
nodeId: fileId,
userId: user.id,
workspaceId: workspace.id,
updater(attributes) {
if (attributes.type !== 'file') {
throw new Error('Node is not a file');
}
attributes.status = FileStatus.Ready;
return attributes;
},
});
expect(updated).toBe(true);
const missingUploadResponse = await app.inject({
method: 'GET',
url: `/client/v1/workspaces/${workspace.id}/files/${fileId}`,
headers: buildAuthHeader(token),
});
expect(missingUploadResponse.statusCode).toBe(400);
expect(missingUploadResponse.json()).toMatchObject({
code: ApiErrorCode.FileUploadNotFound,
});
});
});

View File

@@ -0,0 +1,170 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { IdType, MutationStatus, generateId } from '@colanode/core';
import { database } from '@colanode/server/data/database';
import { jobService } from '@colanode/server/services/job-service';
import { buildTestApp } from '../helpers/app';
import {
buildAuthHeader,
buildCreateNodeMutation,
createAccount,
createDevice,
createPageNode,
createSpaceNode,
createUser,
createWorkspace,
} from '../helpers/seed';
const app = buildTestApp();
beforeAll(async () => {
await app.ready();
});
afterAll(async () => {
await app.close();
});
describe('mutation idempotency', () => {
it('returns CREATED then OK for repeated node.create', async () => {
const account = await createAccount({
email: 'mutations@example.com',
password: 'Password123!',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
const user = await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const { token } = await createDevice({ accountId: account.id });
const nodeId = generateId(IdType.Space);
const mutation = buildCreateNodeMutation({
nodeId,
attributes: {
type: 'space',
name: 'Idempotent Space',
visibility: 'private',
collaborators: {
[user.id]: 'admin',
},
},
});
const secondMutation = {
...mutation,
id: generateId(IdType.Mutation),
};
const response = await app.inject({
method: 'POST',
url: `/client/v1/workspaces/${workspace.id}/mutations`,
headers: buildAuthHeader(token),
payload: {
mutations: [mutation, secondMutation],
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { results: { status: number }[] };
expect(body.results[0]?.status).toBe(MutationStatus.CREATED);
expect(body.results[1]?.status).toBe(MutationStatus.OK);
});
});
describe('delete cascade', () => {
it('creates a tombstone and schedules cleanup on node.delete', async () => {
const account = await createAccount({
email: 'delete@example.com',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
const user = await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const { token } = await createDevice({ accountId: account.id });
const rootId = await createSpaceNode({
workspaceId: workspace.id,
userId: user.id,
});
const pageId = await createPageNode({
workspaceId: workspace.id,
userId: user.id,
parentId: rootId,
rootId,
});
const addJobSpy = vi
.spyOn(jobService, 'addJob')
.mockResolvedValue(undefined);
const deletedAt = new Date().toISOString();
const response = await app.inject({
method: 'POST',
url: `/client/v1/workspaces/${workspace.id}/mutations`,
headers: buildAuthHeader(token),
payload: {
mutations: [
{
id: generateId(IdType.Mutation),
createdAt: deletedAt,
type: 'node.delete',
data: {
nodeId: pageId,
rootId,
deletedAt,
},
},
],
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { results: { status: number }[] };
expect(body.results[0]?.status).toBe(MutationStatus.OK);
const deletedNode = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', pageId)
.executeTakeFirst();
expect(deletedNode).toBeUndefined();
const tombstone = await database
.selectFrom('node_tombstones')
.selectAll()
.where('id', '=', pageId)
.executeTakeFirst();
expect(tombstone).not.toBeNull();
expect(tombstone?.root_id).toBe(rootId);
expect(addJobSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'node.clean',
nodeId: pageId,
parentId: rootId,
workspaceId: workspace.id,
userId: user.id,
})
);
addJobSpy.mockRestore();
});
});

View File

@@ -0,0 +1,243 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
ApiErrorCode,
IdType,
UserStatus,
WorkspaceStatus,
generateId,
} from '@colanode/core';
import { database } from '@colanode/server/data/database';
import { buildTestApp } from '../helpers/app';
import {
buildAuthHeader,
buildCreateNodeMutation,
createAccount,
createDevice,
createUser,
createWorkspace,
} from '../helpers/seed';
const app = buildTestApp();
beforeAll(async () => {
await app.ready();
});
afterAll(async () => {
await app.close();
});
describe('workspace access', () => {
it('rejects users with role none', async () => {
const account = await createAccount({
email: 'no-role@example.com',
password: 'Password123!',
});
const workspace = await createWorkspace({
createdBy: account.id,
status: WorkspaceStatus.Active,
});
await createUser({
workspaceId: workspace.id,
account,
role: 'none',
});
const { token } = await createDevice({ accountId: account.id });
const response = await app.inject({
method: 'GET',
url: `/client/v1/workspaces/${workspace.id}`,
headers: buildAuthHeader(token),
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({
code: ApiErrorCode.WorkspaceNoAccess,
});
});
});
describe('workspace readonly guard', () => {
it('rejects mutations when workspace is not active (readonly)', async () => {
const account = await createAccount({
email: 'readonly@example.com',
});
const workspace = await createWorkspace({
createdBy: account.id,
status: WorkspaceStatus.Readonly,
});
const user = await createUser({
workspaceId: workspace.id,
account,
role: 'owner',
});
const { token } = await createDevice({ accountId: account.id });
const mutation = buildCreateNodeMutation({
nodeId: generateId(IdType.Space),
attributes: {
type: 'space',
name: 'Readonly Space',
visibility: 'private',
collaborators: {
[user.id]: 'admin',
},
},
});
const response = await app.inject({
method: 'POST',
url: `/client/v1/workspaces/${workspace.id}/mutations`,
headers: buildAuthHeader(token),
payload: {
mutations: [mutation],
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({
code: ApiErrorCode.WorkspaceNoAccess,
});
});
});
describe('workspace user invites', () => {
it('rejects invites from non-admin roles', async () => {
const account = await createAccount({
email: 'invite@example.com',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
await createUser({
workspaceId: workspace.id,
account,
role: 'guest',
});
const { token } = await createDevice({ accountId: account.id });
const response = await app.inject({
method: 'POST',
url: `/client/v1/workspaces/${workspace.id}/users`,
headers: buildAuthHeader(token),
payload: {
users: [
{
email: 'new-user@example.com',
role: 'collaborator',
},
],
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({
code: ApiErrorCode.UserInviteNoAccess,
});
});
});
describe('workspace role updates', () => {
it('rejects role updates from non-admin users', async () => {
const account = await createAccount({
email: 'role-update@example.com',
});
const workspace = await createWorkspace({
createdBy: account.id,
});
await createUser({
workspaceId: workspace.id,
account,
role: 'guest',
});
const targetAccount = await createAccount({
email: 'role-target@example.com',
});
const targetUser = await createUser({
workspaceId: workspace.id,
account: targetAccount,
role: 'collaborator',
});
const { token } = await createDevice({ accountId: account.id });
const response = await app.inject({
method: 'PATCH',
url: `/client/v1/workspaces/${workspace.id}/users/${targetUser.id}/role`,
headers: buildAuthHeader(token),
payload: {
role: 'admin',
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({
code: ApiErrorCode.UserUpdateNoAccess,
});
});
it('sets status to Removed when role is none', async () => {
const ownerAccount = await createAccount({
email: 'role-owner@example.com',
});
const workspace = await createWorkspace({
createdBy: ownerAccount.id,
});
await createUser({
workspaceId: workspace.id,
account: ownerAccount,
role: 'owner',
});
const memberAccount = await createAccount({
email: 'role-member@example.com',
});
const memberUser = await createUser({
workspaceId: workspace.id,
account: memberAccount,
role: 'collaborator',
});
const { token } = await createDevice({ accountId: ownerAccount.id });
const response = await app.inject({
method: 'PATCH',
url: `/client/v1/workspaces/${workspace.id}/users/${memberUser.id}/role`,
headers: buildAuthHeader(token),
payload: {
role: 'none',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { status: number; role: string };
expect(body.role).toBe('none');
expect(body.status).toBe(UserStatus.Removed);
const updatedUser = await database
.selectFrom('users')
.selectAll()
.where('id', '=', memberUser.id)
.executeTakeFirst();
expect(updatedUser?.status).toBe(UserStatus.Removed);
expect(updatedUser?.role).toBe('none');
});
});

View File

@@ -0,0 +1,66 @@
import os from 'node:os';
import path from 'node:path';
import { writeFile } from 'node:fs/promises';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer } from '@testcontainers/redis';
type TestEnvConfig = {
POSTGRES_URL: string;
REDIS_URL: string;
CONFIG: string;
NODE_ENV: string;
};
const ENV_PATH = path.join(os.tmpdir(), 'colanode-test-env.json');
const CONFIG_PATH = path.join(os.tmpdir(), 'colanode-test-config.json');
export default async function globalSetup() {
const postgres = await new PostgreSqlContainer('pgvector/pgvector:pg17')
.withDatabase('colanode_test')
.withUsername('postgres')
.withPassword('postgres')
.start();
const redis = await new RedisContainer('redis:7-alpine').start();
const testConfig = {
mode: 'standalone',
logging: { level: 'silent' },
email: { enabled: false },
jobs: {
nodeUpdatesMerge: { enabled: false },
documentUpdatesMerge: { enabled: false },
cleanup: { enabled: false },
},
storage: {
tus: { locker: { type: 'memory' }, cache: { type: 'none' } },
provider: {
type: 'file',
directory: path.join(os.tmpdir(), 'colanode-test-storage'),
},
},
};
await writeFile(CONFIG_PATH, JSON.stringify(testConfig));
const envConfig: TestEnvConfig = {
POSTGRES_URL: postgres.getConnectionUri(),
REDIS_URL: redis.getConnectionUrl(),
CONFIG: CONFIG_PATH,
NODE_ENV: 'test',
};
await writeFile(ENV_PATH, JSON.stringify(envConfig));
Object.assign(process.env, envConfig);
const { migrate, database } = await import('../src/data/database');
await migrate();
await database.destroy();
return async () => {
await redis.stop();
await postgres.stop();
};
}

View File

@@ -0,0 +1,25 @@
import fastifyWebsocket from '@fastify/websocket';
import { fastify, FastifyInstance } from 'fastify';
import {
serializerCompiler,
validatorCompiler,
} from 'fastify-type-provider-zod';
import { apiRoutes } from '@colanode/server/api';
import { clientDecorator } from '@colanode/server/api/client/plugins/client';
import { corsPlugin } from '@colanode/server/api/client/plugins/cors';
import { errorHandler } from '@colanode/server/api/client/plugins/error-handler';
export const buildTestApp = (): FastifyInstance => {
const app = fastify();
app.register(errorHandler);
app.setSerializerCompiler(serializerCompiler);
app.setValidatorCompiler(validatorCompiler);
app.register(corsPlugin);
app.register(fastifyWebsocket);
app.register(clientDecorator);
app.register(apiRoutes);
return app;
};

View File

@@ -0,0 +1,285 @@
import {
AccountStatus,
generateId,
IdType,
UserStatus,
WorkspaceStatus,
} from '@colanode/core';
import { YDoc } from '@colanode/crdt';
import { database } from '@colanode/server/data/database';
import type {
SelectAccount,
SelectDevice,
SelectUser,
SelectWorkspace,
} from '@colanode/server/data/schema';
import { generatePasswordHash } from '@colanode/server/lib/accounts';
import { createNode } from '@colanode/server/lib/nodes';
import { generateToken } from '@colanode/server/lib/tokens';
import { DeviceType } from '@colanode/server/types/devices';
import { getNodeModel, NodeAttributes } from '@colanode/core';
import { FileStatus } from '@colanode/core';
export const createAccount = async (input?: {
email?: string;
name?: string;
status?: AccountStatus;
password?: string | null;
}): Promise<SelectAccount> => {
const email = input?.email ?? `user-${generateId(IdType.Account)}@example.com`;
const name = input?.name ?? 'Test User';
const status = input?.status ?? AccountStatus.Active;
const password = input?.password ?? 'password123';
const passwordHash =
password === null ? null : await generatePasswordHash(password);
const account = await database
.insertInto('accounts')
.returningAll()
.values({
id: generateId(IdType.Account),
name,
email,
avatar: null,
password: passwordHash,
attributes: null,
created_at: new Date(),
updated_at: null,
status,
})
.executeTakeFirst();
if (!account) {
throw new Error('Failed to create account');
}
return account;
};
export const createWorkspace = async (input: {
createdBy: string;
name?: string;
status?: WorkspaceStatus;
}): Promise<SelectWorkspace> => {
const workspace = await database
.insertInto('workspaces')
.returningAll()
.values({
id: generateId(IdType.Workspace),
name: input.name ?? 'Test Workspace',
description: null,
avatar: null,
attrs: null,
created_at: new Date(),
created_by: input.createdBy,
updated_at: null,
updated_by: null,
status: input.status ?? WorkspaceStatus.Active,
max_file_size: null,
})
.executeTakeFirst();
if (!workspace) {
throw new Error('Failed to create workspace');
}
return workspace;
};
export const createUser = async (input: {
workspaceId: string;
account: SelectAccount;
role: 'owner' | 'admin' | 'collaborator' | 'guest' | 'none';
status?: UserStatus;
}): Promise<SelectUser> => {
const user = await database
.insertInto('users')
.returningAll()
.values({
id: generateId(IdType.User),
account_id: input.account.id,
workspace_id: input.workspaceId,
role: input.role,
name: input.account.name,
email: input.account.email,
avatar: input.account.avatar,
custom_name: null,
custom_avatar: null,
created_at: new Date(),
created_by: input.account.id,
status: input.status ?? UserStatus.Active,
max_file_size: '0',
storage_limit: '0',
})
.executeTakeFirst();
if (!user) {
throw new Error('Failed to create user');
}
return user;
};
export const createDevice = async (input: {
accountId: string;
}): Promise<{ device: SelectDevice; token: string }> => {
const deviceId = generateId(IdType.Device);
const { token, salt, hash } = generateToken(deviceId);
const device = await database
.insertInto('devices')
.returningAll()
.values({
id: deviceId,
account_id: input.accountId,
token_hash: hash,
token_salt: salt,
token_generated_at: new Date(),
previous_token_hash: null,
previous_token_salt: null,
type: DeviceType.Web,
version: 'test',
platform: 'test',
ip: '127.0.0.1',
created_at: new Date(),
synced_at: null,
})
.executeTakeFirst();
if (!device) {
throw new Error('Failed to create device');
}
return { device, token };
};
export const createSpaceNode = async (input: {
workspaceId: string;
userId: string;
name?: string;
}): Promise<string> => {
const spaceId = generateId(IdType.Space);
const attributes: NodeAttributes = {
type: 'space',
name: input.name ?? 'Test Space',
description: null,
avatar: null,
visibility: 'private',
collaborators: {
[input.userId]: 'admin',
},
};
const created = await createNode({
nodeId: spaceId,
rootId: spaceId,
attributes,
userId: input.userId,
workspaceId: input.workspaceId,
});
if (!created) {
throw new Error('Failed to create space node');
}
return spaceId;
};
export const createPageNode = async (input: {
workspaceId: string;
userId: string;
parentId: string;
rootId: string;
name?: string;
}): Promise<string> => {
const pageId = generateId(IdType.Page);
const attributes: NodeAttributes = {
type: 'page',
name: input.name ?? 'Test Page',
parentId: input.parentId,
};
const created = await createNode({
nodeId: pageId,
rootId: input.rootId,
attributes,
userId: input.userId,
workspaceId: input.workspaceId,
});
if (!created) {
throw new Error('Failed to create page node');
}
return pageId;
};
export const createFileNode = async (input: {
workspaceId: string;
userId: string;
parentId: string;
rootId: string;
size: number;
name?: string;
extension?: string;
}): Promise<string> => {
const fileId = generateId(IdType.File);
const attributes: NodeAttributes = {
type: 'file',
subtype: 'other',
parentId: input.parentId,
name: input.name ?? 'Test File',
originalName: input.name ?? 'test.txt',
mimeType: 'text/plain',
extension: input.extension ?? '.txt',
size: input.size,
version: '1',
status: FileStatus.Pending,
};
const created = await createNode({
nodeId: fileId,
rootId: input.rootId,
attributes,
userId: input.userId,
workspaceId: input.workspaceId,
});
if (!created) {
throw new Error('Failed to create file node');
}
return fileId;
};
export const buildCreateNodeMutation = (input: {
nodeId: string;
attributes: NodeAttributes;
createdAt?: string;
}) => {
const model = getNodeModel(input.attributes.type);
const ydoc = new YDoc();
const update = ydoc.update(model.attributesSchema, input.attributes);
if (!update) {
throw new Error('Failed to create node update');
}
const createdAt = input.createdAt ?? new Date().toISOString();
return {
id: generateId(IdType.Mutation),
createdAt,
type: 'node.create' as const,
data: {
nodeId: input.nodeId,
updateId: generateId(IdType.Update),
createdAt,
data: ydoc.getEncodedState(),
},
};
};
export const buildAuthHeader = (token: string) => {
return { authorization: `Bearer ${token}` };
};

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { compareString, getNameFromEmail } from '@colanode/server/lib/utils';
describe('utils', () => {
it('builds display names from email local parts', () => {
expect(getNameFromEmail('jane.doe@example.com')).toBe('Jane Doe');
expect(getNameFromEmail('john_doe-smith@example.com')).toBe(
'John Doe Smith'
);
});
it('compares strings with nulls and undefined', () => {
expect(compareString('a', 'a')).toBe(0);
expect(compareString(undefined, 'a')).toBe(-1);
expect(compareString('a', undefined)).toBe(1);
});
});

View File

@@ -0,0 +1,46 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll } from 'vitest';
type TestEnvConfig = {
POSTGRES_URL: string;
REDIS_URL: string;
CONFIG: string;
NODE_ENV: string;
};
const ENV_PATH = path.join(os.tmpdir(), 'colanode-test-env.json');
if (!fs.existsSync(ENV_PATH)) {
throw new Error(`Test env file not found at ${ENV_PATH}`);
}
const envConfig = JSON.parse(
fs.readFileSync(ENV_PATH, 'utf-8')
) as TestEnvConfig;
Object.assign(process.env, envConfig);
let redis: typeof import('../src/data/redis').redis | null = null;
let database: typeof import('../src/data/database').database | null = null;
beforeAll(async () => {
const redisModule = await import('../src/data/redis');
await redisModule.initRedis();
redis = redisModule.redis;
const dbModule = await import('../src/data/database');
database = dbModule.database;
});
afterAll(async () => {
if (redis) {
await redis.quit();
}
if (database) {
await database.destroy();
}
});

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { database } from '@colanode/server/data/database';
import { CollaborationSynchronizer } from '@colanode/server/synchronizers/collaborations';
import { generateId, IdType } from '@colanode/core';
const createCollaborationRow = async (input: {
workspaceId: string;
collaboratorId: string;
nodeId: string;
role: string;
}) => {
return database
.insertInto('collaborations')
.returningAll()
.values({
node_id: input.nodeId,
collaborator_id: input.collaboratorId,
workspace_id: input.workspaceId,
role: input.role,
created_at: new Date(),
created_by: input.collaboratorId,
updated_at: null,
updated_by: null,
deleted_at: null,
deleted_by: null,
})
.executeTakeFirstOrThrow();
};
describe('CollaborationSynchronizer', () => {
it('returns collaborations in revision order after the cursor', async () => {
const workspaceId = generateId(IdType.Workspace);
const collaboratorId = generateId(IdType.User);
const first = await createCollaborationRow({
workspaceId,
collaboratorId,
nodeId: generateId(IdType.Space),
role: 'admin',
});
const second = await createCollaborationRow({
workspaceId,
collaboratorId,
nodeId: generateId(IdType.Page),
role: 'editor',
});
const synchronizer = new CollaborationSynchronizer(
'sync-collabs',
{
userId: collaboratorId,
workspaceId,
accountId: generateId(IdType.Account),
deviceId: generateId(IdType.Device),
},
{ type: 'collaborations' },
'0'
);
const output = await synchronizer.fetchData();
expect(output).not.toBeNull();
const items = output?.items ?? [];
expect(items).toHaveLength(2);
const firstCursor = BigInt(items[0]!.cursor);
const secondCursor = BigInt(items[1]!.cursor);
expect(firstCursor).toBe(BigInt(first.revision));
expect(secondCursor).toBe(BigInt(second.revision));
expect(firstCursor < secondCursor).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { database } from '@colanode/server/data/database';
import { NodeUpdatesSynchronizer } from '@colanode/server/synchronizers/node-updates';
import { generateId, IdType } from '@colanode/core';
import { YDoc } from '@colanode/crdt';
const createNodeUpdate = async (input: {
nodeId: string;
rootId: string;
workspaceId: string;
createdBy: string;
}) => {
const ydoc = new YDoc();
const data = ydoc.getState();
return database
.insertInto('node_updates')
.returningAll()
.values({
id: generateId(IdType.Update),
node_id: input.nodeId,
root_id: input.rootId,
workspace_id: input.workspaceId,
data,
created_at: new Date(),
created_by: input.createdBy,
merged_updates: null,
})
.executeTakeFirstOrThrow();
};
describe('NodeUpdatesSynchronizer', () => {
it('returns updates in revision order after the cursor', async () => {
const rootId = generateId(IdType.Space);
const nodeId = generateId(IdType.Page);
const workspaceId = generateId(IdType.Workspace);
const userId = generateId(IdType.User);
const first = await createNodeUpdate({
nodeId,
rootId,
workspaceId,
createdBy: userId,
});
const second = await createNodeUpdate({
nodeId,
rootId,
workspaceId,
createdBy: userId,
});
const synchronizer = new NodeUpdatesSynchronizer(
'sync-1',
{
userId,
workspaceId,
accountId: generateId(IdType.Account),
deviceId: generateId(IdType.Device),
},
{ type: 'node.updates', rootId },
'0'
);
const output = await synchronizer.fetchData();
expect(output).not.toBeNull();
const items = output?.items ?? [];
expect(items).toHaveLength(2);
const firstCursor = BigInt(items[0]!.cursor);
const secondCursor = BigInt(items[1]!.cursor);
expect(firstCursor).toBe(BigInt(first.revision));
expect(secondCursor).toBe(BigInt(second.revision));
expect(firstCursor < secondCursor).toBe(true);
const afterCursorSync = new NodeUpdatesSynchronizer(
'sync-2',
{
userId,
workspaceId,
accountId: generateId(IdType.Account),
deviceId: generateId(IdType.Device),
},
{ type: 'node.updates', rootId },
first.revision.toString()
);
const afterOutput = await afterCursorSync.fetchData();
expect(afterOutput?.items).toHaveLength(1);
expect(afterOutput?.items[0]?.cursor).toBe(second.revision.toString());
});
});

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { database } from '@colanode/server/data/database';
import { UserSynchronizer } from '@colanode/server/synchronizers/users';
import { generateId, IdType, UserStatus } from '@colanode/core';
const createUserRow = async (input: {
workspaceId: string;
accountId: string;
role: string;
email: string;
name: string;
status: UserStatus;
}) => {
return database
.insertInto('users')
.returningAll()
.values({
id: generateId(IdType.User),
workspace_id: input.workspaceId,
account_id: input.accountId,
role: input.role,
name: input.name,
email: input.email,
avatar: null,
custom_name: null,
custom_avatar: null,
created_at: new Date(),
created_by: input.accountId,
status: input.status,
max_file_size: '0',
storage_limit: '0',
})
.executeTakeFirstOrThrow();
};
describe('UserSynchronizer', () => {
it('returns users in revision order after the cursor', async () => {
const workspaceId = generateId(IdType.Workspace);
const accountId = generateId(IdType.Account);
const userId = generateId(IdType.User);
const first = await createUserRow({
workspaceId,
accountId,
role: 'owner',
email: 'first@example.com',
name: 'First',
status: UserStatus.Active,
});
const second = await createUserRow({
workspaceId,
accountId: generateId(IdType.Account),
role: 'collaborator',
email: 'second@example.com',
name: 'Second',
status: UserStatus.Active,
});
const synchronizer = new UserSynchronizer(
'sync-users',
{
userId,
workspaceId,
accountId,
deviceId: generateId(IdType.Device),
},
{ type: 'users' },
'0'
);
const output = await synchronizer.fetchData();
expect(output).not.toBeNull();
const items = output?.items ?? [];
expect(items).toHaveLength(2);
const firstCursor = BigInt(items[0]!.cursor);
const secondCursor = BigInt(items[1]!.cursor);
expect(firstCursor).toBe(BigInt(first.revision));
expect(secondCursor).toBe(BigInt(second.revision));
expect(firstCursor < secondCursor).toBe(true);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Server Tests",
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"baseUrl": "..",
"rootDir": ".",
"types": ["vitest/globals", "node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -1,6 +1,5 @@
{
"extends": ["//"],
"globalDependencies": [".env"],
"tasks": {
"build": {
"env": ["DEMO_ENV_VAR"],

View File

@@ -0,0 +1,25 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: rootDir,
plugins: [tsconfigPaths()],
resolve: {
alias: {
'@colanode/server': path.resolve(rootDir, 'src'),
'@colanode/core': path.resolve(rootDir, '../../packages/core/src'),
'@colanode/crdt': path.resolve(rootDir, '../../packages/crdt/src'),
},
},
test: {
environment: 'node',
globalSetup: ['./test/global-setup.ts'],
setupFiles: ['./test/setup-env.ts'],
include: ['test/**/*.test.ts', 'test/**/*.spec.ts'],
},
});

View File

@@ -8,7 +8,7 @@
"start": "vite --port 4000",
"build": "vite build && tsc",
"serve": "vite preview",
"test": "vitest run"
"test": "vitest --passWithNoTests"
},
"dependencies": {
"@colanode/client": "*",
@@ -19,6 +19,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-react": "^5.1.2",
"jsdom": "^27.4.0",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0"

2133
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
"tsx": "^4.21.0",
"turbo": "^2.7.3",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
"vitest": "^4.0.17"
},
"dependencies": {
"debug": "^4.4.3",

View File

@@ -18,7 +18,7 @@
],
"scripts": {
"compile": "tsc --noEmit",
"test": "vitest",
"test": "vitest --passWithNoTests",
"lint": "eslint . --max-warnings 0",
"build": "tsc",
"coverage": "vitest run --coverage "

View File

@@ -16,7 +16,7 @@
],
"scripts": {
"compile": "tsc --noEmit",
"test": "vitest",
"test": "vitest --passWithNoTests",
"lint": "eslint src --ext .ts --max-warnings 0",
"build": "tsc",
"coverage": "vitest run --coverage "

View File

@@ -12,7 +12,7 @@
],
"scripts": {
"compile": "tsc --noEmit",
"test": "vitest",
"test": "vitest --passWithNoTests",
"lint": "eslint . --max-warnings 0",
"build": "tsc",
"coverage": "vitest run --coverage "

View File

@@ -11,7 +11,7 @@
],
"scripts": {
"compile": "tsc --noEmit",
"test": "vitest",
"test": "vitest --passWithNoTests",
"lint": "eslint . --max-warnings 0",
"build": "tsc",
"coverage": "vitest run --coverage "

View File

@@ -2,6 +2,10 @@
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env", "**/.env.*local"],
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "out/**", "assets/**"]