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
112 changes: 112 additions & 0 deletions src/modules/ownership/ownership-update-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { updateOwnership } from './ownership.service';
import { logger } from '../../utils/logger.utils';
import { prisma } from '../../utils/prisma.utils';

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
keyOwnership: {
findFirst: jest.fn(),
upsert: jest.fn(),
},
},
}));

jest.mock('../../utils/logger.utils', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
HTTP_STATUS: {},
}));

const mockPrisma = prisma as jest.Mocked<typeof prisma>;

function mockOwnership(balance: number) {
return {
id: 'own-1',
ownerAddress: 'GTEST1234GTEST1234GTEST1234GTEST1234GTEST1234GTEST12345',
creatorId: 'creator-1',
balance: BigInt(balance),
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
};
}

describe('updateOwnership — structured debug log', () => {
const ownerAddress = 'GTEST1234GTEST1234GTEST1234GTEST1234GTEST1234GTEST12345';
const creatorId = 'creator-1';

beforeEach(() => {
jest.clearAllMocks();
});

it('emits debug log with all six required fields after successful update', async () => {
(mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(mockOwnership(10));
(mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(15));

await updateOwnership(ownerAddress, creatorId, 5, {
event_type: 'buy',
ledger_sequence: 42,
});

expect(logger.debug).toHaveBeenCalledTimes(1);
const [logFields, _msg] = (logger.debug as jest.Mock).mock.calls[0];

expect(logFields).toMatchObject({
creator_id: creatorId,
previous_balance: 10,
new_balance: 15,
event_type: 'buy',
ledger_sequence: 42,
});
expect(typeof logFields.wallet_address).toBe('string');
expect(logFields.wallet_address).not.toBe(ownerAddress); // masked
expect(logFields.wallet_address).toContain('GTES'); // first 4
expect(logFields.wallet_address).toContain('2345'); // last 4
});

it('masks wallet address to first 4 and last 4 characters', async () => {
(mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null);
(mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(5));

await updateOwnership(ownerAddress, creatorId, 5);

const [logFields] = (logger.debug as jest.Mock).mock.calls[0];
// masked address should not be the full address
expect(logFields.wallet_address.length).toBeLessThan(ownerAddress.length);
expect(logFields.wallet_address.startsWith(ownerAddress.slice(0, 4))).toBe(true);
expect(logFields.wallet_address.endsWith(ownerAddress.slice(-4))).toBe(true);
});

it('sets previous_balance to 0 when record is new (create path)', async () => {
(mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null);
(mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(10));

await updateOwnership(ownerAddress, creatorId, 10, { event_type: 'buy' });

const [logFields] = (logger.debug as jest.Mock).mock.calls[0];
expect(logFields.previous_balance).toBe(0);
expect(logFields.new_balance).toBe(10);
});

it('correctly logs sell event type', async () => {
(mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(mockOwnership(20));
(mockPrisma.keyOwnership.upsert as jest.Mock).mockResolvedValue(mockOwnership(15));

await updateOwnership(ownerAddress, creatorId, -5, { event_type: 'sell', ledger_sequence: 100 });

const [logFields] = (logger.debug as jest.Mock).mock.calls[0];
expect(logFields.event_type).toBe('sell');
expect(logFields.ledger_sequence).toBe(100);
});

it('does not emit log when upsert throws', async () => {
(mockPrisma.keyOwnership.findFirst as jest.Mock).mockResolvedValue(null);
(mockPrisma.keyOwnership.upsert as jest.Mock).mockRejectedValue(new Error('DB error'));

await expect(updateOwnership(ownerAddress, creatorId, 5)).rejects.toThrow('DB error');
expect(logger.debug).not.toHaveBeenCalled();
});
});
33 changes: 31 additions & 2 deletions src/modules/ownership/ownership.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { prisma } from '../../utils/prisma.utils';
import { logger } from '../../utils/logger.utils';
import { OwnershipQueryType } from './ownership.schemas';

type KeyOwnership = NonNullable<Awaited<ReturnType<typeof prisma.keyOwnership.findFirst>>>;

function maskAddress(address: string): string {
if (address.length <= 8) return address;
return `${address.slice(0, 4)}…${address.slice(-4)}`;
}

export async function fetchOwnership(
query: OwnershipQueryType
): Promise<KeyOwnership[]> {
Expand All @@ -18,12 +24,24 @@ export async function fetchOwnership(
});
}

export interface OwnershipUpdateContext {
event_type?: 'buy' | 'sell';
ledger_sequence?: number;
}

export async function updateOwnership(
ownerAddress: string,
creatorId: string,
balanceChange: number
balanceChange: number,
ctx: OwnershipUpdateContext = {},
): Promise<KeyOwnership> {
return prisma.keyOwnership.upsert({
const existing = await prisma.keyOwnership.findFirst({
where: { ownerAddress, creatorId },
select: { balance: true },
});
const previousBalance = existing ? Number(existing.balance) : 0;

const result = await prisma.keyOwnership.upsert({
where: {
ownerAddress_creatorId: {
ownerAddress,
Expand All @@ -39,4 +57,15 @@ export async function updateOwnership(
balance: balanceChange,
},
});

logger.debug({
creator_id: creatorId,
wallet_address: maskAddress(ownerAddress),
previous_balance: previousBalance,
new_balance: Number(result.balance),
event_type: ctx.event_type,
ledger_sequence: ctx.ledger_sequence,
}, 'Ownership read model updated');

return result;
}
Loading