InventoryEndpointTests.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. using System.Net;
  2. using System.Net.Http.Json;
  3. using RackPeek.Domain.Api;
  4. using Xunit.Abstractions;
  5. namespace Tests.Api;
  6. public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(output) {
  7. [Fact]
  8. public async Task DryRun_Add_New_Resource_Does_Not_Persist() {
  9. HttpClient client = CreateClient(true);
  10. var yaml = """
  11. resources:
  12. - name: example-server
  13. kind: Server
  14. """;
  15. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  16. new {
  17. yaml,
  18. dryRun = true
  19. });
  20. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  21. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  22. Assert.Single(result!.Added);
  23. Assert.Contains("example-server", result.Added);
  24. // Call again — still should be "added" because dry run did not persist
  25. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  26. new {
  27. yaml,
  28. dryRun = true
  29. });
  30. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  31. Assert.Single(result2!.Added);
  32. }
  33. [Fact]
  34. public async Task Merge_Add_New_Resource_Persists() {
  35. HttpClient client = CreateClient(true);
  36. var yaml = """
  37. version: 2
  38. resources:
  39. - kind: Server
  40. name: server-merge
  41. """;
  42. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  43. new {
  44. Yaml = yaml,
  45. mode = "Merge"
  46. });
  47. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  48. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  49. Assert.Single(result!.Added);
  50. // Now second call should detect no change
  51. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  52. new {
  53. yaml,
  54. dryRun = true
  55. });
  56. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  57. Assert.Empty(result2!.Added);
  58. Assert.Empty(result2.Updated);
  59. Assert.Empty(result2.Replaced);
  60. }
  61. [Fact]
  62. public async Task Merge_Updates_Existing_Resource() {
  63. HttpClient client = CreateClient(true);
  64. var initial = """
  65. version: 2
  66. resources:
  67. - kind: Server
  68. name: server-update
  69. ipmi: true
  70. """;
  71. await client.PostAsJsonAsync("/api/inventory",
  72. new { Yaml = initial });
  73. var update = """
  74. version: 3
  75. resources:
  76. - kind: Server
  77. name: server-update
  78. ipmi: false
  79. """;
  80. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  81. new {
  82. Yaml = update,
  83. mode = "Merge"
  84. });
  85. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  86. Assert.Single(result!.Updated);
  87. Assert.Contains("server-update", result.Updated);
  88. }
  89. [Fact]
  90. public async Task Replace_Replaces_Existing_Resource() {
  91. HttpClient client = CreateClient(true);
  92. var initial = """
  93. resources:
  94. - kind: Server
  95. name: server-replace
  96. ipmi: true
  97. """;
  98. await client.PostAsJsonAsync("/api/inventory",
  99. new { yaml = initial });
  100. var replace = """
  101. resources:
  102. - kind: Server
  103. name: server-replace
  104. """;
  105. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  106. new {
  107. yaml = replace,
  108. mode = "Replace"
  109. });
  110. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  111. Assert.Single(result!.Replaced);
  112. Assert.Contains("server-replace", result.Replaced);
  113. }
  114. [Fact]
  115. public async Task Invalid_Yaml_Returns_400() {
  116. HttpClient client = CreateClient(true);
  117. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  118. new {
  119. yaml = "not: valid: yaml:"
  120. });
  121. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  122. }
  123. [Fact]
  124. public async Task Missing_Resources_Section_Returns_400() {
  125. HttpClient client = CreateClient(true);
  126. var yaml = """
  127. somethingElse:
  128. - name: test
  129. """;
  130. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  131. new { yaml });
  132. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  133. }
  134. [Fact]
  135. public async Task Accepts_Json_Root_Input() {
  136. HttpClient client = CreateClient(true);
  137. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  138. new {
  139. json = new {
  140. version = 1,
  141. resources = new[]
  142. {
  143. new { kind = "Server", name = "json-server" }
  144. }
  145. }
  146. });
  147. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  148. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  149. Assert.Single(result!.Added);
  150. Assert.Contains("json-server", result.Added);
  151. }
  152. [Fact]
  153. public async Task Requires_Api_Key() {
  154. HttpClient client = CreateClient();
  155. var yaml = """
  156. resources:
  157. - name: no-auth
  158. kind: Server
  159. """;
  160. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  161. new { yaml });
  162. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  163. }
  164. [Fact]
  165. public async Task Import_Full_Config_Works() {
  166. HttpClient client = CreateClient(true);
  167. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  168. // Put your big sample YAML in TestData folder
  169. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  170. new { yaml });
  171. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  172. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  173. Assert.True(result!.Added.Count > 10);
  174. Assert.Empty(result.Updated);
  175. Assert.Empty(result.Replaced);
  176. }
  177. [Fact]
  178. public async Task Import_Full_Config_Twice_Is_Idempotent() {
  179. HttpClient client = CreateClient(true);
  180. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  181. await client.PostAsJsonAsync("/api/inventory", new { yaml });
  182. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  183. new { yaml, dryRun = true });
  184. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  185. Assert.Empty(result2!.Added);
  186. Assert.Empty(result2.Updated);
  187. Assert.Empty(result2.Replaced);
  188. }
  189. [Fact]
  190. public async Task Merge_Updates_Nested_Object() {
  191. HttpClient client = CreateClient(true);
  192. var initial = """
  193. version: 2
  194. resources:
  195. - kind: Server
  196. name: nested-test
  197. ram:
  198. size: 64
  199. mts: 2666
  200. """;
  201. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  202. var update = """
  203. version: 3
  204. resources:
  205. - kind: Server
  206. name: nested-test
  207. ram:
  208. size: 128
  209. """;
  210. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  211. new { yaml = update, mode = "Merge" });
  212. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  213. Assert.Single(result!.Updated);
  214. }
  215. [Fact]
  216. public async Task Merge_Does_Not_Clear_List_When_Empty() {
  217. HttpClient client = CreateClient(true);
  218. var initial = """
  219. resources:
  220. - kind: Server
  221. name: drive-test
  222. drives:
  223. - type: ssd
  224. size: 1024
  225. """;
  226. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  227. var update = """
  228. resources:
  229. - kind: Server
  230. name: drive-test
  231. drives: []
  232. """;
  233. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  234. new { yaml = update, mode = "Merge" });
  235. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  236. // Should NOT count as update because empty list ignored
  237. Assert.Empty(result!.Updated);
  238. }
  239. [Fact]
  240. public async Task Replace_Clears_List() {
  241. HttpClient client = CreateClient(true);
  242. var initial = """
  243. resources:
  244. - kind: Server
  245. name: replace-drive-test
  246. drives:
  247. - type: ssd
  248. size: 1024
  249. """;
  250. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  251. var replace = """
  252. resources:
  253. - kind: Server
  254. name: replace-drive-test
  255. drives: []
  256. """;
  257. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  258. new { yaml = replace, mode = "Replace" });
  259. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  260. Assert.Single(result!.Replaced);
  261. }
  262. [Fact]
  263. public async Task Type_Change_Forces_Replace() {
  264. HttpClient client = CreateClient(true);
  265. var initial = """
  266. version: 2
  267. resources:
  268. - kind: Server
  269. name: polymorph-test
  270. """;
  271. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  272. var update = """
  273. version: 3
  274. resources:
  275. - kind: Firewall
  276. name: polymorph-test
  277. """;
  278. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  279. new { yaml = update, mode = "Merge" });
  280. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  281. Assert.Single(result!.Replaced);
  282. }
  283. [Fact]
  284. public async Task Name_Matching_Is_Case_Insensitive() {
  285. HttpClient client = CreateClient(true);
  286. var initial = """
  287. resources:
  288. - kind: Server
  289. name: CaseTest
  290. """;
  291. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  292. var update = """
  293. resources:
  294. - kind: Server
  295. name: casetest
  296. ipmi: true
  297. """;
  298. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  299. new { yaml = update });
  300. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  301. Assert.Single(result!.Updated);
  302. }
  303. [Fact]
  304. public async Task Multiple_Resources_Are_Processed() {
  305. HttpClient client = CreateClient(true);
  306. var yaml = """
  307. resources:
  308. - kind: Server
  309. name: multi-1
  310. - kind: Firewall
  311. name: multi-2
  312. """;
  313. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  314. new { yaml });
  315. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  316. Assert.Equal(2, result!.Added.Count);
  317. }
  318. [Fact]
  319. public async Task DryRun_Replace_Does_Not_Persist() {
  320. HttpClient client = CreateClient(true);
  321. var initial = """
  322. resources:
  323. - kind: Server
  324. name: dry-replace
  325. ipmi: true
  326. """;
  327. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  328. var replace = """
  329. resources:
  330. - kind: Server
  331. name: dry-replace
  332. """;
  333. await client.PostAsJsonAsync("/api/inventory",
  334. new { yaml = replace, mode = "Replace", dryRun = true });
  335. HttpResponseMessage check = await client.PostAsJsonAsync("/api/inventory",
  336. new { yaml = replace, mode = "Replace", dryRun = true });
  337. ImportYamlResponse? result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
  338. Assert.Single(result!.Replaced);
  339. }
  340. [Fact]
  341. public async Task Providing_Both_Yaml_And_Json_Returns_400() {
  342. HttpClient client = CreateClient(true);
  343. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  344. new {
  345. yaml = "resources: []",
  346. json = new { resources = Array.Empty<object>() }
  347. });
  348. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  349. }
  350. [Fact]
  351. public async Task Empty_Request_Returns_400() {
  352. HttpClient client = CreateClient(true);
  353. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { });
  354. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  355. }
  356. [Fact]
  357. public async Task Version_1_Config_Is_Accepted() {
  358. HttpClient client = CreateClient(true);
  359. var yaml = """
  360. version: 1
  361. resources:
  362. - kind: Server
  363. name: v1-server
  364. """;
  365. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  366. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  367. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  368. Assert.Single(result!.Added);
  369. }
  370. [Fact]
  371. public async Task Replace_Removes_Existing_Fields() {
  372. HttpClient client = CreateClient(true);
  373. var initial = """
  374. resources:
  375. - kind: Server
  376. name: destructive-test
  377. ipmi: true
  378. """;
  379. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  380. var replace = """
  381. resources:
  382. - kind: Server
  383. name: destructive-test
  384. """;
  385. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  386. new { yaml = replace, mode = "Replace" });
  387. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  388. Assert.Single(result!.Replaced);
  389. Assert.Contains("destructive-test", result.OldYaml.Keys);
  390. Assert.Contains("destructive-test", result.NewYaml.Keys);
  391. }
  392. [Fact]
  393. public async Task Merge_Does_Not_Affect_Unspecified_Resources() {
  394. HttpClient client = CreateClient(true);
  395. var full = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  396. await client.PostAsJsonAsync("/api/inventory", new { yaml = full });
  397. var update = """
  398. resources:
  399. - kind: Server
  400. name: proxmox-node01
  401. ipmi: false
  402. """;
  403. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  404. new { yaml = update, mode = "Merge" });
  405. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  406. Assert.Single(result!.Updated);
  407. Assert.DoesNotContain("proxmox-node02", result.Updated);
  408. }
  409. [Fact]
  410. public async Task Json_Input_Resolves_Polymorphic_Resource() {
  411. HttpClient client = CreateClient(true);
  412. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  413. new {
  414. json = new {
  415. version = 2,
  416. resources = new[]
  417. {
  418. new { kind = "Firewall", name = "json-fw", model = "Test" }
  419. }
  420. }
  421. });
  422. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  423. Assert.Single(result!.Added);
  424. Assert.Contains("json-fw", result.Added);
  425. }
  426. [Fact]
  427. public async Task Large_Config_Is_Fully_Idempotent() {
  428. HttpClient client = CreateClient(true);
  429. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  430. await client.PostAsJsonAsync("/api/inventory", new { yaml });
  431. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  432. new { yaml });
  433. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  434. Assert.Empty(result!.Added);
  435. Assert.Empty(result.Updated);
  436. Assert.Empty(result.Replaced);
  437. }
  438. [Fact]
  439. public async Task Unknown_Kind_Returns_400() {
  440. HttpClient client = CreateClient(true);
  441. var yaml = """
  442. resources:
  443. - kind: UnknownThing
  444. name: mystery
  445. """;
  446. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  447. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  448. }
  449. [Fact]
  450. public async Task DryRun_Does_Not_Persist_Snapshots() {
  451. HttpClient client = CreateClient(true);
  452. var yaml = """
  453. resources:
  454. - kind: Server
  455. name: dry-snapshot
  456. """;
  457. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  458. new { yaml, dryRun = true });
  459. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  460. Assert.Empty(result!.OldYaml);
  461. }
  462. [Fact]
  463. public async Task Reordering_List_Does_Not_Count_As_Update() {
  464. HttpClient client = CreateClient(true);
  465. var initial = """
  466. resources:
  467. - kind: Server
  468. name: order-test
  469. drives:
  470. - type: ssd
  471. size: 1024
  472. - type: hdd
  473. size: 4096
  474. """;
  475. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  476. var reordered = """
  477. resources:
  478. - kind: Server
  479. name: order-test
  480. drives:
  481. - type: hdd
  482. size: 4096
  483. - type: ssd
  484. size: 1024
  485. """;
  486. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  487. new { yaml = reordered });
  488. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  489. Assert.Single(result!.Updated);
  490. Assert.Contains("order-test", result.Updated);
  491. }
  492. [Fact]
  493. public async Task Duplicate_Names_In_Same_Request_Returns_400() {
  494. HttpClient client = CreateClient(true);
  495. var yaml = """
  496. resources:
  497. - kind: Server
  498. name: dup
  499. - kind: Server
  500. name: dup
  501. """;
  502. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  503. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  504. }
  505. }