1
0

beta-signup.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. /**
  2. * CodeGraph Pro beta opt-in (installer Step 5½).
  3. *
  4. * Covers the module the interactive prompt drives:
  5. * - ask-once persistence in the user-level state dir (temp dir injected —
  6. * no real ~/.codegraph ever touched)
  7. * - the submit request shape (endpoint, method, JSON body)
  8. * - fail-soft behavior: bad responses and network errors return false,
  9. * never throw
  10. */
  11. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as path from 'path';
  14. import * as os from 'os';
  15. import {
  16. BETA_SIGNUP_ENDPOINT,
  17. EMAIL_RE,
  18. hasBetaSignupChoice,
  19. recordBetaSignupChoice,
  20. shouldOfferBetaSignup,
  21. submitBetaSignup,
  22. } from '../src/installer/beta-signup';
  23. describe('beta signup choice persistence', () => {
  24. let dir: string;
  25. beforeEach(() => {
  26. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-beta-'));
  27. });
  28. afterEach(() => {
  29. fs.rmSync(dir, { recursive: true, force: true });
  30. });
  31. it('reports no choice on a fresh machine', () => {
  32. expect(hasBetaSignupChoice({ dir })).toBe(false);
  33. });
  34. it('remembers a subscribed choice so no future install re-asks', () => {
  35. recordBetaSignupChoice(true, { dir });
  36. expect(hasBetaSignupChoice({ dir })).toBe(true);
  37. const raw = JSON.parse(fs.readFileSync(path.join(dir, 'beta-signup.json'), 'utf8'));
  38. expect(raw.status).toBe('subscribed');
  39. expect(raw.updated_at).toBeTruthy();
  40. });
  41. it('remembers a declined choice too — declining is also asked-once', () => {
  42. recordBetaSignupChoice(false, { dir });
  43. expect(hasBetaSignupChoice({ dir })).toBe(true);
  44. const raw = JSON.parse(fs.readFileSync(path.join(dir, 'beta-signup.json'), 'utf8'));
  45. expect(raw.status).toBe('declined');
  46. });
  47. it('creates the state dir when missing', () => {
  48. const nested = path.join(dir, 'not', 'yet', 'there');
  49. recordBetaSignupChoice(true, { dir: nested });
  50. expect(hasBetaSignupChoice({ dir: nested })).toBe(true);
  51. });
  52. it('treats a corrupted choice file as no choice', () => {
  53. fs.writeFileSync(path.join(dir, 'beta-signup.json'), 'not json');
  54. expect(hasBetaSignupChoice({ dir })).toBe(false);
  55. fs.writeFileSync(path.join(dir, 'beta-signup.json'), JSON.stringify({ status: 'maybe' }));
  56. expect(hasBetaSignupChoice({ dir })).toBe(false);
  57. });
  58. });
  59. describe('shouldOfferBetaSignup — the shared no-spam gate', () => {
  60. let dir: string;
  61. const tty = { stdinIsTTY: true, stdoutIsTTY: true };
  62. beforeEach(() => {
  63. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-beta-gate-'));
  64. });
  65. afterEach(() => {
  66. fs.rmSync(dir, { recursive: true, force: true });
  67. });
  68. it('offers on a fresh machine with a real terminal', () => {
  69. expect(shouldOfferBetaSignup({ dir, ...tty })).toBe(true);
  70. });
  71. it('never offers again once subscribed — from install OR upgrade', () => {
  72. recordBetaSignupChoice(true, { dir });
  73. expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-install' })).toBe(false);
  74. expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-upgrade' })).toBe(false);
  75. });
  76. it('never offers again once declined either', () => {
  77. recordBetaSignupChoice(false, { dir });
  78. expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-install' })).toBe(false);
  79. expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-upgrade' })).toBe(false);
  80. });
  81. it('never offers without a terminal (scripts, CI, piped output)', () => {
  82. expect(shouldOfferBetaSignup({ dir, stdinIsTTY: false, stdoutIsTTY: true })).toBe(false);
  83. expect(shouldOfferBetaSignup({ dir, stdinIsTTY: true, stdoutIsTTY: false })).toBe(false);
  84. });
  85. });
  86. describe('submitBetaSignup', () => {
  87. it('POSTs the email as JSON to the waitlist endpoint', async () => {
  88. let captured: { url: string; init: RequestInit } | null = null;
  89. const fetchImpl = (async (url: unknown, init?: RequestInit) => {
  90. captured = { url: String(url), init: init! };
  91. return new Response('{"ok":true}', { status: 200 });
  92. }) as typeof fetch;
  93. const ok = await submitBetaSignup('dev@example.com', { fetchImpl });
  94. expect(ok).toBe(true);
  95. expect(captured!.url).toBe(BETA_SIGNUP_ENDPOINT);
  96. expect(captured!.init.method).toBe('POST');
  97. expect((captured!.init.headers as Record<string, string>)['Content-Type']).toBe(
  98. 'application/json',
  99. );
  100. const body = JSON.parse(String(captured!.init.body));
  101. expect(body).toEqual({ email: 'dev@example.com', source: 'cli-install' });
  102. });
  103. it('records where the signup came from (install vs upgrade)', async () => {
  104. let body: Record<string, unknown> = {};
  105. const fetchImpl = (async (_url: unknown, init?: RequestInit) => {
  106. body = JSON.parse(String(init!.body));
  107. return new Response('{"ok":true}', { status: 200 });
  108. }) as typeof fetch;
  109. await submitBetaSignup('dev@example.com', { fetchImpl, source: 'cli-upgrade' });
  110. expect(body.source).toBe('cli-upgrade');
  111. });
  112. it('returns false on a non-2xx response', async () => {
  113. const fetchImpl = (async () =>
  114. new Response('{"ok":false}', { status: 502 })) as typeof fetch;
  115. expect(await submitBetaSignup('dev@example.com', { fetchImpl })).toBe(false);
  116. });
  117. it('returns false (never throws) when the network fails', async () => {
  118. const fetchImpl = (async () => {
  119. throw new Error('offline');
  120. }) as typeof fetch;
  121. expect(await submitBetaSignup('dev@example.com', { fetchImpl })).toBe(false);
  122. });
  123. });
  124. describe('EMAIL_RE', () => {
  125. it('matches the landing-page validation shape', () => {
  126. expect(EMAIL_RE.test('you@company.com')).toBe(true);
  127. expect(EMAIL_RE.test('first.last+tag@sub.domain.io')).toBe(true);
  128. expect(EMAIL_RE.test('nope')).toBe(false);
  129. expect(EMAIL_RE.test('nope@nodot')).toBe(false);
  130. expect(EMAIL_RE.test('spaces in@mail.com')).toBe(false);
  131. expect(EMAIL_RE.test('')).toBe(false);
  132. });
  133. });