File size: 5,616 Bytes
ec8acdf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');

function source(path) {
  return readFileSync(resolve(root, path), 'utf8');
}

function assertObjectProperty(sourceText, objectName, propertyPattern) {
  assert.match(sourceText, new RegExp(`${objectName}:\\s*\\{[^}]*${propertyPattern}`, 's'));
}

describe('ACLED resolution-feed seed contract (#5076)', () => {
  const conflictSeed = source('scripts/seed-conflict-intel.mjs');
  const unrestSeed = source('scripts/seed-unrest-events.mjs');
  const resolutionSpec = source('scripts/_forecast-resolution.mjs');
  const healthApi = source('api/health.js');
  const seedHealthApi = source('api/seed-health.js');

  it('routes conflict hard counts to a long-window resolution key, not the map display key', () => {
    assert.match(resolutionSpec, /CONFLICT_COUNT_SOURCE_FEED\s*=\s*'conflict:acled-resolution:v1:all:0:0'/);
    assert.doesNotMatch(resolutionSpec, /CONFLICT_COUNT_SOURCE_FEED\s*=\s*'conflict:acled:v1:all:0:0'/);
  });

  it('routes unrest hard counts to a long-window resolution key, not the canonical display feed', () => {
    assert.match(resolutionSpec, /UNREST_COUNT_SOURCE_FEED\s*=\s*'unrest:events-resolution:v1'/);
    assert.doesNotMatch(resolutionSpec, /UNREST_COUNT_SOURCE_FEED\s*=\s*'unrest:events:v1'/);
  });

  it('conflict seeder keeps the capped display payload but also publishes a paginated 60d resolution feed', () => {
    assert.match(conflictSeed, /ACLED_CACHE_KEY\s*=\s*'conflict:acled:v1:all:0:0'/);
    assert.match(conflictSeed, /ACLED_DISPLAY_LOOKBACK_DAYS\s*=\s*30/);
    assert.match(conflictSeed, /ACLED_DISPLAY_LIMIT\s*=\s*500/);
    assert.match(conflictSeed, /ACLED_RESOLUTION_CACHE_KEY\s*=\s*'conflict:acled-resolution:v1:all:0:0'/);
    assert.match(conflictSeed, /ACLED_RESOLUTION_LOOKBACK_DAYS\s*=\s*60/);
    assert.match(conflictSeed, /ACLED_RESOLUTION_PAGE_LIMIT\s*=\s*5000/);
    assert.match(conflictSeed, /ACLED_RESOLUTION_MAX_PAGES\s*=\s*(?:[1-9]\d+)/);
    assert.match(conflictSeed, /writeExtraKeyWithMeta\(\s*ACLED_RESOLUTION_CACHE_KEY/);
    assert.match(conflictSeed, /ACLED_RESOLUTION_CACHE_KEY,[\s\S]*clusters:\s*\[\],[\s\S]*acResolution\.pagination/);
  });

  it('conflict seeder skips ACLED gracefully when no credentials are configured (auxiliary-only mode, #1651/#2288)', () => {
    // Regression guard for #5106: a *missing* ACLED credential must NOT crash the
    // seed every cron tick. When creds are absent the seed runs in its long-standing
    // auxiliary-only mode — publish an empty ACLED payload and exit 0 rather than throw.
    //
    // #5256: returning a BARE `{ events: [], pagination: undefined }` was not enough to
    // deliver the exit 0 this guard promises. It laundered an upstream outage into a
    // "0 records" result, which runSeed reads as contract RETRY — and once the last-good
    // keys expired, #5258's guard exited 1 and the seeder crash-looped forever. The
    // payload must carry `sourceUnavailable: true` so runSeed skips the publish (an empty
    // envelope would overwrite last-good) and exits 0. Keep the flag in the pattern:
    // dropping it silently reinstates the crash loop.
    assert.match(conflictSeed, /missingCredentials\s*=\s*acled\.status\s*===\s*'fulfilled'/);
    assert.match(
      conflictSeed,
      /if\s*\(\s*missingCredentials\s*\)\s*\{[\s\S]*?return\s*\{\s*events:\s*\[\],\s*pagination:\s*undefined,\s*sourceUnavailable:\s*true\s*\}/,
    );
  });

  it('conflict seeder still fails when a CONFIGURED ACLED primary feed is unavailable (no silent masking, #5106)', () => {
    // #5106's genuine value is preserved: when creds ARE present but the display
    // fetch fails, refuse to let auxiliary feeds silently mask the broken primary feed.
    assert.match(conflictSeed, /const err = new Error\([\s\S]*ACLED display fetch failed for \$\{ACLED_CACHE_KEY\}[\s\S]*auxiliary conflict\/intel feeds mask the primary feed/);
    assert.match(conflictSeed, /if\s*\(\s*acled\.reason\?\.nonRetryable\s*\)\s*err\.nonRetryable\s*=\s*true/);
    assert.match(conflictSeed, /throw err/);
  });

  it('health surfaces the ACLED display cache and seeder heartbeat (#5099)', () => {
    assert.match(healthApi, /acledIntel:\s*'conflict:acled:v1:all:0:0'/);
    assertObjectProperty(healthApi, 'acledIntel', "key:\\s*'seed-meta:conflict:acled-intel'");
    assertObjectProperty(healthApi, 'acledIntel', 'maxStaleMin:\\s*38');
    assertObjectProperty(seedHealthApi, "'conflict:acled-intel'", "key:\\s*'seed-meta:conflict:acled-intel'");
    assertObjectProperty(seedHealthApi, "'conflict:acled-intel'", 'intervalMin:\\s*19');
  });

  it('unrest seeder keeps the canonical display feed but also publishes a paginated 60d ACLED resolution feed', () => {
    assert.match(unrestSeed, /CANONICAL_KEY\s*=\s*'unrest:events:v1'/);
    assert.match(unrestSeed, /ACLED_DISPLAY_LOOKBACK_DAYS\s*=\s*30/);
    assert.match(unrestSeed, /ACLED_DISPLAY_LIMIT\s*=\s*500/);
    assert.match(unrestSeed, /UNREST_RESOLUTION_CACHE_KEY\s*=\s*'unrest:events-resolution:v1'/);
    assert.match(unrestSeed, /UNREST_RESOLUTION_LOOKBACK_DAYS\s*=\s*60/);
    assert.match(unrestSeed, /UNREST_RESOLUTION_PAGE_LIMIT\s*=\s*5000/);
    assert.match(unrestSeed, /UNREST_RESOLUTION_MAX_PAGES\s*=\s*(?:[1-9]\d+)/);
    assert.match(unrestSeed, /writeExtraKeyWithMeta\(\s*UNREST_RESOLUTION_CACHE_KEY/);
  });
});