547 lines
15 KiB
TypeScript
547 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
createWorkspace,
|
|
deleteWorkspaceMember,
|
|
deleteWorkspaceIfEmpty,
|
|
ensureDefaultWorkspaceForUser,
|
|
ensurePersonalWorkspaceForUser,
|
|
findAlternateWorkspaceForUser,
|
|
getPlatformAdminSummary,
|
|
listOwnedWorkspacesByOwnerEmail,
|
|
updateWorkspace,
|
|
updateWorkspaceMemberRole,
|
|
} from './workspaceRepository.js';
|
|
import { mockDb } from '../test/mockDb.js';
|
|
import type { UserRow } from '../types.js';
|
|
|
|
const user: UserRow = {
|
|
id: 'user-1',
|
|
email: 'owner@example.com',
|
|
password_hash: null,
|
|
name: 'Owner',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
};
|
|
|
|
test('ensurePersonalWorkspaceForUser returns an existing workspace without creating one', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [{ workspace_id: 42 }],
|
|
});
|
|
|
|
const workspaceId = await ensurePersonalWorkspaceForUser(user);
|
|
|
|
assert.equal(workspaceId, 42);
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].text, /FROM workspace_members/);
|
|
});
|
|
|
|
test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claiming the legacy seed flock', async () => {
|
|
const { calls } = mockDb(
|
|
{
|
|
rowCount: 0,
|
|
rows: [],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [{ next_id: 43 }],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [],
|
|
},
|
|
);
|
|
|
|
const workspaceId = await ensurePersonalWorkspaceForUser(user);
|
|
|
|
assert.equal(workspaceId, 43);
|
|
assert.equal(calls.length, 4);
|
|
assert.match(calls[1].text, /SELECT COALESCE\(MAX\(id\), 0\) \+ 1 AS next_id FROM workspaces/);
|
|
assert.match(calls[2].text, /INSERT INTO workspaces/);
|
|
assert.match(calls[3].text, /INSERT INTO workspace_members/);
|
|
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
|
|
});
|
|
|
|
test('ensureDefaultWorkspaceForUser reuses an existing rescue workspace without creating a household flock', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [{ workspace_id: 84 }],
|
|
});
|
|
|
|
const workspaceId = await ensureDefaultWorkspaceForUser(user);
|
|
|
|
assert.equal(workspaceId, 84);
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].text, /FROM workspace_members/);
|
|
assert.doesNotMatch(calls[0].text, /workspaces\.workspace_type = 'standard'/);
|
|
});
|
|
|
|
test('ensureDefaultWorkspaceForUser creates a household flock when the user has no workspace', async () => {
|
|
const { calls } = mockDb(
|
|
{
|
|
rowCount: 0,
|
|
rows: [],
|
|
},
|
|
{
|
|
rowCount: 0,
|
|
rows: [],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [{ next_id: 43 }],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [],
|
|
},
|
|
);
|
|
|
|
const workspaceId = await ensureDefaultWorkspaceForUser(user);
|
|
|
|
assert.equal(workspaceId, 43);
|
|
assert.equal(calls.length, 5);
|
|
assert.match(calls[0].text, /FROM workspace_members/);
|
|
assert.match(calls[1].text, /workspaces\.workspace_type = 'standard'/);
|
|
assert.match(calls[3].text, /INSERT INTO workspaces/);
|
|
});
|
|
|
|
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
|
|
const { calls } = mockDb(
|
|
{ rowCount: 1, rows: [] },
|
|
{ rowCount: 1, rows: [] },
|
|
{
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 9,
|
|
name: 'My Rescue',
|
|
workspace_type: 'rescue',
|
|
billing_email: 'billing@example.com',
|
|
billing_plan: 'rescue_free',
|
|
billing_interval: 'monthly',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
updated_at: '2026-04-14T00:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
);
|
|
|
|
const workspace = await createWorkspace({
|
|
id: 9,
|
|
name: 'My Rescue',
|
|
workspaceType: 'rescue',
|
|
billingEmail: 'billing@example.com',
|
|
billingPlan: 'rescue_free',
|
|
billingInterval: 'monthly',
|
|
owner: user,
|
|
});
|
|
|
|
assert.equal(workspace?.id, 9);
|
|
assert.equal(calls.length, 3);
|
|
assert.match(calls[0].text, /INSERT INTO workspaces/);
|
|
assert.match(calls[1].text, /INSERT INTO workspace_members/);
|
|
assert.match(calls[2].text, /SELECT id, name, workspace_type/);
|
|
});
|
|
|
|
test('updateWorkspace converts an existing household flock to rescue without inserting a new flock', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 42,
|
|
name: 'Converted Rescue',
|
|
workspace_type: 'rescue',
|
|
billing_email: 'billing@example.com',
|
|
billing_plan: 'rescue_free',
|
|
billing_interval: 'monthly',
|
|
subscription_status: 'active',
|
|
rescue_verification_status: 'pending',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
updated_at: '2026-04-15T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const workspace = await updateWorkspace({
|
|
workspaceId: 42,
|
|
name: 'Converted Rescue',
|
|
workspaceType: 'rescue',
|
|
billingEmail: 'billing@example.com',
|
|
billingPlan: 'rescue_free',
|
|
billingInterval: 'monthly',
|
|
});
|
|
|
|
assert.equal(workspace?.id, 42);
|
|
assert.equal(workspace?.workspace_type, 'rescue');
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].text, /UPDATE workspaces/);
|
|
assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/);
|
|
assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free', 'monthly']);
|
|
});
|
|
|
|
test('deleteWorkspaceIfEmpty blocks deletion when birds are still assigned', async () => {
|
|
const { calls } = mockDb(
|
|
{
|
|
rowCount: 1,
|
|
rows: [{ count: '2' }],
|
|
},
|
|
);
|
|
|
|
const result = await deleteWorkspaceIfEmpty(42);
|
|
|
|
assert.deepEqual(result, { deleted: false, reason: 'birds_present' });
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].text, /FROM birds/);
|
|
});
|
|
|
|
test('deleteWorkspaceIfEmpty deletes an empty workspace', async () => {
|
|
const { calls } = mockDb(
|
|
{
|
|
rowCount: 1,
|
|
rows: [{ count: '0' }],
|
|
},
|
|
{
|
|
rowCount: 1,
|
|
rows: [{ id: 42 }],
|
|
},
|
|
);
|
|
|
|
const result = await deleteWorkspaceIfEmpty(42);
|
|
|
|
assert.deepEqual(result, { deleted: true });
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[1].text, /DELETE FROM workspaces/);
|
|
});
|
|
|
|
test('findAlternateWorkspaceForUser returns another workspace when available', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [{ workspace_id: 84 }],
|
|
});
|
|
|
|
const workspaceId = await findAlternateWorkspaceForUser('user-1', 42);
|
|
|
|
assert.equal(workspaceId, 84);
|
|
assert.deepEqual(calls[0].params, ['user-1', 42]);
|
|
});
|
|
|
|
test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 84,
|
|
name: 'Receiving Flock',
|
|
workspace_type: 'standard',
|
|
billing_email: 'receiver@example.com',
|
|
billing_plan: 'household_basic',
|
|
subscription_status: 'active',
|
|
rescue_verification_status: 'not_required',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
updated_at: '2026-04-14T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const workspaces = await listOwnedWorkspacesByOwnerEmail('Receiver@Example.com', 42);
|
|
|
|
assert.equal(workspaces[0]?.id, 84);
|
|
assert.deepEqual(calls[0].params, ['Receiver@Example.com', 42]);
|
|
assert.match(calls[0].text, /workspace_members\.role = 'owner'/);
|
|
assert.match(calls[0].text, /accepted_at IS NOT NULL/);
|
|
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 'member-1',
|
|
workspace_id: 42,
|
|
user_id: 'user-2',
|
|
invite_email: 'helper@example.com',
|
|
name: 'Helper',
|
|
role: 'viewer',
|
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'member-1',
|
|
workspaceId: 42,
|
|
role: 'viewer',
|
|
requesterMemberId: 'owner-member',
|
|
requesterIsBillingOwner: false,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member?.role, 'viewer');
|
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
|
|
assert.match(calls[0].text, /UPDATE workspace_members/);
|
|
assert.match(calls[0].text, /role <> 'owner'/);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
|
|
mockDb({
|
|
rowCount: 0,
|
|
rows: [],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'owner-member',
|
|
workspaceId: 42,
|
|
role: 'viewer',
|
|
requesterMemberId: 'owner-member',
|
|
requesterIsBillingOwner: false,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member, null);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 'other-owner',
|
|
workspace_id: 42,
|
|
user_id: 'user-2',
|
|
invite_email: 'other@example.com',
|
|
name: 'Other Owner',
|
|
role: 'assistant',
|
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'other-owner',
|
|
workspaceId: 42,
|
|
role: 'assistant',
|
|
requesterMemberId: 'billing-owner',
|
|
requesterIsBillingOwner: true,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member?.role, 'assistant');
|
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
|
|
assert.match(calls[0].text, /id <> \$5/);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
|
|
mockDb({
|
|
rowCount: 0,
|
|
rows: [],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'billing-owner',
|
|
workspaceId: 42,
|
|
role: 'assistant',
|
|
requesterMemberId: 'billing-owner',
|
|
requesterIsBillingOwner: true,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member, null);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 'other-owner',
|
|
workspace_id: 42,
|
|
user_id: 'user-2',
|
|
invite_email: 'other@example.com',
|
|
name: 'Other Owner',
|
|
role: 'assistant',
|
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'other-owner',
|
|
workspaceId: 42,
|
|
role: 'assistant',
|
|
requesterMemberId: 'non-billing-owner',
|
|
requesterIsBillingOwner: false,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member?.role, 'assistant');
|
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
|
|
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
|
|
mockDb({
|
|
rowCount: 0,
|
|
rows: [],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'billing-owner',
|
|
workspaceId: 42,
|
|
role: 'assistant',
|
|
requesterMemberId: 'non-billing-owner',
|
|
requesterIsBillingOwner: false,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member, null);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
id: 'member-1',
|
|
workspace_id: 42,
|
|
user_id: 'user-2',
|
|
invite_email: 'helper@example.com',
|
|
name: 'Helper',
|
|
role: 'owner',
|
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
created_at: '2026-04-14T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'member-1',
|
|
workspaceId: 42,
|
|
role: 'owner',
|
|
requesterMemberId: 'billing-owner',
|
|
requesterIsBillingOwner: true,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member?.role, 'owner');
|
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
|
|
assert.match(calls[0].text, /\$3 <> 'owner'/);
|
|
});
|
|
|
|
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
|
|
mockDb({
|
|
rowCount: 0,
|
|
rows: [],
|
|
});
|
|
|
|
const member = await updateWorkspaceMemberRole({
|
|
memberId: 'member-1',
|
|
workspaceId: 42,
|
|
role: 'owner',
|
|
requesterMemberId: 'non-billing-owner',
|
|
requesterIsBillingOwner: false,
|
|
requesterRole: 'owner',
|
|
billingEmail: 'billing@example.com',
|
|
});
|
|
|
|
assert.equal(member, null);
|
|
});
|
|
|
|
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [{ id: 'member-1' }],
|
|
});
|
|
|
|
const deleted = await deleteWorkspaceMember({
|
|
memberId: 'member-1',
|
|
workspaceId: 42,
|
|
requesterMemberId: 'owner-member',
|
|
requesterIsBillingOwner: false,
|
|
});
|
|
|
|
assert.equal(deleted, true);
|
|
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
|
|
assert.match(calls[0].text, /role <> 'owner'/);
|
|
});
|
|
|
|
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [{ id: 'other-owner' }],
|
|
});
|
|
|
|
const deleted = await deleteWorkspaceMember({
|
|
memberId: 'other-owner',
|
|
workspaceId: 42,
|
|
requesterMemberId: 'billing-owner',
|
|
requesterIsBillingOwner: true,
|
|
});
|
|
|
|
assert.equal(deleted, true);
|
|
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
|
|
assert.match(calls[0].text, /id <> \$4/);
|
|
});
|
|
|
|
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
|
|
mockDb({
|
|
rowCount: 0,
|
|
rows: [],
|
|
});
|
|
|
|
const deleted = await deleteWorkspaceMember({
|
|
memberId: 'billing-owner',
|
|
workspaceId: 42,
|
|
requesterMemberId: 'billing-owner',
|
|
requesterIsBillingOwner: true,
|
|
});
|
|
|
|
assert.equal(deleted, false);
|
|
});
|
|
|
|
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
|
const { calls } = mockDb({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
total_birds: 8,
|
|
memorialized_birds: 3,
|
|
total_users: 4,
|
|
total_workspaces: 2,
|
|
rescue_workspaces: 1,
|
|
rescue_birds: 5,
|
|
pending_rescues: 1,
|
|
daily_users: 2,
|
|
},
|
|
],
|
|
});
|
|
|
|
const summary = await getPlatformAdminSummary();
|
|
|
|
assert.equal(summary?.memorialized_birds, 3);
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].text, /memorialized_birds/);
|
|
assert.match(calls[0].text, /memorialized_at IS NOT NULL/);
|
|
assert.match(calls[0].text, /rescue_birds/);
|
|
assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/);
|
|
});
|