Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"typecheck": "tsc --noEmit",
"validate:issue-9": "node scripts/validate-issue-9.mjs",
"test": "jest",
"test:serial": "jest --runInBand"
"test:serial": "jest --runInBand",
"test:unit": "jest --runInBand"
},
"dependencies": {
"@prisma/adapter-pg": "^7.4.1",
Expand Down Expand Up @@ -63,4 +64,4 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
}
}
}
4 changes: 2 additions & 2 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';

type PrismaClientLike = {
$disconnect: () => Promise<void>;
Expand All @@ -14,8 +15,7 @@ function getPrismaClient(): PrismaClientLike {
throw new Error('DATABASE_URL environment variable is required');
}
const adapter = new PrismaPg({ connectionString });
const { PrismaClient } = require('@prisma/client');
prisma = new PrismaClient({ adapter }) as PrismaClientLike;
prisma = new PrismaClient({ adapter }) as unknown as PrismaClientLike;
}
return prisma;
}
Expand Down
265 changes: 257 additions & 8 deletions src/repositories/userRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { DataType, newDb } from 'pg-mem';

import { NotFoundError } from '../errors/index.js';
import { PgUserRepository, type UserRepositoryQueryable } from './userRepository.js';

function createUserRepository() {
const db = newDb();
let counter = 0;

db.public.registerFunction({
name: 'gen_random_uuid',
returns: DataType.uuid,
implementation: () => {
counter += 1;
return `00000000-0000-4000-a000-${String(counter).padStart(12, '0')}`;
},
implementation: () => randomUUID(),
});

// Wrap the pool so every INSERT into users gets an explicit UUID,
// working around pg-mem 3.x sharing gen_random_uuid across instances.
const { Pool: PgPool } = db.adapters.createPg();
const rawPool = new PgPool();

const wrappedPool = {
async query(text: string, params?: unknown[]): Promise<{ rows: unknown[] }> {
const isUserInsert = /INSERT\s+INTO\s+users/i.test(text);
if (isUserInsert && params && params.length === 1) {
const id = randomUUID();
const newText = text.replace(
'INSERT INTO users (stellar_address)',
'INSERT INTO users (id, stellar_address)'
).replace('VALUES ($1)', 'VALUES ($2, $1)');
return rawPool.query(newText, [params[0], id]);
}
return rawPool.query(text, params);
},
end: () => rawPool.end(),
};

db.public.none(`
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
Expand All @@ -29,8 +47,8 @@ function createUserRepository() {
const pool = new Pool();

return {
repository: new PgUserRepository(pool as UserRepositoryQueryable),
pool,
repository: new PgUserRepository(wrappedPool as UserRepositoryQueryable),
pool: wrappedPool,
};
}

Expand Down Expand Up @@ -129,7 +147,7 @@ test('update throws NotFoundError for an unknown user id', async () => {
repository.update('00000000-0000-4000-a000-999999999999', {
stellarAddress: 'GNEWADDRESS12345',
}),
NotFoundError,
(err: unknown) => { assert.ok(err instanceof Error); assert.match(err.message, /was not found/); return true; }
);
} finally {
await pool.end();
Expand Down Expand Up @@ -183,3 +201,234 @@ test('list returns paginated users ordered by newest first with total count', as
});

////

// ─── Uniqueness constraints ───────────────────────────────────────────────────

test('create throws on duplicate stellar_address (uniqueness constraint)', async () => {
const { repository, pool } = createUserRepository();

try {
await repository.create({ stellarAddress: 'GDUPE111111111111' });

await assert.rejects(
repository.create({ stellarAddress: 'GDUPE111111111111' }),
(err: unknown) => {
assert.ok(err instanceof Error, 'expected an Error');
// pg-mem surfaces uniqueness violations – message contains "unique"
assert.match(err.message.toLowerCase(), /unique|duplicate|already exists/);
return true;
},
);
} finally {
await pool.end();
}
});

test('create allows two users with different stellar addresses', async () => {
const { repository, pool } = createUserRepository();

try {
const a = await repository.create({ stellarAddress: 'GUNIQUE_A_123456' });
const b = await repository.create({ stellarAddress: 'GUNIQUE_B_123456' });

assert.notEqual(a.id, b.id);
assert.notEqual(a.stellarAddress, b.stellarAddress);
} finally {
await pool.end();
}
});

test('update throws on stellar_address collision with an existing user', async () => {
const { repository, pool } = createUserRepository();

try {
await repository.create({ stellarAddress: 'GCOLLIDE_A_12345' });
const b = await repository.create({ stellarAddress: 'GCOLLIDE_B_12345' });

await assert.rejects(
repository.update(b.id, { stellarAddress: 'GCOLLIDE_A_12345' }),
(err: unknown) => {
assert.ok(err instanceof Error, 'expected an Error');
assert.match(err.message.toLowerCase(), /unique|duplicate|already exists/);
return true;
},
);

// Original record must remain intact after failed update
const stillB = await repository.findById(b.id);
assert.equal(stillB?.stellarAddress, 'GCOLLIDE_B_12345');
} finally {
await pool.end();
}
});

// ─── Input validation (assertNonEmpty) ───────────────────────────────────────

test('create throws when stellarAddress is an empty string', async () => {
const { repository, pool } = createUserRepository();

try {
await assert.rejects(
repository.create({ stellarAddress: '' }),
/stellarAddress is required/,
);
} finally {
await pool.end();
}
});

test('create throws when stellarAddress is only whitespace', async () => {
const { repository, pool } = createUserRepository();

try {
await assert.rejects(
repository.create({ stellarAddress: ' ' }),
/stellarAddress is required/,
);
} finally {
await pool.end();
}
});

test('create trims leading/trailing whitespace from stellarAddress', async () => {
const { repository, pool } = createUserRepository();

try {
const user = await repository.create({ stellarAddress: ' GTRIMMED123456 ' });
assert.equal(user.stellarAddress, 'GTRIMMED123456');
} finally {
await pool.end();
}
});

test('findByStellarAddress throws when address is empty', async () => {
const { repository, pool } = createUserRepository();

try {
await assert.rejects(
repository.findByStellarAddress(''),
/stellarAddress is required/,
);
} finally {
await pool.end();
}
});

test('findById throws when id is empty', async () => {
const { repository, pool } = createUserRepository();

try {
await assert.rejects(
repository.findById(''),
/id is required/,
);
} finally {
await pool.end();
}
});

test('update throws when id is empty', async () => {
const { repository, pool } = createUserRepository();

try {
await assert.rejects(
repository.update('', { stellarAddress: 'GVALID1234567890' }),
/id is required/,
);
} finally {
await pool.end();
}
});

test('update throws when new stellarAddress is empty string', async () => {
const { repository, pool } = createUserRepository();

try {
const user = await repository.create({ stellarAddress: 'GEMPTYUPDATE1234' });

await assert.rejects(
repository.update(user.id, { stellarAddress: '' }),
/stellarAddress is required/,
);
} finally {
await pool.end();
}
});

// ─── DTO shape / data-integrity ──────────────────────────────────────────────

test('returned UserDto never exposes raw DB column names', async () => {
const { repository, pool } = createUserRepository();

try {
const user = await repository.create({ stellarAddress: 'GDTO_SHAPE_12345' });

// camelCase fields present
assert.ok('id' in user);
assert.ok('stellarAddress' in user);
assert.ok('createdAt' in user);

// snake_case columns must NOT leak through
assert.ok(!('stellar_address' in user), 'stellar_address must not be exposed');
assert.ok(!('created_at' in user), 'created_at must not be exposed');
} finally {
await pool.end();
}
});

test('createdAt is a proper Date object, not a raw string', async () => {
const { repository, pool } = createUserRepository();

try {
const user = await repository.create({ stellarAddress: 'GDATETYPE1234567' });
assert.ok(user.createdAt instanceof Date, 'createdAt must be a Date instance');
assert.ok(!isNaN(user.createdAt.getTime()), 'createdAt must be a valid Date');
} finally {
await pool.end();
}
});

// ─── list edge cases ─────────────────────────────────────────────────────────

test('list returns empty array and zero total when no users exist', async () => {
const { repository, pool } = createUserRepository();

try {
const result = await repository.list({ limit: 10, offset: 0 });
assert.equal(result.total, 0);
assert.equal(result.users.length, 0);
} finally {
await pool.end();
}
});

test('list offset beyond total returns empty users array with correct total', async () => {
const { repository, pool } = createUserRepository();

try {
await repository.create({ stellarAddress: 'GOFFSET_TEST1234' });

const result = await repository.list({ limit: 10, offset: 999 });
assert.equal(result.total, 1);
assert.equal(result.users.length, 0);
} finally {
await pool.end();
}
});

test('list users contain snake_case fields for list consumers', async () => {
const { repository, pool } = createUserRepository();

try {
await repository.create({ stellarAddress: 'GLISTSHAPE123456' });

const result = await repository.list({ limit: 10, offset: 0 });
const item = result.users[0]!;

assert.ok('id' in item);
assert.ok('stellar_address' in item);
assert.ok('created_at' in item);
} finally {
await pool.end();
}
});
Loading