config.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
  2. import { mkdtemp, rm, mkdir } from 'node:fs/promises';
  3. import { join, dirname } from 'node:path';
  4. import { tmpdir } from 'node:os';
  5. import {
  6. readConfig,
  7. writeConfig,
  8. createDefaultConfig,
  9. mergeConfig,
  10. isYoloMode,
  11. isAutoBackup,
  12. getConfigPath,
  13. } from './config.js';
  14. // ── getConfigPath ─────────────────────────────────────────────────────────────
  15. // Pure function — no I/O.
  16. describe('getConfigPath', () => {
  17. // ✅ Positive: returns the expected path
  18. test('returns .oac/config.json under the project root', () => {
  19. // Arrange
  20. const projectRoot = '/home/user/my-project';
  21. // Act
  22. const result = getConfigPath(projectRoot);
  23. // Assert
  24. expect(result).toBe('/home/user/my-project/.oac/config.json');
  25. });
  26. // ✅ Positive: works with trailing slash stripped by path.join
  27. test('handles project roots without trailing slash', () => {
  28. const result = getConfigPath('/tmp/proj');
  29. expect(result).toEndWith('/.oac/config.json');
  30. });
  31. });
  32. // ── createDefaultConfig ───────────────────────────────────────────────────────
  33. // Pure function — no I/O.
  34. describe('createDefaultConfig', () => {
  35. // ✅ Positive: returns version "1"
  36. test('returns a config with version "1"', () => {
  37. const config = createDefaultConfig();
  38. expect(config.version).toBe('1');
  39. });
  40. // ✅ Positive: yoloMode defaults to false
  41. test('yoloMode defaults to false', () => {
  42. const config = createDefaultConfig();
  43. expect(config.preferences.yoloMode).toBe(false);
  44. });
  45. // ✅ Positive: autoBackup defaults to true
  46. test('autoBackup defaults to true', () => {
  47. const config = createDefaultConfig();
  48. expect(config.preferences.autoBackup).toBe(true);
  49. });
  50. // ❌ Negative: two calls return independent objects (no shared reference)
  51. test('returns a new object on each call', () => {
  52. const a = createDefaultConfig();
  53. const b = createDefaultConfig();
  54. // Mutating one should not affect the other
  55. a.preferences.yoloMode = true;
  56. expect(b.preferences.yoloMode).toBe(false);
  57. });
  58. });
  59. // ── mergeConfig ───────────────────────────────────────────────────────────────
  60. // Pure function — no I/O.
  61. describe('mergeConfig', () => {
  62. // ✅ Positive: overrides a single preference field
  63. test('overrides yoloMode when provided', () => {
  64. // Arrange
  65. const base = createDefaultConfig();
  66. // Act
  67. const merged = mergeConfig(base, { yoloMode: true });
  68. // Assert
  69. expect(merged.preferences.yoloMode).toBe(true);
  70. });
  71. // ✅ Positive: preserves unspecified preference fields
  72. test('preserves autoBackup when only yoloMode is overridden', () => {
  73. const base = createDefaultConfig(); // autoBackup: true
  74. const merged = mergeConfig(base, { yoloMode: true });
  75. expect(merged.preferences.autoBackup).toBe(true);
  76. });
  77. // ✅ Positive: overrides autoBackup
  78. test('overrides autoBackup when provided', () => {
  79. const base = createDefaultConfig();
  80. const merged = mergeConfig(base, { autoBackup: false });
  81. expect(merged.preferences.autoBackup).toBe(false);
  82. });
  83. // ✅ Positive: overrides both fields simultaneously
  84. test('overrides both fields when both are provided', () => {
  85. const base = createDefaultConfig();
  86. const merged = mergeConfig(base, { yoloMode: true, autoBackup: false });
  87. expect(merged.preferences.yoloMode).toBe(true);
  88. expect(merged.preferences.autoBackup).toBe(false);
  89. });
  90. // ❌ Negative: does not mutate the base config
  91. test('does not mutate the base config', () => {
  92. const base = createDefaultConfig();
  93. mergeConfig(base, { yoloMode: true });
  94. expect(base.preferences.yoloMode).toBe(false);
  95. });
  96. // ❌ Negative: empty overrides returns equivalent config
  97. test('empty overrides returns config with same preference values', () => {
  98. const base = createDefaultConfig();
  99. const merged = mergeConfig(base, {});
  100. expect(merged.preferences.yoloMode).toBe(base.preferences.yoloMode);
  101. expect(merged.preferences.autoBackup).toBe(base.preferences.autoBackup);
  102. });
  103. });
  104. // ── isYoloMode ────────────────────────────────────────────────────────────────
  105. // Note: isYoloMode also checks process.env.CI — we test both branches.
  106. describe('isYoloMode', () => {
  107. // ✅ Positive: returns true when yoloMode preference is true
  108. test('returns true when config.preferences.yoloMode is true', () => {
  109. // Arrange
  110. const config = mergeConfig(createDefaultConfig(), { yoloMode: true });
  111. // Act & Assert
  112. // Temporarily clear CI to isolate the preference check
  113. const savedCI = process.env['CI'];
  114. delete process.env['CI'];
  115. try {
  116. expect(isYoloMode(config)).toBe(true);
  117. } finally {
  118. if (savedCI !== undefined) process.env['CI'] = savedCI;
  119. }
  120. });
  121. // ✅ Positive: returns true when CI env var is "true" (even if preference is false)
  122. test('returns true when process.env.CI is "true"', () => {
  123. const config = createDefaultConfig(); // yoloMode: false
  124. const savedCI = process.env['CI'];
  125. process.env['CI'] = 'true';
  126. try {
  127. expect(isYoloMode(config)).toBe(true);
  128. } finally {
  129. if (savedCI !== undefined) {
  130. process.env['CI'] = savedCI;
  131. } else {
  132. delete process.env['CI'];
  133. }
  134. }
  135. });
  136. // ❌ Negative: returns false when yoloMode is false and CI is not set
  137. test('returns false when yoloMode is false and CI is not "true"', () => {
  138. const config = createDefaultConfig(); // yoloMode: false
  139. const savedCI = process.env['CI'];
  140. delete process.env['CI'];
  141. try {
  142. expect(isYoloMode(config)).toBe(false);
  143. } finally {
  144. if (savedCI !== undefined) process.env['CI'] = savedCI;
  145. }
  146. });
  147. });
  148. // ── isAutoBackup ──────────────────────────────────────────────────────────────
  149. describe('isAutoBackup', () => {
  150. // ✅ Positive: returns true when autoBackup is true
  151. test('returns true when autoBackup preference is true', () => {
  152. const config = createDefaultConfig(); // autoBackup: true
  153. expect(isAutoBackup(config)).toBe(true);
  154. });
  155. // ❌ Negative: returns false when autoBackup is false
  156. test('returns false when autoBackup preference is false', () => {
  157. const config = mergeConfig(createDefaultConfig(), { autoBackup: false });
  158. expect(isAutoBackup(config)).toBe(false);
  159. });
  160. });
  161. // ── readConfig / writeConfig (I/O round-trip) ─────────────────────────────────
  162. describe('readConfig / writeConfig', () => {
  163. let tmpDir: string;
  164. beforeAll(async () => {
  165. tmpDir = await mkdtemp(join(tmpdir(), 'oac-config-test-'));
  166. });
  167. afterAll(async () => {
  168. await rm(tmpDir, { recursive: true, force: true });
  169. });
  170. // ✅ Positive: readConfig returns null when no config file exists
  171. test('readConfig returns null when config file does not exist', async () => {
  172. // Arrange — fresh tmpDir has no .oac/config.json
  173. const emptyDir = join(tmpDir, 'empty');
  174. // Act
  175. const result = await readConfig(emptyDir);
  176. // Assert
  177. expect(result).toBeNull();
  178. });
  179. // ✅ Positive: writeConfig creates .oac/ dir and writes the file
  180. test('writeConfig creates .oac/ directory and writes config.json', async () => {
  181. // Arrange
  182. const projectRoot = join(tmpDir, 'write-test');
  183. const config = createDefaultConfig();
  184. // Act
  185. await writeConfig(projectRoot, config);
  186. // Assert — file should now exist
  187. const configPath = getConfigPath(projectRoot);
  188. expect(await Bun.file(configPath).exists()).toBe(true);
  189. });
  190. // ✅ Positive: round-trip — write then read returns the same config
  191. test('writeConfig then readConfig round-trips the default config', async () => {
  192. // Arrange
  193. const projectRoot = join(tmpDir, 'roundtrip-default');
  194. const original = createDefaultConfig();
  195. // Act
  196. await writeConfig(projectRoot, original);
  197. const read = await readConfig(projectRoot);
  198. // Assert
  199. expect(read).not.toBeNull();
  200. expect(read?.version).toBe('1');
  201. expect(read?.preferences.yoloMode).toBe(false);
  202. expect(read?.preferences.autoBackup).toBe(true);
  203. });
  204. // ✅ Positive: round-trip preserves non-default preference values
  205. test('writeConfig then readConfig round-trips a modified config', async () => {
  206. // Arrange
  207. const projectRoot = join(tmpDir, 'roundtrip-modified');
  208. const config = mergeConfig(createDefaultConfig(), { yoloMode: true, autoBackup: false });
  209. // Act
  210. await writeConfig(projectRoot, config);
  211. const read = await readConfig(projectRoot);
  212. // Assert
  213. expect(read?.preferences.yoloMode).toBe(true);
  214. expect(read?.preferences.autoBackup).toBe(false);
  215. });
  216. // ✅ Positive: writeConfig is idempotent — second write overwrites first
  217. test('second writeConfig call overwrites the first', async () => {
  218. // Arrange
  219. const projectRoot = join(tmpDir, 'overwrite-test');
  220. const first = createDefaultConfig();
  221. const second = mergeConfig(createDefaultConfig(), { yoloMode: true });
  222. // Act
  223. await writeConfig(projectRoot, first);
  224. await writeConfig(projectRoot, second);
  225. const read = await readConfig(projectRoot);
  226. // Assert
  227. expect(read?.preferences.yoloMode).toBe(true);
  228. });
  229. // ❌ Negative: readConfig throws for invalid JSON structure
  230. test('readConfig throws an error when config JSON is structurally invalid', async () => {
  231. // Arrange — write a config with a bad version field
  232. const projectRoot = join(tmpDir, 'bad-config');
  233. const configPath = getConfigPath(projectRoot);
  234. // Create the .oac/ directory first (Bun.write does not auto-create parent dirs)
  235. await mkdir(dirname(configPath), { recursive: true });
  236. // Write invalid config (version must be literal "1")
  237. await Bun.write(configPath, JSON.stringify({ version: '99', preferences: {} }));
  238. // Act & Assert
  239. await expect(readConfig(projectRoot)).rejects.toThrow();
  240. });
  241. // ❌ Negative: readConfig throws for missing required fields
  242. test('readConfig throws when preferences field is missing', async () => {
  243. // Arrange
  244. const projectRoot = join(tmpDir, 'missing-prefs');
  245. const configPath = getConfigPath(projectRoot);
  246. await mkdir(dirname(configPath), { recursive: true });
  247. await Bun.write(configPath, JSON.stringify({ version: '1' }));
  248. // Act & Assert
  249. await expect(readConfig(projectRoot)).rejects.toThrow();
  250. });
  251. // ❌ Negative: readConfig throws for wrong preference types
  252. test('readConfig throws when preference values have wrong types', async () => {
  253. // Arrange
  254. const projectRoot = join(tmpDir, 'wrong-types');
  255. const configPath = getConfigPath(projectRoot);
  256. await mkdir(dirname(configPath), { recursive: true });
  257. await Bun.write(
  258. configPath,
  259. JSON.stringify({ version: '1', preferences: { yoloMode: 'yes', autoBackup: 1 } }),
  260. );
  261. // Act & Assert
  262. await expect(readConfig(projectRoot)).rejects.toThrow();
  263. });
  264. });