diff --git a/src/models/MiningJob.spec.ts b/src/models/MiningJob.spec.ts new file mode 100644 index 0000000..f7d392f --- /dev/null +++ b/src/models/MiningJob.spec.ts @@ -0,0 +1,85 @@ +import * as bitcoinjs from 'bitcoinjs-lib'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; + +import { MockRecording1 } from '../../test/models/MockRecording1'; +import { StratumV1JobsService } from '../services/stratum-v1-jobs.service'; +import { MiningJob } from './MiningJob'; + +describe('MiningJob', () => { + let jobTemplate; + let job: MiningJob; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000)); + + const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); + const bitcoinRpcService = { + newBlockTemplate$: blockTemplate$.asObservable(), + miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height } + }; + jest.spyOn(console, 'log').mockImplementation(() => undefined); + + const jobsService = new StratumV1JobsService(bitcoinRpcService as any); + jobTemplate = await firstValueFrom(jobsService.newMiningJob$); + job = new MiningJob( + bitcoinjs.networks.testnet, + '1', + [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }], + jobTemplate + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('should split coinbase around 12 bytes of extranonce space', () => { + const notify = JSON.parse(job.response(jobTemplate)); + const coinbasePart1 = notify.params[2]; + const coinbasePart2 = notify.params[3]; + const extraNonce1 = '57a6f098'; + const extraNonce2 = 'c708000000000000'; + const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`); + + expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12); + expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`); + expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true); + }); + + it('should update block nonce, timestamp, version mask, and coinbase script', () => { + const extraNonce1 = '57a6f098'; + const extraNonce2 = 'c708000000000000'; + const timestamp = parseInt(MockRecording1.TIME, 16); + const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot); + + const updatedBlock = job.copyAndUpdateBlock( + jobTemplate, + parseInt('00002000', 16), + parseInt('ed460d91', 16), + extraNonce1, + extraNonce2, + timestamp + ); + + expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16)); + expect(updatedBlock.timestamp).toBe(timestamp); + expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16)); + expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true); + expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false); + }); + + it('should leave block version unchanged without a version mask', () => { + const updatedBlock = job.copyAndUpdateBlock( + jobTemplate, + 0, + parseInt('ed460d91', 16), + '57a6f098', + 'c708000000000000', + parseInt(MockRecording1.TIME, 16) + ); + + expect(updatedBlock.version).toBe(jobTemplate.block.version); + }); +}); diff --git a/src/models/StratumV1Client.spec.ts b/src/models/StratumV1Client.spec.ts index 5368689..94d6428 100644 --- a/src/models/StratumV1Client.spec.ts +++ b/src/models/StratumV1Client.spec.ts @@ -8,6 +8,7 @@ import { DataSource } from 'typeorm'; import { MockRecording1 } from '../../test/models/MockRecording1'; import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; +import { BlocksEntity } from '../ORM/blocks/blocks.entity'; import { BlocksService } from '../ORM/blocks/blocks.service'; import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity'; import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module'; @@ -51,6 +52,8 @@ describe('StratumV1Client', () => { let socketEmitter: (...args: any[]) => void; const emitMessage = (message: string) => socketEmitter(Buffer.from(`${message}\n`)); + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; let newBlockEmitter: BehaviorSubject = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); @@ -65,7 +68,8 @@ describe('StratumV1Client', () => { synchronize: true, autoLoadEntities: true, cache: true, - logging: false + logging: false, + entities: [ClientEntity, ClientStatisticsEntity, BlocksEntity] }), ClientModule, ClientStatisticsModule, @@ -97,6 +101,8 @@ describe('StratumV1Client', () => { jest.useFakeTimers({ advanceTimers: true }) jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000)); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); clientService = moduleRef.get(ClientService); @@ -104,6 +110,7 @@ describe('StratumV1Client', () => { await dataSource.getRepository(ClientStatisticsEntity).delete({}); await dataSource.getRepository(ClientEntity).delete({}); + await dataSource.getRepository(BlocksEntity).delete({}); clientStatisticsService = moduleRef.get(ClientStatisticsService); @@ -132,6 +139,12 @@ describe('StratumV1Client', () => { socket.end = jest.fn(); const addressSettings = moduleRef.get(AddressSettingsService); + notificationService = { + notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) + } as any; + blocksService = { + save: jest.fn().mockResolvedValue(undefined) + } as any; client = new StratumV1Client( @@ -153,6 +166,8 @@ describe('StratumV1Client', () => { afterEach(async () => { client.destroy(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); jest.useRealTimers(); }) @@ -273,6 +288,118 @@ describe('StratumV1Client', () => { }); + it('should reject duplicate submissions', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + + emitMessage(MockRecording1.MINING_SUBMIT); + await new Promise((r) => setTimeout(r, 100)); + emitMessage(MockRecording1.MINING_SUBMIT); + await new Promise((r) => setTimeout(r, 100)); + + expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`); + }); + + it('should reject submissions for unknown jobs', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + + emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "ff", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`); + await new Promise((r) => setTimeout(r, 100)); + + expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`); + }); + + it('should reject submissions when the job template has expired', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + stratumV1JobsService.blocks = {}; + + emitMessage(MockRecording1.MINING_SUBMIT); + await new Promise((r) => setTimeout(r, 100)); + + expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`); + }); + + it('should reject low difficulty shares', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + + emitMessage(MockRecording1.MINING_SUBMIT); + await new Promise((r) => setTimeout(r, 100)); + + expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`); + }); + + it('should reject submissions with short extranonce2 values', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + + emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`); + await new Promise((r) => setTimeout(r, 100)); + + expect((client as any).write).lastCalledWith(expect.stringContaining(`"error":[20,"Mining Submit validation error"`)); + }); + + it('should close socket when a submit arrives before stratum is initialized', async () => { + const endSpy = jest.spyOn(socket, 'end'); + + emitMessage(MockRecording1.MINING_SUBMIT); + await new Promise((r) => setTimeout(r, 100)); + + expect(endSpy).toHaveBeenCalled(); + }); + + it('should submit and persist found blocks', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: Number.MAX_SAFE_INTEGER, + submissionHash: 'block-share' + }); + const addressSettings = moduleRef.get(AddressSettingsService); + jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + + emitMessage(MockRecording1.MINING_SUBMIT); + jest.useRealTimers(); + await new Promise((r) => setTimeout(r, 1000)); + + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String)); + expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({ + height: MockRecording1.BLOCK_TEMPLATE.height, + minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + worker: 'bitaxe3', + sessionId: MockRecording1.EXTRA_NONCE, + blockData: expect.any(String) + })); + expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled(); + expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled(); + expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`); + }); + }); diff --git a/src/models/StratumV1ClientStatistics.spec.ts b/src/models/StratumV1ClientStatistics.spec.ts new file mode 100644 index 0000000..33cf0ec --- /dev/null +++ b/src/models/StratumV1ClientStatistics.spec.ts @@ -0,0 +1,107 @@ +import { StratumV1ClientStatistics } from './StratumV1ClientStatistics'; + +describe('StratumV1ClientStatistics', () => { + const client = { + id: 'client-id', + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'bitaxe3', + sessionId: '57a6f098' + } as any; + + let clientStatisticsService: { + insert: jest.Mock, + updateBulkAsync: jest.Mock + }; + let statistics: StratumV1ClientStatistics; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-06T12:00:00Z')); + clientStatisticsService = { + insert: jest.fn().mockResolvedValue(undefined), + updateBulkAsync: jest.fn().mockResolvedValue(undefined) + }; + statistics = new StratumV1ClientStatistics(clientStatisticsService as any); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should insert the first share bucket', async () => { + await statistics.addShares(client, 64); + + expect(clientStatisticsService.insert).toHaveBeenCalledWith({ + time: new Date('2026-05-06T12:00:00Z').getTime(), + clientId: client.id, + shares: 64, + acceptedCount: 1, + address: client.address, + clientName: client.clientName, + sessionId: client.sessionId + }); + }); + + it('should update the current share bucket for additional shares', async () => { + await statistics.addShares(client, 64); + await statistics.addShares(client, 32); + + expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({ + time: new Date('2026-05-06T12:00:00Z').getTime(), + clientId: client.id, + shares: 96, + acceptedCount: 2 + }); + }); + + it('should create a new share bucket when the time slot changes', async () => { + await statistics.addShares(client, 64); + jest.setSystemTime(new Date('2026-05-06T12:10:00Z')); + + await statistics.addShares(client, 32); + + expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({ + time: new Date('2026-05-06T12:00:00Z').getTime(), + clientId: client.id, + shares: 64, + acceptedCount: 1 + }); + expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({ + time: new Date('2026-05-06T12:10:00Z').getTime(), + clientId: client.id, + shares: 32, + acceptedCount: 1, + address: client.address, + clientName: client.clientName, + sessionId: client.sessionId + }); + }); + + it('should not suggest a difficulty change before enough time or shares have passed', () => { + expect(statistics.getSuggestedDifficulty(64)).toBeNull(); + }); + + it('should lower difficulty when a miner has not submitted shares for several minutes', () => { + jest.setSystemTime(new Date('2026-05-06T12:06:00Z')); + + expect(statistics.getSuggestedDifficulty(64)).toBe(8); + }); + + it('should increase difficulty for rapid submissions', async () => { + for (let i = 0; i < 5; i++) { + jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 1000))); + await statistics.addShares(client, 64); + } + + expect(statistics.getSuggestedDifficulty(64)).toBe(2048); + }); + + it('should decrease difficulty for slow submissions', async () => { + for (let i = 0; i < 5; i++) { + jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 150000))); + await statistics.addShares(client, 64); + } + + expect(statistics.getSuggestedDifficulty(128)).toBe(16); + }); +}); diff --git a/src/models/stratum-messages/AuthorizationMessage.spec.ts b/src/models/stratum-messages/AuthorizationMessage.spec.ts new file mode 100644 index 0000000..9798f2f --- /dev/null +++ b/src/models/stratum-messages/AuthorizationMessage.spec.ts @@ -0,0 +1,40 @@ +import { plainToInstance } from 'class-transformer'; + +import { AuthorizationMessage } from './AuthorizationMessage'; + +describe('AuthorizationMessage', () => { + it('should parse address, worker, and starting difficulty', () => { + const message = plainToInstance( + AuthorizationMessage, + JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=2048"]}') + ); + + expect(message.address).toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'); + expect(message.worker).toBe('worker1'); + expect(message.password).toBe('x,d=2048'); + expect(message.startingDiff).toBe(2048); + }); + + it('should default worker name when one is not provided', () => { + const message = plainToInstance( + AuthorizationMessage, + JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4","x"]}') + ); + + expect(message.worker).toBe('worker'); + expect(message.startingDiff).toBeNull(); + }); + + it('should build successful authorization responses', () => { + const message = plainToInstance( + AuthorizationMessage, + JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x"]}') + ); + + expect(message.response()).toEqual({ + id: 3, + error: null, + result: true + }); + }); +}); diff --git a/src/models/stratum-messages/MiningSubmitMessage.spec.ts b/src/models/stratum-messages/MiningSubmitMessage.spec.ts index ff90914..01fa3e1 100644 --- a/src/models/stratum-messages/MiningSubmitMessage.spec.ts +++ b/src/models/stratum-messages/MiningSubmitMessage.spec.ts @@ -1,4 +1,5 @@ import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; import { MiningSubmitMessage } from './MiningSubmitMessage'; @@ -29,6 +30,34 @@ describe('MiningSubmitMessage', () => { expect(message.nonce).toEqual('2402812d'); expect(message.versionMask).toEqual('00006000'); }); + + it('should validate 8-byte extranonce2 submissions', async () => { + const errors = await validate(message); + + expect(errors).toEqual([]); + }); + + it('should reject short extranonce2 submissions', async () => { + const shortMessage = plainToInstance( + MiningSubmitMessage, + JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'), + ); + + const errors = await validate(shortMessage); + + expect(errors.some(error => error.property === 'extraNonce2')).toBe(true); + }); + + it('should reject long extranonce2 submissions', async () => { + const longMessage = plainToInstance( + MiningSubmitMessage, + JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "990200000000000000", "64b1f10f", "2402812d", "00006000"]}'), + ); + + const errors = await validate(longMessage); + + expect(errors.some(error => error.property === 'extraNonce2')).toBe(true); + }); }); diff --git a/src/models/stratum-messages/SubscriptionMessage.spec.ts b/src/models/stratum-messages/SubscriptionMessage.spec.ts new file mode 100644 index 0000000..ae24d95 --- /dev/null +++ b/src/models/stratum-messages/SubscriptionMessage.spec.ts @@ -0,0 +1,45 @@ +import { plainToInstance } from 'class-transformer'; + +import { SubscriptionMessage } from './SubscriptionMessage'; + +describe('SubscriptionMessage', () => { + it('should parse and refine known user agents', () => { + const bosminer = plainToInstance( + SubscriptionMessage, + JSON.parse('{"id":1,"method":"mining.subscribe","params":["bosminer/23.08"]}') + ); + const cpuminer = plainToInstance( + SubscriptionMessage, + JSON.parse('{"id":1,"method":"mining.subscribe","params":["cpuminer-opt/1.0"]}') + ); + + expect(bosminer.userAgent).toBe('Braiins OS'); + expect(cpuminer.userAgent).toBe('cpuminer'); + }); + + it('should default missing user agents to unknown', () => { + const message = plainToInstance( + SubscriptionMessage, + JSON.parse('{"id":1,"method":"mining.subscribe","params":[]}') + ); + + expect(message.userAgent).toBe('unknown'); + }); + + it('should respond with extranonce2 size of 8 bytes', () => { + const message = plainToInstance( + SubscriptionMessage, + JSON.parse('{"id":1,"method":"mining.subscribe","params":["bitaxe v2.2"]}') + ); + + expect(message.response('57a6f098')).toEqual({ + id: 1, + error: null, + result: [ + [['mining.notify', '57a6f098']], + '57a6f098', + 8 + ] + }); + }); +}); diff --git a/src/services/stratum-v1-jobs.service.spec.ts b/src/services/stratum-v1-jobs.service.spec.ts new file mode 100644 index 0000000..d35b935 --- /dev/null +++ b/src/services/stratum-v1-jobs.service.spec.ts @@ -0,0 +1,91 @@ +import { BehaviorSubject, firstValueFrom, skip } from 'rxjs'; + +import { MockRecording1 } from '../../test/models/MockRecording1'; +import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; +import { StratumV1JobsService } from './stratum-v1-jobs.service'; + +describe('StratumV1JobsService', () => { + let blockTemplate$: BehaviorSubject; + let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } }; + let service: StratumV1JobsService; + let consoleLogSpy: jest.SpyInstance; + + const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({ + ...MockRecording1.BLOCK_TEMPLATE, + height + }); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000)); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + blockTemplate$ = new BehaviorSubject(createTemplate()); + bitcoinRpcService = { + newBlockTemplate$: blockTemplate$.asObservable(), + miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height } + }; + service = new StratumV1JobsService(bitcoinRpcService as any); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('should create job templates from block templates', async () => { + const jobTemplate = await firstValueFrom(service.newMiningJob$); + + expect(jobTemplate.blockData).toEqual(expect.objectContaining({ + id: '1', + height: MockRecording1.BLOCK_TEMPLATE.height, + clearJobs: true, + coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue + })); + expect(jobTemplate.merkle_branch.length).toBeGreaterThan(0); + expect(jobTemplate.block.transactions[0].ins[0].witness[0]).toHaveLength(32); + expect(service.getJobTemplateById('1')).toBe(jobTemplate); + }); + + it('should clear stale jobs when the block height changes', async () => { + await firstValueFrom(service.newMiningJob$); + service.addJob({ jobId: 'old-job', creation: Date.now() } as any); + + bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height + 1; + const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1))); + blockTemplate$.next(createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1)); + const jobTemplate = await nextTemplate; + + expect(jobTemplate.blockData.clearJobs).toBe(true); + expect(service.getJobById('old-job')).toBeUndefined(); + expect(Object.keys(service.blocks)).toEqual([jobTemplate.blockData.id]); + }); + + it('should expire old jobs and templates when the block height is unchanged', async () => { + await firstValueFrom(service.newMiningJob$); + const oldCreation = Date.now() - (1000 * 60 * 6); + service.jobs['old-job'] = { jobId: 'old-job', creation: oldCreation } as any; + service.blocks['old-template'] = { + blockData: { creation: oldCreation } + } as any; + + bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height; + const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1))); + blockTemplate$.next(createTemplate()); + const jobTemplate = await nextTemplate; + + expect(jobTemplate.blockData.clearJobs).toBe(false); + expect(service.getJobById('old-job')).toBeUndefined(); + expect(service.getJobTemplateById('old-template')).toBeUndefined(); + expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate); + }); + + it('should increment job ids when jobs are added', () => { + expect(service.getNextId()).toBe('1'); + + service.addJob({ jobId: '1', creation: Date.now() } as any); + + expect(service.getNextId()).toBe('2'); + expect(service.getJobById('1')).toEqual(expect.objectContaining({ jobId: '1' })); + }); +});