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
1 change: 1 addition & 0 deletions src/modules/indexer/indexer-pipeline.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export async function processTradeEvents(events: IndexerChainEvent[]): Promise<v
creatorId,
price: BigInt(price),
tradeAt: new Date(tradeAt),
ledger: Number(ledger),
});
});
}
139 changes: 139 additions & 0 deletions src/modules/indexer/price-snapshot-debug-log.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// src/modules/indexer/price-snapshot-debug-log.unit.test.ts
// Unit tests for #636 — debug-level log emitted after each successful
// price snapshot write, with creator_id, new_price, previous_price,
// ledger and ingested_at fields.
//
// Uses jest mocks — no database required.

import { upsertPriceSnapshot } from './price-snapshot.service';
import { prisma } from '../../utils/prisma.utils';
import { logger } from '../../utils/logger.utils';

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
creatorPriceSnapshot: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
},
}));

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

const mockPrisma = prisma as unknown as {
creatorPriceSnapshot: {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};

const mockLogger = logger as unknown as {
debug: jest.Mock;
error: jest.Mock;
};

const CREATOR_ID = 'creator-debug-log-1';

describe('#636 price snapshot write debug log', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('emits a debug log with all five fields after the first (create) snapshot write', async () => {
mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue(null);
mockPrisma.creatorPriceSnapshot.create.mockResolvedValue({});

const tradeAt = new Date('2026-01-01T00:00:00Z');
await upsertPriceSnapshot({
creatorId: CREATOR_ID,
price: BigInt(1_000_000),
tradeAt,
ledger: 5000,
});

expect(mockPrisma.creatorPriceSnapshot.create).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledTimes(1);

const [fields] = mockLogger.debug.mock.calls[0];
expect(fields).toMatchObject({
creator_id: CREATOR_ID,
new_price: '1000000',
previous_price: null,
ledger: 5000,
});
expect(fields.ingested_at).toEqual(expect.any(String));
expect(() => new Date(fields.ingested_at).toISOString()).not.toThrow();
});

it('sets previous_price to null on the first snapshot for a creator', async () => {
mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue(null);
mockPrisma.creatorPriceSnapshot.create.mockResolvedValue({});

await upsertPriceSnapshot({
creatorId: CREATOR_ID,
price: BigInt(2_000_000),
tradeAt: new Date('2026-01-01T00:00:00Z'),
ledger: 5001,
});

const [fields] = mockLogger.debug.mock.calls[0];
expect(fields.previous_price).toBeNull();
});

it('emits a debug log with the previous price on a subsequent (update) write', async () => {
mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue({
creatorId: CREATOR_ID,
currentPrice: BigInt(1_000_000),
price24hAgo: BigInt(1_000_000),
lastTradeAt: new Date('2025-12-01T00:00:00Z'),
});
mockPrisma.creatorPriceSnapshot.update.mockResolvedValue({});

await upsertPriceSnapshot({
creatorId: CREATOR_ID,
price: BigInt(3_000_000),
tradeAt: new Date('2026-01-02T00:00:00Z'),
ledger: 5002,
});

expect(mockPrisma.creatorPriceSnapshot.update).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledTimes(1);

const [fields] = mockLogger.debug.mock.calls[0];
expect(fields).toMatchObject({
creator_id: CREATOR_ID,
new_price: '3000000',
previous_price: '1000000',
ledger: 5002,
});
expect(fields.ingested_at).toEqual(expect.any(String));
});

it('does not emit the debug log when the write fails', async () => {
mockPrisma.creatorPriceSnapshot.findUnique.mockResolvedValue(null);
mockPrisma.creatorPriceSnapshot.create.mockRejectedValue(
new Error('db write failed')
);

await expect(
upsertPriceSnapshot({
creatorId: CREATOR_ID,
price: BigInt(1_000_000),
tradeAt: new Date('2026-01-01T00:00:00Z'),
ledger: 5003,
})
).rejects.toThrow('db write failed');

expect(mockLogger.debug).not.toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalled();
});
});
12 changes: 7 additions & 5 deletions src/modules/indexer/price-snapshot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface TradeEventPayload {
price: bigint;
/** ISO timestamp of the trade */
tradeAt: Date;
/** Ledger sequence number the trade was included in */
ledger?: number;
}

/**
Expand All @@ -25,7 +27,7 @@ export interface TradeEventPayload {
export async function upsertPriceSnapshot(
event: TradeEventPayload
): Promise<void> {
const { creatorId, price, tradeAt } = event;
const { creatorId, price, tradeAt, ledger } = event;

try {
const existing = await prisma.creatorPriceSnapshot.findUnique({
Expand All @@ -47,8 +49,8 @@ export async function upsertPriceSnapshot(
creator_id: creatorId,
new_price: price.toString(),
previous_price: null,
ledger_sequence: null,
written_at: tradeAt.toISOString(),
ledger: ledger ?? null,
ingested_at: new Date().toISOString(),
},
'price-snapshot: written (first trade)'
);
Expand Down Expand Up @@ -89,8 +91,8 @@ export async function upsertPriceSnapshot(
creator_id: creatorId,
new_price: price.toString(),
previous_price: existing.currentPrice.toString(),
ledger_sequence: null,
written_at: tradeAt.toISOString(),
ledger: ledger ?? null,
ingested_at: new Date().toISOString(),
},
'price-snapshot: written'
);
Expand Down
24 changes: 24 additions & 0 deletions src/modules/wallet/__tests__/wallet.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ describe('isValidStellarAddress', () => {
it('returns false for a random non-address string', () => {
expect(isValidStellarAddress('not-a-stellar-address')).toBe(false);
});

// Adjacent invalid formats seen in real API submissions (#638)

it('returns false for an otherwise-valid address with one lowercase character', () => {
const addr = 'G' + 'a' + 'A'.repeat(54);
expect(isValidStellarAddress(addr)).toBe(false);
});

it('returns false for an address starting with G that contains a 0 (not valid base32)', () => {
const addr = 'G' + '0' + 'A'.repeat(54);
expect(isValidStellarAddress(addr)).toBe(false);
});

it('returns false for a valid address with a leading space', () => {
expect(isValidStellarAddress(' ' + VALID_ADDRESS)).toBe(false);
});

it('returns false for a valid address with a trailing newline', () => {
expect(isValidStellarAddress(VALID_ADDRESS + '\n')).toBe(false);
});

it('returns true for the existing valid address (regression guard)', () => {
expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true);
});
});

describe('StellarAddressSchema', () => {
Expand Down
Loading
Loading