|
6 | 6 |
|
7 | 7 | import { readFileSync } from "node:fs"; |
8 | 8 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 9 | +import { globalServiceRegistry } from "workglow"; |
9 | 10 | import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; |
10 | 11 | import { setupAllDatabases } from "../../../config/setupAllDatabases"; |
11 | 12 | import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo"; |
| 13 | +import { FILING_REPOSITORY_TOKEN } from "../../../storage/filing/FilingSchema"; |
| 14 | +import { getBackfillDescriptor } from "../../../task/forms/backfillDescriptors"; |
12 | 15 | import { SpacMergerExtractionRepo } from "../../../storage/spac/SpacMergerExtractionRepo"; |
13 | 16 | import { SpacRepo } from "../../../storage/spac/SpacRepo"; |
14 | 17 | import { SpacReportWriter } from "../../../storage/spac/SpacReportWriter"; |
@@ -253,6 +256,138 @@ describe("processMergerProxy (e2e)", () => { |
253 | 256 | expect(row?.status).toBe("deal_announced"); |
254 | 257 | }); |
255 | 258 |
|
| 259 | + /** |
| 260 | + * A provider error that is NOT a throttle: `isRateLimitError` would otherwise |
| 261 | + * make the extractor wait out real backoff sleeps before failing. |
| 262 | + */ |
| 263 | + function scriptExtractionFailure(): () => void { |
| 264 | + return registerFakeStructuredProvider([new Error("upstream provider failure")]).unregister; |
| 265 | + } |
| 266 | + |
| 267 | + it("leaves a recorded proxy event standing when the re-run's extraction fails", async () => { |
| 268 | + // A failed model call is not a verdict about the filing. The approval |
| 269 | + // evidence (`seeks_combination_approval`) is deterministic and still reads |
| 270 | + // true here — only the extraction failed — so retracting would delete an |
| 271 | + // event an earlier run recorded from real evidence, and losing the `proxy` |
| 272 | + // event cascades: the vehicle's next Form 25/15 inside the post-approval |
| 273 | + // window stops reading as a completed de-SPAC. |
| 274 | + await seedSpacWithOpenDeal(130); |
| 275 | + cleanup = scriptMergerDeal(); |
| 276 | + await runProxy(130, "130-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY)); |
| 277 | + expect((await repo.getSpac(130))?.status).toBe("proxy"); |
| 278 | + cleanup(); |
| 279 | + |
| 280 | + cleanup = scriptExtractionFailure(); |
| 281 | + await runProxy(130, "130-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY)); |
| 282 | + |
| 283 | + const events = await repo.getEvents(130); |
| 284 | + expect(events.filter((e) => e.event_type === "proxy")).toHaveLength(1); |
| 285 | + const row = await repo.getSpac(130); |
| 286 | + expect(row?.status).toBe("proxy"); |
| 287 | + expect(row?.proxy_date).toBe("2021-05-01"); |
| 288 | + |
| 289 | + // The failure is on the worklist — left for retry, not read as an answer. |
| 290 | + const entry = await new ExtractionDeadLetterRepo().get("merger-proxy", "130-def14a", "merger"); |
| 291 | + expect(entry?.status).toBe("pending"); |
| 292 | + }); |
| 293 | + |
| 294 | + it("retracts on the document verdict even when the extraction failed", async () => { |
| 295 | + // The other half of the same rule: `seeks_combination_approval` is derived |
| 296 | + // from the document with no model call, and the gate is conjunctive — a |
| 297 | + // statement asking for no approval can never emit, so a false verdict |
| 298 | + // retracts whether or not the model was reachable. That is what keeps the |
| 299 | + // recovery ceremony able to unwind a stale close during a provider outage. |
| 300 | + await seedSpacWithOpenDeal(131); |
| 301 | + cleanup = scriptMergerDeal(); |
| 302 | + await runProxy(131, "131-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY)); |
| 303 | + expect((await repo.getSpac(131))?.status).toBe("proxy"); |
| 304 | + cleanup(); |
| 305 | + |
| 306 | + cleanup = scriptExtractionFailure(); |
| 307 | + await runProxy(131, "131-def14a", "DEF 14A", "2021-05-01", submissionWithBody(EXTENSION_BODY)); |
| 308 | + |
| 309 | + const events = await repo.getEvents(131); |
| 310 | + expect(events.some((e) => e.event_type === "proxy")).toBe(false); |
| 311 | + const row = await repo.getSpac(131); |
| 312 | + expect(row?.status).toBe("deal_announced"); |
| 313 | + expect(row?.proxy_date).toBeNull(); |
| 314 | + }); |
| 315 | + |
| 316 | + it("leaves a recorded proxy event standing when no model could be resolved", async () => { |
| 317 | + // The same invariant one stage earlier: the section never reaches a model, |
| 318 | + // so the run has nothing to say about the document. |
| 319 | + await seedSpacWithOpenDeal(132); |
| 320 | + cleanup = scriptMergerDeal(); |
| 321 | + await runProxy(132, "132-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY)); |
| 322 | + expect((await repo.getSpac(132))?.status).toBe("proxy"); |
| 323 | + cleanup(); |
| 324 | + cleanup = undefined; |
| 325 | + |
| 326 | + // No `model` argument, and no merger-proxy model id is registered here. |
| 327 | + const parsed = await Form_DEFM14A.parse("DEF 14A", submissionWithBody(APPROVAL_BODY)); |
| 328 | + await processMergerProxy({ |
| 329 | + cik: 132, |
| 330 | + file_number: "", |
| 331 | + accession_number: "132-def14a", |
| 332 | + filing_date: "2021-05-01", |
| 333 | + primary_doc: "proxy.htm", |
| 334 | + form: "DEF 14A", |
| 335 | + formMergerProxy: parsed, |
| 336 | + }); |
| 337 | + |
| 338 | + const entry = await new ExtractionDeadLetterRepo().get("merger-proxy", "132-def14a", "merger"); |
| 339 | + expect(entry?.reason_code).toBe("MODEL_RESOLUTION_ERROR"); |
| 340 | + expect((await repo.getEvents(132)).filter((e) => e.event_type === "proxy")).toHaveLength(1); |
| 341 | + expect((await repo.getSpac(132))?.status).toBe("proxy"); |
| 342 | + }); |
| 343 | + |
| 344 | + it("records the gate verdict even when the run extracted nothing", async () => { |
| 345 | + // The backfill re-selects a general definitive proxy whose verdict is NULL. |
| 346 | + // A run that could not reach the model still evaluated the deterministic |
| 347 | + // gate, so it must record the verdict — otherwise the clause never |
| 348 | + // extinguishes and every sweep re-runs the same filing forever. |
| 349 | + await seedSpacWithOpenDeal(133); |
| 350 | + cleanup = scriptMergerDeal(); |
| 351 | + await runProxy(133, "133-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY)); |
| 352 | + cleanup(); |
| 353 | + |
| 354 | + // Model the pre-gate row the recovery ceremony targets: verdict never set. |
| 355 | + const extractions = new SpacMergerExtractionRepo(); |
| 356 | + const before = await extractions.getByAccession("133-def14a"); |
| 357 | + await extractions.save({ ...before!, seeks_combination_approval: null }); |
| 358 | + |
| 359 | + cleanup = scriptExtractionFailure(); |
| 360 | + await runProxy(133, "133-def14a", "DEF 14A", "2021-05-01", submissionWithBody(APPROVAL_BODY)); |
| 361 | + |
| 362 | + const after = await extractions.getByAccession("133-def14a"); |
| 363 | + expect(after?.seeks_combination_approval).toBe(true); |
| 364 | + // The extraction the earlier run persisted is untouched by the failed one. |
| 365 | + expect(after?.target_name).toBe("Acme Target Inc."); |
| 366 | + |
| 367 | + // And the backfill's null-verdict clause no longer selects it. |
| 368 | + await globalServiceRegistry.get(FILING_REPOSITORY_TOKEN).put({ |
| 369 | + cik: 133, |
| 370 | + accession_number: "133-def14a", |
| 371 | + form: "DEF 14A", |
| 372 | + primary_doc: "proxy.htm", |
| 373 | + file_number: "", |
| 374 | + filing_date: "2021-05-01", |
| 375 | + acceptance_date: "2021-05-01T00:00:00.000Z", |
| 376 | + report_date: "2021-05-01", |
| 377 | + film_number: null, |
| 378 | + primary_doc_description: null, |
| 379 | + size: null, |
| 380 | + is_xbrl: null, |
| 381 | + is_inline_xbrl: null, |
| 382 | + items: null, |
| 383 | + act: null, |
| 384 | + } as never); |
| 385 | + const descriptor = getBackfillDescriptor("merger-proxy")!; |
| 386 | + const candidates = await descriptor.selectCandidates(); |
| 387 | + expect(candidates.map((c) => c.accession_number)).toContain("133-def14a"); |
| 388 | + expect(await descriptor.filterTodo!(candidates)).toEqual([]); |
| 389 | + }); |
| 390 | + |
256 | 391 | it("records the gate verdict only for the forms it governs", async () => { |
257 | 392 | // The "M" forms decide on the symbol alone and must not pay a full-document |
258 | 393 | // render, so their verdict stays null — which is also what the backfill |
|
0 commit comments