fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit.
This commit is contained in:
@@ -22,7 +22,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
}) as never,
|
||||
);
|
||||
const upstream = { getCandles: jest.fn() };
|
||||
const access = new TbankAccessService(prisma, tokens, upstream as never);
|
||||
const access = new TbankAccessService(prisma, tokens);
|
||||
const instrument = {
|
||||
uid: 'uid',
|
||||
currency: 'rub',
|
||||
@@ -46,10 +46,10 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
},
|
||||
]),
|
||||
};
|
||||
const createService = () =>
|
||||
const createService = (selectedSources = sources) =>
|
||||
new CandleService(
|
||||
repository,
|
||||
sources as never,
|
||||
selectedSources as never,
|
||||
access,
|
||||
upstream as never,
|
||||
{ event: jest.fn() } as never,
|
||||
@@ -112,6 +112,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
});
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => {
|
||||
const attempt = await reserve();
|
||||
await repository.stage(
|
||||
@@ -302,6 +303,149 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
|
||||
expect(read.meta.sync.state).toBe('failed');
|
||||
expect(read.data?.candles).toEqual([]);
|
||||
});
|
||||
it.each([
|
||||
['owner', 'before loading', false],
|
||||
['observer', 'before loading', false],
|
||||
['owner', 'after ordinary publication', false],
|
||||
['observer', 'after ordinary publication', false],
|
||||
['observer', 'after ordinary publication', true],
|
||||
] as const)(
|
||||
'honors force from %s joining %s (later failure=%s)',
|
||||
async (userId, phase, laterFailure) => {
|
||||
if (userId !== owner.userId) {
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
create: {
|
||||
id: userId,
|
||||
email: `${userId}@example.invalid`,
|
||||
passwordHash: 'synthetic',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
await prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId,
|
||||
revision: 'revision',
|
||||
encryptedToken: tokens.encrypt('synthetic'),
|
||||
connectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
const history = {
|
||||
...instrument,
|
||||
first_1dayCandleDate: {
|
||||
seconds: String(Date.parse('2024-01-01Z') / 1000),
|
||||
},
|
||||
};
|
||||
const covered = {
|
||||
from: new Date('2024-01-01Z'),
|
||||
to: new Date('2025-01-01Z'),
|
||||
};
|
||||
const old = { ...candle, time: '2024-01-02T00:00:00.000Z' };
|
||||
const initial = await reserve([covered]);
|
||||
await repository.stage(initial, covered, [old], new Date('2025-01-02Z'));
|
||||
await repository.publish(initial, { uid: 'uid', ranges: [covered] });
|
||||
let entered = () => {};
|
||||
let release = () => {};
|
||||
const started = new Promise<void>((resolve) => (entered = resolve));
|
||||
const blocked = new Promise<void>((resolve) => (release = resolve));
|
||||
const selectedSources = {
|
||||
...sources,
|
||||
ensureDetail: jest.fn().mockResolvedValue(history),
|
||||
};
|
||||
if (phase === 'before loading')
|
||||
selectedSources.ensureDetail.mockImplementationOnce(async () => {
|
||||
entered();
|
||||
await blocked;
|
||||
return history;
|
||||
});
|
||||
else {
|
||||
const publish = repository.publish.bind(repository);
|
||||
jest
|
||||
.spyOn(repository, 'publish')
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
const result = await publish(...args);
|
||||
entered();
|
||||
await blocked;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
const forcedAt = new Date('2026-02-03T13:00:00Z');
|
||||
const forcedStates: string[] = [];
|
||||
const fullRange = { from: covered.from, to: new Date('2026-02-04Z') };
|
||||
let beforeForced:
|
||||
Awaited<ReturnType<CandleRepository['read']>> | undefined;
|
||||
upstream.getCandles.mockImplementation(
|
||||
async (_token, _uid, _from, to: Date) => {
|
||||
if (laterFailure && forcedStates.length)
|
||||
throw new Error('Forced reconciliation second window unavailable');
|
||||
if (to.valueOf() === forcedAt.valueOf()) {
|
||||
beforeForced = await repository.read('uid', fullRange);
|
||||
const work = await repository.work(
|
||||
userId,
|
||||
'revision',
|
||||
'candles:uid:all',
|
||||
);
|
||||
forcedStates.push(work?.state ?? 'missing');
|
||||
}
|
||||
return [
|
||||
{
|
||||
time: { seconds: String(Date.parse(old.time) / 1000) },
|
||||
close: { units: '200', nano: 1 },
|
||||
isComplete: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
const service = createService(selectedSources);
|
||||
const input = {
|
||||
uid: 'uid',
|
||||
instrumentType: 'share' as const,
|
||||
period: 'all' as const,
|
||||
};
|
||||
const ordinaryId = await service.request(
|
||||
'owner',
|
||||
'revision',
|
||||
input,
|
||||
new Date('2026-02-02T12:00:00Z'),
|
||||
false,
|
||||
2,
|
||||
);
|
||||
await started;
|
||||
const forcedId = await service.request(
|
||||
userId,
|
||||
'revision',
|
||||
input,
|
||||
forcedAt,
|
||||
true,
|
||||
0,
|
||||
);
|
||||
release();
|
||||
await settled();
|
||||
|
||||
if (laterFailure) {
|
||||
expect(beforeForced).toBeDefined();
|
||||
expect(await repository.read('uid', fullRange)).toEqual(beforeForced);
|
||||
} else
|
||||
expect((await repository.read('uid', covered)).data).toEqual([
|
||||
{ ...old, close: '200.000000001' },
|
||||
]);
|
||||
expect(forcedStates).toEqual(['running']);
|
||||
expect(
|
||||
await repository.work(userId, 'revision', 'candles:uid:all'),
|
||||
).toMatchObject({
|
||||
id: forcedId,
|
||||
state: laterFailure ? 'failed' : 'complete',
|
||||
priority: 0,
|
||||
intent: {
|
||||
force: true,
|
||||
now: forcedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
if (userId === owner.userId) expect(forcedId).toBe(ordinaryId);
|
||||
else expect(forcedId).not.toBe(ordinaryId);
|
||||
},
|
||||
);
|
||||
it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => {
|
||||
const now = new Date();
|
||||
now.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
Reference in New Issue
Block a user