|
1 | 1 | import { IsNull, type DataSource } from 'typeorm'; |
2 | 2 | import createOrGetConnection from '../../src/db'; |
| 3 | +import { ChannelDigest } from '../../src/entity/ChannelDigest'; |
3 | 4 | import { ChannelHighlightDefinition } from '../../src/entity/ChannelHighlightDefinition'; |
4 | 5 | import { ChannelHighlightRun } from '../../src/entity/ChannelHighlightRun'; |
| 6 | +import { AGENTS_DIGEST_SOURCE } from '../../src/entity/Source'; |
5 | 7 | import { |
6 | 8 | PostHighlight, |
7 | 9 | PostHighlightSignificance, |
@@ -123,19 +125,20 @@ describe('generateChannelHighlight worker', () => { |
123 | 125 | await deleteKeysByPattern('channel-highlight:*'); |
124 | 126 | await con.getRepository(ChannelHighlightRun).clear(); |
125 | 127 | await con.getRepository(ChannelHighlightDefinition).clear(); |
| 128 | + await con.getRepository(ChannelDigest).clear(); |
126 | 129 | await con.getRepository(PostHighlight).clear(); |
127 | 130 | await con.getRepository(PostRelation).clear(); |
128 | 131 | await con |
129 | 132 | .createQueryBuilder() |
130 | 133 | .delete() |
131 | 134 | .from('post') |
132 | 135 | .where('"sourceId" IN (:...sourceIds)', { |
133 | | - sourceIds: ['content-source', 'secondary-source'], |
| 136 | + sourceIds: ['content-source', 'secondary-source', AGENTS_DIGEST_SOURCE], |
134 | 137 | }) |
135 | 138 | .execute(); |
136 | 139 | await con |
137 | 140 | .getRepository(Source) |
138 | | - .delete(['content-source', 'secondary-source']); |
| 141 | + .delete(['content-source', 'secondary-source', AGENTS_DIGEST_SOURCE]); |
139 | 142 | }); |
140 | 143 |
|
141 | 144 | it('should be registered', () => { |
@@ -379,6 +382,174 @@ describe('generateChannelHighlight worker', () => { |
379 | 382 | expect(retiredHighlight?.retiredAt).toBeInstanceOf(Date); |
380 | 383 | }); |
381 | 384 |
|
| 385 | + it('should exclude retired highlights from candidates and keep them retired', async () => { |
| 386 | + const now = new Date('2026-03-03T11:45:00.000Z'); |
| 387 | + await con.getRepository(ChannelHighlightDefinition).save({ |
| 388 | + channel: 'vibes', |
| 389 | + mode: 'publish', |
| 390 | + candidateHorizonHours: 72, |
| 391 | + maxItems: 3, |
| 392 | + }); |
| 393 | + await saveArticle({ |
| 394 | + id: 'retired-1', |
| 395 | + title: 'Previously highlighted story', |
| 396 | + createdAt: new Date('2026-03-03T11:15:00.000Z'), |
| 397 | + }); |
| 398 | + await saveArticle({ |
| 399 | + id: 'fresh-1', |
| 400 | + title: 'Fresh candidate', |
| 401 | + createdAt: new Date('2026-03-03T11:20:00.000Z'), |
| 402 | + }); |
| 403 | + await con.getRepository(PostHighlight).save({ |
| 404 | + channel: 'vibes', |
| 405 | + postId: 'retired-1', |
| 406 | + highlightedAt: new Date('2026-03-03T11:00:00.000Z'), |
| 407 | + headline: 'Previously highlighted headline', |
| 408 | + significance: PostHighlightSignificance.Major, |
| 409 | + reason: 'previous run', |
| 410 | + retiredAt: new Date('2026-03-03T11:10:00.000Z'), |
| 411 | + }); |
| 412 | + |
| 413 | + const evaluatorSpy = jest |
| 414 | + .spyOn(evaluator, 'evaluateChannelHighlights') |
| 415 | + .mockResolvedValue({ |
| 416 | + items: [ |
| 417 | + { |
| 418 | + postId: 'fresh-1', |
| 419 | + headline: 'Fresh headline', |
| 420 | + significanceLabel: 'breaking', |
| 421 | + reason: 'test', |
| 422 | + }, |
| 423 | + ], |
| 424 | + }); |
| 425 | + |
| 426 | + await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>( |
| 427 | + worker, |
| 428 | + { |
| 429 | + channel: 'vibes', |
| 430 | + scheduledAt: now.toISOString(), |
| 431 | + }, |
| 432 | + ); |
| 433 | + |
| 434 | + expect(evaluatorSpy).toHaveBeenCalledTimes(1); |
| 435 | + expect(evaluatorSpy.mock.calls[0][0].newCandidates).toEqual([ |
| 436 | + expect.objectContaining({ |
| 437 | + postId: 'fresh-1', |
| 438 | + title: 'Fresh candidate', |
| 439 | + }), |
| 440 | + ]); |
| 441 | + |
| 442 | + const liveHighlights = await con.getRepository(PostHighlight).find({ |
| 443 | + where: { channel: 'vibes', retiredAt: IsNull() }, |
| 444 | + }); |
| 445 | + expect(liveHighlights).toEqual([ |
| 446 | + expect.objectContaining({ |
| 447 | + postId: 'fresh-1', |
| 448 | + headline: 'Fresh headline', |
| 449 | + }), |
| 450 | + ]); |
| 451 | + |
| 452 | + const retiredHighlights = await con.getRepository(PostHighlight).find({ |
| 453 | + where: { channel: 'vibes', postId: 'retired-1' }, |
| 454 | + }); |
| 455 | + expect(retiredHighlights).toHaveLength(1); |
| 456 | + expect(retiredHighlights[0].retiredAt).toBeInstanceOf(Date); |
| 457 | + }); |
| 458 | + |
| 459 | + it('should ignore posts from channel digest sources for highlights', async () => { |
| 460 | + const now = new Date('2026-03-03T11:50:00.000Z'); |
| 461 | + await con |
| 462 | + .getRepository(Source) |
| 463 | + .save( |
| 464 | + createSource( |
| 465 | + AGENTS_DIGEST_SOURCE, |
| 466 | + 'Agents Digest', |
| 467 | + 'https://daily.dev/agents.png', |
| 468 | + ), |
| 469 | + ); |
| 470 | + await con.getRepository(ChannelDigest).save({ |
| 471 | + key: 'agentic', |
| 472 | + sourceId: AGENTS_DIGEST_SOURCE, |
| 473 | + channel: 'vibes', |
| 474 | + targetAudience: 'Digest readers', |
| 475 | + frequency: 'daily', |
| 476 | + includeSentiment: false, |
| 477 | + sentimentGroupIds: [], |
| 478 | + enabled: true, |
| 479 | + }); |
| 480 | + await con.getRepository(ChannelHighlightDefinition).save({ |
| 481 | + channel: 'vibes', |
| 482 | + mode: 'publish', |
| 483 | + candidateHorizonHours: 72, |
| 484 | + maxItems: 3, |
| 485 | + }); |
| 486 | + await saveArticle({ |
| 487 | + id: 'digest-post', |
| 488 | + sourceId: AGENTS_DIGEST_SOURCE, |
| 489 | + title: 'Digest source post', |
| 490 | + createdAt: new Date('2026-03-03T11:20:00.000Z'), |
| 491 | + }); |
| 492 | + await saveArticle({ |
| 493 | + id: 'fresh-1', |
| 494 | + title: 'Fresh candidate', |
| 495 | + createdAt: new Date('2026-03-03T11:25:00.000Z'), |
| 496 | + }); |
| 497 | + await con.getRepository(PostHighlight).save({ |
| 498 | + channel: 'vibes', |
| 499 | + postId: 'digest-post', |
| 500 | + highlightedAt: new Date('2026-03-03T11:10:00.000Z'), |
| 501 | + headline: 'Digest highlight', |
| 502 | + significance: PostHighlightSignificance.Major, |
| 503 | + reason: 'existing', |
| 504 | + }); |
| 505 | + |
| 506 | + const evaluatorSpy = jest |
| 507 | + .spyOn(evaluator, 'evaluateChannelHighlights') |
| 508 | + .mockResolvedValue({ |
| 509 | + items: [ |
| 510 | + { |
| 511 | + postId: 'fresh-1', |
| 512 | + headline: 'Fresh headline', |
| 513 | + significanceLabel: 'major', |
| 514 | + reason: 'test', |
| 515 | + }, |
| 516 | + ], |
| 517 | + }); |
| 518 | + |
| 519 | + await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>( |
| 520 | + worker, |
| 521 | + { |
| 522 | + channel: 'vibes', |
| 523 | + scheduledAt: now.toISOString(), |
| 524 | + }, |
| 525 | + ); |
| 526 | + |
| 527 | + expect(evaluatorSpy).toHaveBeenCalledTimes(1); |
| 528 | + expect(evaluatorSpy.mock.calls[0][0].newCandidates).toEqual([ |
| 529 | + expect.objectContaining({ |
| 530 | + postId: 'fresh-1', |
| 531 | + }), |
| 532 | + ]); |
| 533 | + |
| 534 | + const liveHighlights = await con.getRepository(PostHighlight).find({ |
| 535 | + where: { channel: 'vibes', retiredAt: IsNull() }, |
| 536 | + }); |
| 537 | + expect(liveHighlights).toEqual([ |
| 538 | + expect.objectContaining({ |
| 539 | + postId: 'fresh-1', |
| 540 | + headline: 'Fresh headline', |
| 541 | + }), |
| 542 | + ]); |
| 543 | + |
| 544 | + const retiredDigestHighlight = await con |
| 545 | + .getRepository(PostHighlight) |
| 546 | + .findOneByOrFail({ |
| 547 | + channel: 'vibes', |
| 548 | + postId: 'digest-post', |
| 549 | + }); |
| 550 | + expect(retiredDigestHighlight.retiredAt).toBeInstanceOf(Date); |
| 551 | + }); |
| 552 | + |
382 | 553 | it('should remove highlights that aged past the configured horizon', async () => { |
383 | 554 | const now = new Date('2026-03-03T12:00:00.000Z'); |
384 | 555 | await con.getRepository(ChannelHighlightDefinition).save({ |
|
0 commit comments