route.test.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach } from "vitest";
  3. vi.mock("@/lib/auth/session", () => ({
  4. getSession: vi.fn(),
  5. }));
  6. vi.mock("@/lib/db", () => ({
  7. getDb: vi.fn(),
  8. }));
  9. vi.mock("@/models/user", () => {
  10. const USER_ROLES = Object.freeze({
  11. BRANCH: "branch",
  12. ADMIN: "admin",
  13. SUPERADMIN: "superadmin",
  14. DEV: "dev",
  15. });
  16. return {
  17. default: {
  18. find: vi.fn(),
  19. findOne: vi.fn(),
  20. create: vi.fn(),
  21. },
  22. USER_ROLES,
  23. };
  24. });
  25. vi.mock("bcryptjs", () => {
  26. const hash = vi.fn();
  27. return {
  28. default: { hash },
  29. hash,
  30. };
  31. });
  32. import { getSession } from "@/lib/auth/session";
  33. import { getDb } from "@/lib/db";
  34. import User from "@/models/user";
  35. import { hash as bcryptHash } from "bcryptjs";
  36. import { GET, POST, dynamic } from "./route.js";
  37. function buildCursor(lastId) {
  38. return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
  39. "base64url",
  40. );
  41. }
  42. function buildOffsetCursor(sort, offset) {
  43. return Buffer.from(JSON.stringify({ v: 2, sort, offset }), "utf8").toString(
  44. "base64url",
  45. );
  46. }
  47. function createRequestStub(body) {
  48. return {
  49. async json() {
  50. return body;
  51. },
  52. };
  53. }
  54. describe("GET /api/admin/users", () => {
  55. beforeEach(() => {
  56. vi.clearAllMocks();
  57. getDb.mockResolvedValue({});
  58. });
  59. it('exports dynamic="force-dynamic"', () => {
  60. expect(dynamic).toBe("force-dynamic");
  61. });
  62. it("returns 401 when unauthenticated", async () => {
  63. getSession.mockResolvedValue(null);
  64. const res = await GET(new Request("http://localhost/api/admin/users"));
  65. expect(res.status).toBe(401);
  66. expect(await res.json()).toEqual({
  67. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  68. });
  69. });
  70. it("returns 403 when authenticated but not allowed (admin)", async () => {
  71. getSession.mockResolvedValue({
  72. userId: "u1",
  73. role: "admin",
  74. branchId: null,
  75. email: "admin@example.com",
  76. });
  77. const res = await GET(new Request("http://localhost/api/admin/users"));
  78. expect(res.status).toBe(403);
  79. expect(await res.json()).toEqual({
  80. error: {
  81. message: "Forbidden",
  82. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  83. },
  84. });
  85. expect(User.find).not.toHaveBeenCalled();
  86. });
  87. it("returns 200 with items and nextCursor (superadmin, limit + cursor)", async () => {
  88. getSession.mockResolvedValue({
  89. userId: "u2",
  90. role: "superadmin",
  91. branchId: null,
  92. email: "superadmin@example.com",
  93. });
  94. const d1 = {
  95. _id: "507f1f77bcf86cd799439013",
  96. username: "u3",
  97. email: "u3@example.com",
  98. role: "admin",
  99. branchId: null,
  100. mustChangePassword: false,
  101. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  102. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  103. };
  104. const d2 = {
  105. _id: "507f1f77bcf86cd799439012",
  106. username: "u2",
  107. email: "u2@example.com",
  108. role: "branch",
  109. branchId: "NL01",
  110. mustChangePassword: true,
  111. createdAt: new Date("2026-02-01T09:00:00.000Z"),
  112. updatedAt: new Date("2026-02-02T09:00:00.000Z"),
  113. };
  114. const d3 = {
  115. _id: "507f1f77bcf86cd799439011",
  116. username: "u1",
  117. email: "u1@example.com",
  118. role: "dev",
  119. branchId: null,
  120. mustChangePassword: false,
  121. createdAt: new Date("2026-02-01T08:00:00.000Z"),
  122. updatedAt: new Date("2026-02-02T08:00:00.000Z"),
  123. };
  124. const chain = {
  125. sort: vi.fn().mockReturnThis(),
  126. limit: vi.fn().mockReturnThis(),
  127. select: vi.fn().mockReturnThis(),
  128. exec: vi.fn().mockResolvedValue([d1, d2, d3]),
  129. };
  130. User.find.mockReturnValue(chain);
  131. const res = await GET(
  132. new Request("http://localhost/api/admin/users?limit=2"),
  133. );
  134. expect(res.status).toBe(200);
  135. expect(chain.sort).toHaveBeenCalledWith({ _id: -1 });
  136. expect(chain.limit).toHaveBeenCalledWith(3); // limit + 1
  137. const body = await res.json();
  138. expect(body.items).toHaveLength(2);
  139. expect(body.items[0]).toMatchObject({
  140. id: "507f1f77bcf86cd799439013",
  141. username: "u3",
  142. email: "u3@example.com",
  143. role: "admin",
  144. branchId: null,
  145. mustChangePassword: false,
  146. });
  147. expect(body.items[1]).toMatchObject({
  148. id: "507f1f77bcf86cd799439012",
  149. username: "u2",
  150. email: "u2@example.com",
  151. role: "branch",
  152. branchId: "NL01",
  153. mustChangePassword: true,
  154. });
  155. expect(body.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
  156. });
  157. it("returns 400 for invalid sort parameter", async () => {
  158. getSession.mockResolvedValue({
  159. userId: "u2",
  160. role: "superadmin",
  161. branchId: null,
  162. email: "superadmin@example.com",
  163. });
  164. const res = await GET(
  165. new Request("http://localhost/api/admin/users?sort=unknown"),
  166. );
  167. expect(res.status).toBe(400);
  168. expect(await res.json()).toEqual({
  169. error: {
  170. message: "Invalid sort",
  171. code: "VALIDATION_INVALID_FIELD",
  172. details: {
  173. field: "sort",
  174. value: "unknown",
  175. allowed: ["default", "role_rights", "branch_asc"],
  176. },
  177. },
  178. });
  179. });
  180. it("returns sorted users for sort=role_rights with stable nextCursor", async () => {
  181. getSession.mockResolvedValue({
  182. userId: "u2",
  183. role: "superadmin",
  184. branchId: null,
  185. email: "superadmin@example.com",
  186. });
  187. const docs = [
  188. {
  189. _id: "507f1f77bcf86cd799439011",
  190. username: "branchb",
  191. email: "branchb@example.com",
  192. role: "branch",
  193. branchId: "NL10",
  194. mustChangePassword: false,
  195. },
  196. {
  197. _id: "507f1f77bcf86cd799439012",
  198. username: "admin",
  199. email: "admin@example.com",
  200. role: "admin",
  201. branchId: null,
  202. mustChangePassword: false,
  203. },
  204. {
  205. _id: "507f1f77bcf86cd799439013",
  206. username: "dev",
  207. email: "dev@example.com",
  208. role: "dev",
  209. branchId: null,
  210. mustChangePassword: false,
  211. },
  212. {
  213. _id: "507f1f77bcf86cd799439014",
  214. username: "super",
  215. email: "super@example.com",
  216. role: "superadmin",
  217. branchId: null,
  218. mustChangePassword: false,
  219. },
  220. ];
  221. const chain = {
  222. select: vi.fn().mockReturnThis(),
  223. exec: vi.fn().mockResolvedValue(docs),
  224. };
  225. User.find.mockReturnValue(chain);
  226. const res = await GET(
  227. new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"),
  228. );
  229. expect(res.status).toBe(200);
  230. expect(chain.select).toHaveBeenCalledTimes(1);
  231. expect(chain.exec).toHaveBeenCalledTimes(1);
  232. const body = await res.json();
  233. expect(body.items.map((x) => x.role)).toEqual(["superadmin", "dev"]);
  234. expect(body.nextCursor).toBe(buildOffsetCursor("role_rights", 2));
  235. });
  236. it("returns sorted users for sort=branch_asc with null branches at the end", async () => {
  237. getSession.mockResolvedValue({
  238. userId: "u2",
  239. role: "superadmin",
  240. branchId: null,
  241. email: "superadmin@example.com",
  242. });
  243. const docs = [
  244. {
  245. _id: "507f1f77bcf86cd799439011",
  246. username: "branch10",
  247. email: "branch10@example.com",
  248. role: "branch",
  249. branchId: "NL10",
  250. mustChangePassword: false,
  251. },
  252. {
  253. _id: "507f1f77bcf86cd799439012",
  254. username: "admin",
  255. email: "admin@example.com",
  256. role: "admin",
  257. branchId: null,
  258. mustChangePassword: false,
  259. },
  260. {
  261. _id: "507f1f77bcf86cd799439013",
  262. username: "branch2",
  263. email: "branch2@example.com",
  264. role: "branch",
  265. branchId: "NL2",
  266. mustChangePassword: false,
  267. },
  268. {
  269. _id: "507f1f77bcf86cd799439014",
  270. username: "branch1",
  271. email: "branch1@example.com",
  272. role: "branch",
  273. branchId: "NL01",
  274. mustChangePassword: false,
  275. },
  276. ];
  277. const chain = {
  278. select: vi.fn().mockReturnThis(),
  279. exec: vi.fn().mockResolvedValue(docs),
  280. };
  281. User.find.mockReturnValue(chain);
  282. const res = await GET(
  283. new Request("http://localhost/api/admin/users?sort=branch_asc&limit=10"),
  284. );
  285. expect(res.status).toBe(200);
  286. const body = await res.json();
  287. expect(body.items.map((x) => x.branchId)).toEqual([
  288. "NL01",
  289. "NL2",
  290. "NL10",
  291. null,
  292. ]);
  293. expect(body.nextCursor).toBe(null);
  294. });
  295. it("returns 400 when cursor sort context does not match sort parameter", async () => {
  296. getSession.mockResolvedValue({
  297. userId: "u2",
  298. role: "superadmin",
  299. branchId: null,
  300. email: "superadmin@example.com",
  301. });
  302. const cursor = buildOffsetCursor("role_rights", 2);
  303. const res = await GET(
  304. new Request(
  305. `http://localhost/api/admin/users?sort=branch_asc&cursor=${encodeURIComponent(cursor)}`,
  306. ),
  307. );
  308. expect(res.status).toBe(400);
  309. expect(await res.json()).toEqual({
  310. error: {
  311. message: "Invalid cursor",
  312. code: "VALIDATION_INVALID_FIELD",
  313. details: { field: "cursor" },
  314. },
  315. });
  316. });
  317. it("keeps pagination stable within the same custom sort mode", async () => {
  318. getSession.mockResolvedValue({
  319. userId: "u2",
  320. role: "superadmin",
  321. branchId: null,
  322. email: "superadmin@example.com",
  323. });
  324. const docs = [
  325. {
  326. _id: "507f1f77bcf86cd799439011",
  327. username: "branchb",
  328. email: "branchb@example.com",
  329. role: "branch",
  330. branchId: "NL10",
  331. mustChangePassword: false,
  332. },
  333. {
  334. _id: "507f1f77bcf86cd799439012",
  335. username: "admin",
  336. email: "admin@example.com",
  337. role: "admin",
  338. branchId: null,
  339. mustChangePassword: false,
  340. },
  341. {
  342. _id: "507f1f77bcf86cd799439013",
  343. username: "dev",
  344. email: "dev@example.com",
  345. role: "dev",
  346. branchId: null,
  347. mustChangePassword: false,
  348. },
  349. {
  350. _id: "507f1f77bcf86cd799439014",
  351. username: "super",
  352. email: "super@example.com",
  353. role: "superadmin",
  354. branchId: null,
  355. mustChangePassword: false,
  356. },
  357. ];
  358. const chain = {
  359. select: vi.fn().mockReturnThis(),
  360. exec: vi.fn().mockResolvedValue(docs),
  361. };
  362. User.find.mockReturnValue(chain);
  363. const firstRes = await GET(
  364. new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"),
  365. );
  366. expect(firstRes.status).toBe(200);
  367. const firstBody = await firstRes.json();
  368. expect(firstBody.items.map((x) => x.role)).toEqual(["superadmin", "dev"]);
  369. expect(firstBody.nextCursor).toBe(buildOffsetCursor("role_rights", 2));
  370. const secondRes = await GET(
  371. new Request(
  372. `http://localhost/api/admin/users?sort=role_rights&limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`,
  373. ),
  374. );
  375. expect(secondRes.status).toBe(200);
  376. const secondBody = await secondRes.json();
  377. expect(secondBody.items.map((x) => x.role)).toEqual(["admin", "branch"]);
  378. expect(secondBody.nextCursor).toBe(null);
  379. });
  380. });
  381. describe("POST /api/admin/users", () => {
  382. beforeEach(() => {
  383. vi.clearAllMocks();
  384. getDb.mockResolvedValue({});
  385. bcryptHash.mockResolvedValue("hashed");
  386. });
  387. it("returns 401 when unauthenticated", async () => {
  388. getSession.mockResolvedValue(null);
  389. const res = await POST(createRequestStub({}));
  390. expect(res.status).toBe(401);
  391. expect(await res.json()).toEqual({
  392. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  393. });
  394. });
  395. it("returns 403 when authenticated but not allowed (admin)", async () => {
  396. getSession.mockResolvedValue({
  397. userId: "u1",
  398. role: "admin",
  399. branchId: null,
  400. email: "admin@example.com",
  401. });
  402. const res = await POST(createRequestStub({}));
  403. expect(res.status).toBe(403);
  404. expect(await res.json()).toEqual({
  405. error: {
  406. message: "Forbidden",
  407. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  408. },
  409. });
  410. });
  411. it("returns 400 when JSON parsing fails", async () => {
  412. getSession.mockResolvedValue({
  413. userId: "u2",
  414. role: "superadmin",
  415. branchId: null,
  416. email: "superadmin@example.com",
  417. });
  418. const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
  419. const res = await POST(req);
  420. expect(res.status).toBe(400);
  421. expect(await res.json()).toEqual({
  422. error: {
  423. message: "Invalid request body",
  424. code: "VALIDATION_INVALID_JSON",
  425. },
  426. });
  427. });
  428. it("returns 400 when body is not an object", async () => {
  429. getSession.mockResolvedValue({
  430. userId: "u2",
  431. role: "superadmin",
  432. branchId: null,
  433. email: "superadmin@example.com",
  434. });
  435. const res = await POST(createRequestStub("nope"));
  436. expect(res.status).toBe(400);
  437. expect(await res.json()).toEqual({
  438. error: {
  439. message: "Invalid request body",
  440. code: "VALIDATION_INVALID_BODY",
  441. },
  442. });
  443. });
  444. it("returns 400 when fields are missing", async () => {
  445. getSession.mockResolvedValue({
  446. userId: "u2",
  447. role: "dev",
  448. branchId: null,
  449. email: "dev@example.com",
  450. });
  451. const res = await POST(createRequestStub({}));
  452. expect(res.status).toBe(400);
  453. expect(await res.json()).toEqual({
  454. error: {
  455. message: "Missing required fields",
  456. code: "VALIDATION_MISSING_FIELD",
  457. details: { fields: ["username", "email", "role", "initialPassword"] },
  458. },
  459. });
  460. });
  461. it("returns 400 for invalid role", async () => {
  462. getSession.mockResolvedValue({
  463. userId: "u2",
  464. role: "superadmin",
  465. branchId: null,
  466. email: "superadmin@example.com",
  467. });
  468. const res = await POST(
  469. createRequestStub({
  470. username: "newuser",
  471. email: "new@example.com",
  472. role: "nope",
  473. initialPassword: "StrongPassword123",
  474. }),
  475. );
  476. expect(res.status).toBe(400);
  477. const body = await res.json();
  478. expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
  479. expect(body.error.details.field).toBe("role");
  480. });
  481. it("returns 400 for invalid branchId when role=branch", async () => {
  482. getSession.mockResolvedValue({
  483. userId: "u2",
  484. role: "superadmin",
  485. branchId: null,
  486. email: "superadmin@example.com",
  487. });
  488. const res = await POST(
  489. createRequestStub({
  490. username: "newuser",
  491. email: "new@example.com",
  492. role: "branch",
  493. branchId: "XX1",
  494. initialPassword: "StrongPassword123",
  495. }),
  496. );
  497. expect(res.status).toBe(400);
  498. const body = await res.json();
  499. expect(body.error.code).toBe("VALIDATION_INVALID_FIELD");
  500. expect(body.error.details.field).toBe("branchId");
  501. });
  502. it("returns 400 for weak initialPassword", async () => {
  503. getSession.mockResolvedValue({
  504. userId: "u2",
  505. role: "dev",
  506. branchId: null,
  507. email: "dev@example.com",
  508. });
  509. const res = await POST(
  510. createRequestStub({
  511. username: "newuser",
  512. email: "new@example.com",
  513. role: "admin",
  514. initialPassword: "short1",
  515. }),
  516. );
  517. expect(res.status).toBe(400);
  518. const body = await res.json();
  519. expect(body.error.code).toBe("VALIDATION_WEAK_PASSWORD");
  520. expect(body.error.details).toMatchObject({
  521. minLength: 8,
  522. requireLetter: true,
  523. requireNumber: true,
  524. });
  525. expect(Array.isArray(body.error.details.reasons)).toBe(true);
  526. expect(User.create).not.toHaveBeenCalled();
  527. });
  528. it("returns 400 when username already exists", async () => {
  529. getSession.mockResolvedValue({
  530. userId: "u2",
  531. role: "superadmin",
  532. branchId: null,
  533. email: "superadmin@example.com",
  534. });
  535. User.findOne.mockImplementation((query) => {
  536. if (query?.username) {
  537. return {
  538. select: vi.fn().mockReturnThis(),
  539. exec: vi.fn().mockResolvedValue({ _id: "x" }),
  540. };
  541. }
  542. if (query?.email) {
  543. return {
  544. select: vi.fn().mockReturnThis(),
  545. exec: vi.fn().mockResolvedValue(null),
  546. };
  547. }
  548. return {
  549. select: vi.fn().mockReturnThis(),
  550. exec: vi.fn().mockResolvedValue(null),
  551. };
  552. });
  553. const res = await POST(
  554. createRequestStub({
  555. username: "NewUser",
  556. email: "new@example.com",
  557. role: "admin",
  558. initialPassword: "StrongPassword123",
  559. }),
  560. );
  561. expect(res.status).toBe(400);
  562. expect(await res.json()).toEqual({
  563. error: {
  564. message: "Username already exists",
  565. code: "VALIDATION_INVALID_FIELD",
  566. details: { field: "username" },
  567. },
  568. });
  569. expect(User.create).not.toHaveBeenCalled();
  570. expect(bcryptHash).not.toHaveBeenCalled();
  571. });
  572. it("returns 400 when email already exists", async () => {
  573. getSession.mockResolvedValue({
  574. userId: "u2",
  575. role: "superadmin",
  576. branchId: null,
  577. email: "superadmin@example.com",
  578. });
  579. User.findOne.mockImplementation((query) => {
  580. if (query?.username) {
  581. return {
  582. select: vi.fn().mockReturnThis(),
  583. exec: vi.fn().mockResolvedValue(null),
  584. };
  585. }
  586. if (query?.email) {
  587. return {
  588. select: vi.fn().mockReturnThis(),
  589. exec: vi.fn().mockResolvedValue({ _id: "y" }),
  590. };
  591. }
  592. return {
  593. select: vi.fn().mockReturnThis(),
  594. exec: vi.fn().mockResolvedValue(null),
  595. };
  596. });
  597. const res = await POST(
  598. createRequestStub({
  599. username: "newuser",
  600. email: "NEW@EXAMPLE.COM",
  601. role: "admin",
  602. initialPassword: "StrongPassword123",
  603. }),
  604. );
  605. expect(res.status).toBe(400);
  606. expect(await res.json()).toEqual({
  607. error: {
  608. message: "Email already exists",
  609. code: "VALIDATION_INVALID_FIELD",
  610. details: { field: "email" },
  611. },
  612. });
  613. expect(User.create).not.toHaveBeenCalled();
  614. expect(bcryptHash).not.toHaveBeenCalled();
  615. });
  616. it("returns 400 when username AND email already exist", async () => {
  617. getSession.mockResolvedValue({
  618. userId: "u2",
  619. role: "superadmin",
  620. branchId: null,
  621. email: "superadmin@example.com",
  622. });
  623. User.findOne.mockImplementation((query) => {
  624. if (query?.username) {
  625. return {
  626. select: vi.fn().mockReturnThis(),
  627. exec: vi.fn().mockResolvedValue({ _id: "x" }),
  628. };
  629. }
  630. if (query?.email) {
  631. return {
  632. select: vi.fn().mockReturnThis(),
  633. exec: vi.fn().mockResolvedValue({ _id: "y" }),
  634. };
  635. }
  636. return {
  637. select: vi.fn().mockReturnThis(),
  638. exec: vi.fn().mockResolvedValue(null),
  639. };
  640. });
  641. const res = await POST(
  642. createRequestStub({
  643. username: "newuser",
  644. email: "new@example.com",
  645. role: "admin",
  646. initialPassword: "StrongPassword123",
  647. }),
  648. );
  649. expect(res.status).toBe(400);
  650. expect(await res.json()).toEqual({
  651. error: {
  652. message: "Username and email already exist",
  653. code: "VALIDATION_INVALID_FIELD",
  654. details: { fields: ["username", "email"] },
  655. },
  656. });
  657. expect(User.create).not.toHaveBeenCalled();
  658. expect(bcryptHash).not.toHaveBeenCalled();
  659. });
  660. it("returns 200 and creates user with hashed password + mustChangePassword=true", async () => {
  661. getSession.mockResolvedValue({
  662. userId: "u2",
  663. role: "superadmin",
  664. branchId: null,
  665. email: "superadmin@example.com",
  666. });
  667. // No duplicates
  668. User.findOne.mockImplementation(() => {
  669. return {
  670. select: vi.fn().mockReturnThis(),
  671. exec: vi.fn().mockResolvedValue(null),
  672. };
  673. });
  674. User.create.mockResolvedValue({
  675. _id: "507f1f77bcf86cd799439099",
  676. username: "newuser",
  677. email: "new@example.com",
  678. role: "branch",
  679. branchId: "NL01",
  680. mustChangePassword: true,
  681. createdAt: new Date("2026-02-06T10:00:00.000Z"),
  682. updatedAt: new Date("2026-02-06T10:00:00.000Z"),
  683. });
  684. const res = await POST(
  685. createRequestStub({
  686. username: "NewUser",
  687. email: "NEW@EXAMPLE.COM",
  688. role: "branch",
  689. branchId: "nl01",
  690. initialPassword: "StrongPassword123",
  691. }),
  692. );
  693. expect(res.status).toBe(200);
  694. expect(getDb).toHaveBeenCalledTimes(1);
  695. expect(bcryptHash).toHaveBeenCalledWith("StrongPassword123", 12);
  696. expect(User.findOne).toHaveBeenCalledWith({ username: "newuser" });
  697. expect(User.findOne).toHaveBeenCalledWith({ email: "new@example.com" });
  698. expect(User.create).toHaveBeenCalledWith(
  699. expect.objectContaining({
  700. username: "newuser",
  701. email: "new@example.com",
  702. role: "branch",
  703. branchId: "NL01",
  704. passwordHash: "hashed",
  705. mustChangePassword: true,
  706. }),
  707. );
  708. const body = await res.json();
  709. expect(body).toMatchObject({
  710. ok: true,
  711. user: {
  712. id: "507f1f77bcf86cd799439099",
  713. username: "newuser",
  714. email: "new@example.com",
  715. role: "branch",
  716. branchId: "NL01",
  717. mustChangePassword: true,
  718. },
  719. });
  720. });
  721. });