mirror of
https://github.com/colanode/colanode.git
synced 2026-02-24 03:49:48 +01:00
test(server): add vitest harness and initial integration tests (#302)
This commit is contained in:
66
apps/server/README.md
Normal file
66
apps/server/README.md
Normal 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`.
|
||||
@@ -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",
|
||||
|
||||
105
apps/server/test/api/account.test.ts
Normal file
105
apps/server/test/api/account.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
apps/server/test/api/auth.test.ts
Normal file
118
apps/server/test/api/auth.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
192
apps/server/test/api/files.test.ts
Normal file
192
apps/server/test/api/files.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
170
apps/server/test/api/mutations.test.ts
Normal file
170
apps/server/test/api/mutations.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
243
apps/server/test/api/workspace.test.ts
Normal file
243
apps/server/test/api/workspace.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
apps/server/test/global-setup.ts
Normal file
66
apps/server/test/global-setup.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
25
apps/server/test/helpers/app.ts
Normal file
25
apps/server/test/helpers/app.ts
Normal 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;
|
||||
};
|
||||
285
apps/server/test/helpers/seed.ts
Normal file
285
apps/server/test/helpers/seed.ts
Normal 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}` };
|
||||
};
|
||||
18
apps/server/test/lib/utils.test.ts
Normal file
18
apps/server/test/lib/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
apps/server/test/setup-env.ts
Normal file
46
apps/server/test/setup-env.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
75
apps/server/test/synchronizers/collaborations.test.ts
Normal file
75
apps/server/test/synchronizers/collaborations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
apps/server/test/synchronizers/node-updates.test.ts
Normal file
95
apps/server/test/synchronizers/node-updates.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
86
apps/server/test/synchronizers/users.test.ts
Normal file
86
apps/server/test/synchronizers/users.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
apps/server/test/tsconfig.json
Normal file
12
apps/server/test/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"globalDependencies": [".env"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["DEMO_ENV_VAR"],
|
||||
|
||||
25
apps/server/vitest.config.ts
Normal file
25
apps/server/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
@@ -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
2133
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"compile": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test": "vitest --passWithNoTests",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"build": "tsc",
|
||||
"coverage": "vitest run --coverage "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"compile": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test": "vitest --passWithNoTests",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"build": "tsc",
|
||||
"coverage": "vitest run --coverage "
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"compile": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test": "vitest --passWithNoTests",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"build": "tsc",
|
||||
"coverage": "vitest run --coverage "
|
||||
|
||||
@@ -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/**"]
|
||||
|
||||
Reference in New Issue
Block a user