Merge branch 'develop' into refactor/62-remove-dead-sync-orchestration
Backend verify / commits (pull_request) Failing after 35s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped

This commit is contained in:
2026-10-15 10:24:33 +03:00
22 changed files with 342 additions and 65 deletions
+7
View File
@@ -122,6 +122,13 @@ describe('OpenAPI contract (integration)', () => {
});
it('generates the runtime contract without server startup or deployment configuration', async () => {
const schemas = createOpenApiDocument(app).components?.schemas;
expect(schemas?.StockScreenerCriteriaDto).toMatchObject({
additionalProperties: false,
});
expect(schemas?.StockScreenerItemDto).toMatchObject({
properties: { lot: { type: 'integer', minimum: 1, nullable: true } },
});
expect(
createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto,
).toMatchObject({
+1 -1
View File
@@ -18,7 +18,7 @@ writeFileSync(sentinel, 'fictional user database: must never be opened');
const before = readFileSync(sentinel);
const databases = new Set();
async function check(name, overrides = {}, signal) {
const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], {
const child = spawn(process.execPath, ['test/sqlite.cjs'], {
cwd,
env: {
...process.env,
+77
View File
@@ -385,6 +385,83 @@ describe('hourly attempt lifecycle (SQLite)', () => {
expect(next?.state).not.toBe('running');
});
describe('hourly term eligibility', () => {
const at = (h: number, m = 0) => new Date(Date.UTC(2026, 0, 1, h, m));
const seed = (
state: 'complete' | 'failed' | 'running',
start: Date,
end: Date,
processRunId = 'other-process',
) =>
prisma.tbankSyncWork.create({
data: {
id: randomUUID(),
userId: USER,
revision: REVISION,
scopeKey: 'hourly',
attemptId: randomUUID(),
processRunId,
priority: 0,
intent: {},
progress: {},
state,
createdAt: start,
updatedAt: end,
},
});
const reserve = async (when: Date) => {
jest.setSystemTime(when);
return repository.reserveHourly(USER, 'this-process', 3_600_000);
};
it.each([
['short attempt', 'complete', at(10), at(10, 5), at(10, 59), at(11)],
['failed attempt', 'failed', at(10), at(10, 5), at(10, 59), at(11)],
['walk past one term', 'complete', at(10), at(11, 10), at(11, 59), at(12)],
['walk past two terms', 'complete', at(10), at(12, 10), at(12, 59), at(13)],
['walk ending on a term', 'complete', at(10), at(11), at(10, 59), at(11)],
] as const)('%s', async (_name, state, start, end, early, due) => {
await seed(state, start, end);
expect(await reserve(early)).toBeNull();
const reserved = await reserve(due);
expect(reserved?.work.state).toBe('running');
expect(reserved?.work.createdAt).toEqual(due);
});
it('starts a new revision at once', async () => {
await seed('complete', at(10), at(10, 5));
await prisma.tbankConnection.deleteMany({ where: { userId: USER } });
await prisma.tbankConnection.create({
data: {
userId: USER,
revision: 'next-revision',
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: at(10, 10),
},
});
expect((await reserve(at(10, 10)))?.work.revision).toBe('next-revision');
});
it('closes a dead process attempt without early retry and runs one catch-up after downtime', async () => {
await seed('running', at(10), at(10, 1), 'dead-process');
expect(await reserve(at(10, 30))).toBeNull();
expect((await hourlyRow())?.errorCode).toBe('INTERRUPTED');
expect(await reserve(at(10, 59))).toBeNull();
expect((await reserve(at(14, 30)))?.work.createdAt).toEqual(at(14, 30));
expect(await reserve(at(14, 31))).toBeNull();
expect(
await prisma.tbankSyncWork.count({
where: { userId: USER, scopeKey: 'hourly' },
}),
).toBe(1);
});
it('does not reserve while this process still runs the attempt', async () => {
await seed('running', at(10), at(10, 1), 'this-process');
expect(await reserve(at(12, 30))).toBeNull();
});
});
it('does not overlap a running attempt with a second walk', async () => {
operations.request.mockImplementation(async () => {
const row = await child('running', null, 'operations');
+3 -1
View File
@@ -266,7 +266,9 @@ describe('local income completeness and exact money (SQLite)', () => {
await repo.fail(fence, false);
const after = await read();
expect(after.data).toEqual(before.data);
expect(after.meta.lastAttempt.state).toBe('failed');
// Without an hourly walk the durable attempt is absent; the failed
// source work stays a technical observation, not the hourly attempt.
expect(after.meta.lastAttempt.state).toBe('none');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('retains current-month observed completeness and does not renew source timestamps', async () => {
+69
View File
@@ -15,6 +15,7 @@ import {
} from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto';
import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
import { PrismaService } from '../src/prisma/prisma.service';
@@ -325,6 +326,74 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
});
it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => {
await put().expect(200);
await seedMembership();
await requestPortfolio('account');
await settled();
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const startedAt = new Date('2026-10-10T10:00:00.000Z');
const finishedAt = new Date('2026-10-10T10:05:00.000Z');
await prisma.tbankSyncWork.create({
data: {
id: 'hourly-attempt',
userId: 'owner',
revision: connection.revision,
scopeKey: 'hourly',
intent: {},
state: 'complete',
attemptId: 'hourly-attempt',
processRunId: 'test',
priority: 0,
progress: {
result: 'partial',
sections: [
{
section: 'portfolio',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
{
section: 'operations',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
],
},
createdAt: startedAt,
updatedAt: finishedAt,
},
});
const accounts = await get('brokerage-accounts').expect(200);
const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta;
expect(accountsMeta.lastAttempt.state).toBe('partial');
expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString());
expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString());
expect(accountsMeta.lastAttempt.problems).toEqual([
{
section: 'portfolio',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
]);
const income = await get(
'brokerage-accounts/account/income-analytics?sync=observe',
).expect(200);
const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta;
expect(incomeMeta.lastAttempt.state).toBe('partial');
expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString());
expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString());
expect(incomeMeta.lastAttempt.problems).toEqual([
{
section: 'operations',
accountId: 'account',
errorCode: 'TBANK_UNAVAILABLE',
},
]);
});
it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => {
await put().expect(200);
const connection = await prisma.tbankConnection.update({